Initial Daybook MVP
This commit is contained in:
commit
3974276c9b
25 changed files with 1647 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtualenvs
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Django/local runtime
|
||||||
|
db.sqlite3
|
||||||
|
artifacts_store/
|
||||||
|
django-runserver.log
|
||||||
|
django-runserver.err.log
|
||||||
|
|
||||||
|
# Static/build/cache
|
||||||
|
staticfiles/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Secrets/env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# IDE/OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-filterable feed.
|
||||||
|
|
||||||
|
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
|
||||||
|
|
||||||
|
## Current MVP Decisions
|
||||||
|
|
||||||
|
- Use a single shared ingest API token for now.
|
||||||
|
- Require `persona` in every artifact payload.
|
||||||
|
- Do not add reader authentication in the local/self-hosted MVP.
|
||||||
|
- Use random 8-character artifact IDs.
|
||||||
|
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
||||||
|
- Preserve original markdown as `.md` alongside rendered `.html`.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- Planning source: `planning/daybook_planning.md`.
|
||||||
|
- Django project: `daybook_project/`.
|
||||||
|
- Main app: `artifacts/`.
|
||||||
|
- Theme CSS: `static/themes/`.
|
||||||
|
- Feed template: `templates/artifacts/feed.html`.
|
||||||
|
- Stored artifact files live under `artifacts_store/{persona}/{YYYY-MM-DD}/`.
|
||||||
|
|
||||||
|
## Implementation Guidelines
|
||||||
|
|
||||||
|
- Prefer small, direct changes over broad abstractions.
|
||||||
|
- Keep ingest normalization server-side.
|
||||||
|
- The viewer should only need to render HTML, image, or PDF.
|
||||||
|
- Do not make agents responsible for styling, filenames, or storage paths.
|
||||||
|
- Preserve existing planning decisions unless the user explicitly changes them.
|
||||||
|
- Avoid adding backwards-compatibility code before anything has shipped.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Install dependencies: `pip install -r requirements.txt`
|
||||||
|
- Run migrations: `python manage.py migrate`
|
||||||
|
- Start dev server: `python manage.py runserver`
|
||||||
|
- Django checks: `python manage.py check`
|
||||||
|
|
||||||
|
## API Defaults
|
||||||
|
|
||||||
|
- MVP ingest endpoint: `POST /api/artifacts`
|
||||||
|
- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=`
|
||||||
|
- Artifact view: `GET /a/{id}`
|
||||||
|
- Development token: `Authorization: Bearer dev-token`
|
||||||
|
|
||||||
|
## OpenCode
|
||||||
|
|
||||||
|
This project is initialized with `opencode.json`, which loads this file as project instructions. After changing `opencode.json`, `.opencode/`, or this file, restart OpenCode so the updated instructions are loaded.
|
||||||
0
artifacts/__init__.py
Normal file
0
artifacts/__init__.py
Normal file
10
artifacts/admin.py
Normal file
10
artifacts/admin.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Artifact
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Artifact)
|
||||||
|
class ArtifactAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "title", "persona", "kind", "day", "created_at")
|
||||||
|
list_filter = ("persona", "kind", "day")
|
||||||
|
search_fields = ("id", "title", "persona")
|
||||||
6
artifacts/apps.py
Normal file
6
artifacts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "artifacts"
|
||||||
27
artifacts/migrations/0001_initial.py
Normal file
27
artifacts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Artifact",
|
||||||
|
fields=[
|
||||||
|
("id", models.CharField(max_length=8, primary_key=True, serialize=False)),
|
||||||
|
("persona", models.CharField(db_index=True, max_length=80)),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)),
|
||||||
|
("theme", models.CharField(blank=True, max_length=40)),
|
||||||
|
("day", models.DateField(db_index=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("path", models.CharField(max_length=500)),
|
||||||
|
("original_kind", models.CharField(max_length=20)),
|
||||||
|
("tags", models.JSONField(blank=True, default=list)),
|
||||||
|
("source_prompt", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["-created_at"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
artifacts/migrations/__init__.py
Normal file
0
artifacts/migrations/__init__.py
Normal file
39
artifacts/models.py
Normal file
39
artifacts/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Artifact(models.Model):
|
||||||
|
HTML_FRAGMENT = "html_fragment"
|
||||||
|
HTML_FULL = "html_full"
|
||||||
|
MARKDOWN = "markdown"
|
||||||
|
DECK = "deck"
|
||||||
|
IMAGE = "image"
|
||||||
|
PDF = "pdf"
|
||||||
|
BINARY = "binary"
|
||||||
|
|
||||||
|
KIND_CHOICES = [
|
||||||
|
(HTML_FRAGMENT, "HTML fragment"),
|
||||||
|
(HTML_FULL, "Full HTML"),
|
||||||
|
(MARKDOWN, "Markdown"),
|
||||||
|
(DECK, "Deck"),
|
||||||
|
(IMAGE, "Image"),
|
||||||
|
(PDF, "PDF"),
|
||||||
|
(BINARY, "Binary"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.CharField(max_length=8, primary_key=True)
|
||||||
|
persona = models.CharField(max_length=80, db_index=True)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||||
|
theme = models.CharField(max_length=40, blank=True)
|
||||||
|
day = models.DateField(db_index=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
path = models.CharField(max_length=500)
|
||||||
|
original_kind = models.CharField(max_length=20)
|
||||||
|
tags = models.JSONField(default=list, blank=True)
|
||||||
|
source_prompt = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.persona})"
|
||||||
10
artifacts/urls.py
Normal file
10
artifacts/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.feed, name="feed"),
|
||||||
|
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
|
]
|
||||||
343
artifacts/views.py
Normal file
343
artifacts/views.py
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import html
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
from django.template.defaultfilters import slugify
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .models import Artifact
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
|
||||||
|
DEFAULT_THEMES = {
|
||||||
|
Artifact.HTML_FRAGMENT: "report",
|
||||||
|
Artifact.MARKDOWN: "report",
|
||||||
|
Artifact.DECK: "deck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactListCreateView(APIView):
|
||||||
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
artifacts = Artifact.objects.all()
|
||||||
|
if persona := request.query_params.get("persona"):
|
||||||
|
artifacts = artifacts.filter(persona=persona)
|
||||||
|
if day := request.query_params.get("day"):
|
||||||
|
artifacts = artifacts.filter(day=day)
|
||||||
|
if tag := request.query_params.get("tag"):
|
||||||
|
artifacts = artifacts.filter(tags__contains=[tag])
|
||||||
|
|
||||||
|
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not has_valid_token(request):
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
if request.content_type and request.content_type.startswith("multipart/"):
|
||||||
|
artifact = create_binary_artifact(request.data)
|
||||||
|
if isinstance(artifact, Response):
|
||||||
|
return artifact
|
||||||
|
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
kind = data.get("kind")
|
||||||
|
if kind not in TEXT_KINDS:
|
||||||
|
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
||||||
|
|
||||||
|
required = ["persona", "title", "content"]
|
||||||
|
missing = [field for field in required if not data.get(field)]
|
||||||
|
if missing:
|
||||||
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
|
||||||
|
artifact = create_text_artifact(data, request)
|
||||||
|
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
def feed(request):
|
||||||
|
persona = request.GET.get("persona", "")
|
||||||
|
selected_kind = request.GET.get("kind", "")
|
||||||
|
title_query = request.GET.get("q", "")
|
||||||
|
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
|
||||||
|
selected_artifact_id = request.GET.get("artifact", "")
|
||||||
|
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||||
|
all_artifacts = Artifact.objects.all()
|
||||||
|
artifacts = all_artifacts
|
||||||
|
if persona:
|
||||||
|
artifacts = artifacts.filter(persona=persona)
|
||||||
|
if selected_kind:
|
||||||
|
artifacts = artifacts.filter(kind=selected_kind)
|
||||||
|
if title_query:
|
||||||
|
artifacts = artifacts.filter(title__icontains=title_query)
|
||||||
|
|
||||||
|
days = list(artifacts.dates("day", "day", order="DESC"))
|
||||||
|
if selected_day not in days:
|
||||||
|
days.append(selected_day)
|
||||||
|
days.sort(reverse=True)
|
||||||
|
|
||||||
|
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
|
||||||
|
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
|
||||||
|
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
|
||||||
|
if persona:
|
||||||
|
toggle_query["persona"] = persona
|
||||||
|
if selected_kind:
|
||||||
|
toggle_query["kind"] = selected_kind
|
||||||
|
if title_query:
|
||||||
|
toggle_query["q"] = title_query
|
||||||
|
if selected_artifact:
|
||||||
|
toggle_query["artifact"] = selected_artifact.id
|
||||||
|
rendered_url = f"?{urlencode(toggle_query)}"
|
||||||
|
toggle_query["view"] = "raw"
|
||||||
|
raw_url = f"?{urlencode(toggle_query)}"
|
||||||
|
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"artifacts/feed.html",
|
||||||
|
{
|
||||||
|
"artifacts": day_artifacts,
|
||||||
|
"days": days,
|
||||||
|
"personas": personas,
|
||||||
|
"raw_url": raw_url,
|
||||||
|
"rendered_url": rendered_url,
|
||||||
|
"selected_artifact": selected_artifact,
|
||||||
|
"selected_day": selected_day,
|
||||||
|
"selected_kind": selected_kind,
|
||||||
|
"selected_persona": persona,
|
||||||
|
"selected_view": selected_view,
|
||||||
|
"title_query": title_query,
|
||||||
|
"kind_choices": Artifact.KIND_CHOICES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@xframe_options_sameorigin
|
||||||
|
def artifact_view(request, artifact_id):
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id)
|
||||||
|
if request.GET.get("raw") == "1":
|
||||||
|
raw_path = raw_artifact_path(artifact)
|
||||||
|
if raw_path and raw_path.exists():
|
||||||
|
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
|
||||||
|
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
|
||||||
|
|
||||||
|
path = safe_artifact_path(artifact.path)
|
||||||
|
if not path.exists():
|
||||||
|
raise Http404("Artifact file not found")
|
||||||
|
|
||||||
|
content_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def create_text_artifact(data, request):
|
||||||
|
now = timezone.localtime()
|
||||||
|
artifact_id = generate_id()
|
||||||
|
persona = slugify(data["persona"]) or "unknown"
|
||||||
|
kind = data["kind"]
|
||||||
|
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||||
|
title = data["title"]
|
||||||
|
content = data["content"]
|
||||||
|
|
||||||
|
directory = Path(persona) / now.date().isoformat()
|
||||||
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if kind == Artifact.HTML_FULL:
|
||||||
|
rendered = content
|
||||||
|
elif kind == Artifact.MARKDOWN:
|
||||||
|
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
|
||||||
|
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
|
||||||
|
elif kind == Artifact.DECK:
|
||||||
|
rendered = wrap_deck(content, title, request)
|
||||||
|
else:
|
||||||
|
rendered = wrap_html(content, title, theme, request)
|
||||||
|
|
||||||
|
stored_path = directory / f"{artifact_id}.html"
|
||||||
|
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
||||||
|
|
||||||
|
return Artifact.objects.create(
|
||||||
|
id=artifact_id,
|
||||||
|
persona=persona,
|
||||||
|
title=title,
|
||||||
|
kind=kind,
|
||||||
|
theme=theme,
|
||||||
|
day=now.date(),
|
||||||
|
path=stored_path.as_posix(),
|
||||||
|
original_kind=kind,
|
||||||
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
|
source_prompt=data.get("source_prompt", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_binary_artifact(data):
|
||||||
|
uploaded = data.get("file")
|
||||||
|
required = ["persona", "title"]
|
||||||
|
missing = [field for field in required if not data.get(field)]
|
||||||
|
if not uploaded:
|
||||||
|
missing.append("file")
|
||||||
|
if missing:
|
||||||
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
|
||||||
|
now = timezone.localtime()
|
||||||
|
artifact_id = generate_id()
|
||||||
|
persona = slugify(data["persona"]) or "unknown"
|
||||||
|
title = data["title"]
|
||||||
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||||
|
kind = binary_kind(content_type)
|
||||||
|
|
||||||
|
directory = Path(persona) / now.date().isoformat()
|
||||||
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
suffix = safe_suffix(uploaded.name, content_type)
|
||||||
|
stored_path = directory / f"{artifact_id}{suffix}"
|
||||||
|
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||||
|
for chunk in uploaded.chunks():
|
||||||
|
target.write(chunk)
|
||||||
|
|
||||||
|
return Artifact.objects.create(
|
||||||
|
id=artifact_id,
|
||||||
|
persona=persona,
|
||||||
|
title=title,
|
||||||
|
kind=kind,
|
||||||
|
theme="",
|
||||||
|
day=now.date(),
|
||||||
|
path=stored_path.as_posix(),
|
||||||
|
original_kind=kind,
|
||||||
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
|
source_prompt=data.get("source_prompt", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_html(body, title, theme, request):
|
||||||
|
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<link rel="stylesheet" href="{stylesheet}">
|
||||||
|
</head>
|
||||||
|
<body class="artifact">
|
||||||
|
{body}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_deck(body, title, request):
|
||||||
|
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<link rel="stylesheet" href="{stylesheet}">
|
||||||
|
</head>
|
||||||
|
<body class="deck">
|
||||||
|
<main class="slides">{body}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id():
|
||||||
|
while True:
|
||||||
|
artifact_id = secrets.token_hex(4)
|
||||||
|
if not Artifact.objects.filter(pk=artifact_id).exists():
|
||||||
|
return artifact_id
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_token(request):
|
||||||
|
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
return bool(expected) and auth == f"Bearer {expected}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(tags):
|
||||||
|
if isinstance(tags, str):
|
||||||
|
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||||
|
if isinstance(tags, list):
|
||||||
|
return [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def binary_kind(content_type):
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
return Artifact.IMAGE
|
||||||
|
if content_type == "application/pdf":
|
||||||
|
return Artifact.PDF
|
||||||
|
return Artifact.BINARY
|
||||||
|
|
||||||
|
|
||||||
|
def safe_suffix(filename, content_type):
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
|
||||||
|
return suffix
|
||||||
|
return mimetypes.guess_extension(content_type) or ".bin"
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_artifact(artifact, request):
|
||||||
|
return {
|
||||||
|
"id": artifact.id,
|
||||||
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||||
|
"persona": artifact.persona,
|
||||||
|
"title": artifact.title,
|
||||||
|
"kind": artifact.kind,
|
||||||
|
"theme": artifact.theme,
|
||||||
|
"day": artifact.day.isoformat(),
|
||||||
|
"created_at": artifact.created_at.isoformat(),
|
||||||
|
"tags": artifact.tags,
|
||||||
|
"source_prompt": artifact.source_prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_artifact_path(relative_path):
|
||||||
|
root = settings.ARTIFACTS_ROOT.resolve()
|
||||||
|
path = (root / relative_path).resolve()
|
||||||
|
if root not in path.parents and path != root:
|
||||||
|
raise Http404("Invalid artifact path")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def raw_artifact_path(artifact):
|
||||||
|
path = safe_artifact_path(artifact.path)
|
||||||
|
if artifact.kind == Artifact.MARKDOWN:
|
||||||
|
return path.with_suffix(".md")
|
||||||
|
if artifact.kind == Artifact.HTML_FULL:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def raw_unavailable_html(artifact):
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Raw source unavailable</title>
|
||||||
|
<style>
|
||||||
|
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
|
||||||
|
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Raw source unavailable</h1>
|
||||||
|
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
0
daybook_project/__init__.py
Normal file
0
daybook_project/__init__.py
Normal file
8
daybook_project/asgi.py
Normal file
8
daybook_project/asgi.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
70
daybook_project/settings.py
Normal file
70
daybook_project/settings.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = "dev-only-change-me"
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"artifacts",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "daybook_project.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "daybook_project.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
ARTIFACTS_ROOT = BASE_DIR / "artifacts_store"
|
||||||
|
DAYBOOK_API_TOKEN = "dev-token"
|
||||||
8
daybook_project/urls.py
Normal file
8
daybook_project/urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("", include("artifacts.urls")),
|
||||||
|
]
|
||||||
8
daybook_project/wsgi.py
Normal file
8
daybook_project/wsgi.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
14
manage.py
Normal file
14
manage.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
opencode.json
Normal file
5
opencode.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"instructions": ["AGENTS.md"],
|
||||||
|
"permission": "allow"
|
||||||
|
}
|
||||||
216
planning/daybook_planning.md
Normal file
216
planning/daybook_planning.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Daybook — Project Planning
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
**Daybook** is a self-hosted web application where AI agents publish their
|
||||||
|
outputs (task lists, research reports, slide decks, images, etc.) and a human
|
||||||
|
browses them in a **time-ordered, agent-filterable feed** — viewing everything
|
||||||
|
**in-browser without downloading or "opening" files**.
|
||||||
|
|
||||||
|
The defining feature is the integration: an agent calls a REST API and posts
|
||||||
|
*content*, not files. For HTML it can send just a body fragment and Daybook
|
||||||
|
supplies the CSS. The agent never deals with styling, file naming, or storage.
|
||||||
|
|
||||||
|
## Core principles
|
||||||
|
|
||||||
|
- **View, don't download.** Everything renders inline in the browser.
|
||||||
|
- **Agent-agnostic.** Any agent/persona can publish; the human filters by persona.
|
||||||
|
- **Time-ordered.** The primary axis is the day; flipping back to yesterday is trivial.
|
||||||
|
- **Minimal agent burden.** Agents send content + a few metadata fields. The server
|
||||||
|
owns styling, storage layout, IDs, and dates.
|
||||||
|
- **Self-hosted.** A single deployable unit running on the user's own machine/server.
|
||||||
|
- **Dumb viewer, smart ingest.** Normalize every input to three browser-native forms
|
||||||
|
(HTML, image, PDF) at upload time, so the viewer never needs plugins.
|
||||||
|
|
||||||
|
## Actors
|
||||||
|
|
||||||
|
- **Agent (publisher):** an authenticated machine client (e.g. Claude Code, a
|
||||||
|
persona script). POSTs artifacts via REST.
|
||||||
|
- **Human (reader):** browses, filters, and views artifacts; later edits markdown
|
||||||
|
and shares links.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### MVP
|
||||||
|
- REST ingest endpoint accepting: HTML fragment, full HTML, markdown, image, PDF, binary.
|
||||||
|
- Server-side **named CSS themes**; automatic wrapping of HTML fragments.
|
||||||
|
- Storage in a `persona/day/id` layout plus a metadata table.
|
||||||
|
- Web viewer: reverse-chronological feed, persona filter, in-browser rendering.
|
||||||
|
|
||||||
|
### Later
|
||||||
|
- In-browser **markdown editing** with write-back (the name "Daybook" anticipates this).
|
||||||
|
- Slide decks via a **reveal.js** theme; `pptx`/`docx`/`xlsx` → PDF fallback.
|
||||||
|
- **Share links** (password + expiry) for individual artifacts.
|
||||||
|
- Tags, search, and full-text indexing.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent ──POST /api/artifacts──▶ Daybook (Django + DRF)
|
||||||
|
│
|
||||||
|
├─ normalize-on-ingest
|
||||||
|
│ fragment → wrap with theme CSS → .html
|
||||||
|
│ markdown → render → .html
|
||||||
|
│ deck → reveal.js scaffold → .html
|
||||||
|
│ office → LibreOffice headless → .pdf
|
||||||
|
│ image/pdf → store as-is
|
||||||
|
│
|
||||||
|
├─ write file to artifacts/{persona}/{day}/{id}.{ext}
|
||||||
|
└─ insert Artifact row (metadata index)
|
||||||
|
|
||||||
|
Human ──GET / (feed)──▶ list Artifact rows (reverse-chron, persona filter)
|
||||||
|
──GET /a/{id}──▶ render via <iframe> (html/pdf) or <img> (image)
|
||||||
|
```
|
||||||
|
|
||||||
|
The viewer only ever handles **HTML, image, or PDF**, because everything is
|
||||||
|
collapsed into those three at ingest. No client-side office/pptx rendering.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`Artifact`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | str (8-char) | short unique id, used in URLs (`/a/{id}`) |
|
||||||
|
| `persona` | str (indexed) | the agent that published it; primary filter |
|
||||||
|
| `title` | str | display title |
|
||||||
|
| `kind` | str | `html_fragment` \| `html_full` \| `markdown` \| `deck` \| `image` \| `pdf` \| `binary` |
|
||||||
|
| `theme` | str | named CSS theme used at render (nullable) |
|
||||||
|
| `day` | date (indexed) | publication day; feed grouping key |
|
||||||
|
| `created_at` | datetime | exact timestamp |
|
||||||
|
| `path` | str | relative path to stored, normalized file |
|
||||||
|
| `original_kind` | str | what the agent sent, pre-normalization (audit) |
|
||||||
|
| `tags` | json/list | later: filtering/search |
|
||||||
|
| `source_prompt` | text | optional: the prompt that produced it |
|
||||||
|
|
||||||
|
Share fields (`share_token`, `share_expires`, `share_password_hash`) added in the
|
||||||
|
share-links milestone, not MVP.
|
||||||
|
|
||||||
|
## REST API (MVP contract)
|
||||||
|
|
||||||
|
All ingest endpoints require an API token (per-persona; see Open Questions).
|
||||||
|
|
||||||
|
### `POST /api/artifacts` (JSON — text content)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"persona": "hermes",
|
||||||
|
"kind": "html_fragment", // or html_full | markdown | deck
|
||||||
|
"theme": "report", // optional; defaults per kind
|
||||||
|
"title": "Daily Tasks 2026-06-10",
|
||||||
|
"content": "<h1>Today</h1><ul><li>...</li></ul>",
|
||||||
|
"tags": ["tasks"], // optional
|
||||||
|
"source_prompt": "..." // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response: `{ "id": "a1b2c3d4", "url": "/a/a1b2c3d4" }`
|
||||||
|
|
||||||
|
### `POST /api/artifacts` (multipart — binary)
|
||||||
|
Fields: `persona`, `title`, `tags`; file part `file` (image / pdf / office).
|
||||||
|
Office/deck files are converted to PDF on ingest.
|
||||||
|
|
||||||
|
### `GET /api/artifacts?persona=&day=&tag=`
|
||||||
|
Returns metadata list (reverse-chronological). Drives the feed.
|
||||||
|
|
||||||
|
### `GET /a/{id}`
|
||||||
|
Serves the rendered artifact (HTML/PDF/image) for inline viewing.
|
||||||
|
|
||||||
|
## Theming (server-side CSS)
|
||||||
|
|
||||||
|
The server owns styling so agents can send markup only. Themes live as static
|
||||||
|
files and are selected by name in the POST body:
|
||||||
|
|
||||||
|
```
|
||||||
|
static/themes/report.css
|
||||||
|
static/themes/tasks.css
|
||||||
|
static/themes/deck.css # reveal.js theme
|
||||||
|
static/themes/base.css # default
|
||||||
|
```
|
||||||
|
|
||||||
|
Fragment wrapping (conceptual):
|
||||||
|
|
||||||
|
```python
|
||||||
|
WRAP = """<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="/static/themes/{theme}.css">
|
||||||
|
<title>{title}</title></head>
|
||||||
|
<body class="artifact">{body}</body></html>"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- `html_fragment` → wrapped with the chosen theme.
|
||||||
|
- `markdown` → rendered to HTML, then wrapped.
|
||||||
|
- `deck` → wrapped in reveal.js scaffold (agent sends `<section>` blocks).
|
||||||
|
- `html_full` → stored verbatim (agent owns everything).
|
||||||
|
|
||||||
|
## Ingest normalization pipeline
|
||||||
|
|
||||||
|
| Agent sends | Step | Stored as | Viewer |
|
||||||
|
|---|---|---|---|
|
||||||
|
| HTML fragment | wrap + theme | `.html` | iframe |
|
||||||
|
| Full HTML | verbatim | `.html` | iframe |
|
||||||
|
| Markdown | render + wrap | `.html` | iframe |
|
||||||
|
| Deck (sections) | reveal.js wrap | `.html` | iframe |
|
||||||
|
| Image | verbatim | image | `<img>` |
|
||||||
|
| PDF | verbatim | `.pdf` | native / pdf.js |
|
||||||
|
| pptx/docx/xlsx | LibreOffice → PDF | `.pdf` | native / pdf.js |
|
||||||
|
|
||||||
|
## Viewer
|
||||||
|
|
||||||
|
- **Feed view (`/`):** reverse-chronological list grouped by day, with a persona
|
||||||
|
filter (dropdown or sidebar) and, later, tag/search. Each entry shows title,
|
||||||
|
persona, time, and kind.
|
||||||
|
- **Artifact view (`/a/{id}`):** renders inline — `<iframe>` for HTML/PDF,
|
||||||
|
`<img>` for images. No download step required (download remains available).
|
||||||
|
- Markdown edit (later): the stored source markdown is editable in-browser and
|
||||||
|
re-rendered on save; keep the original `.md` alongside the rendered `.html`.
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
```
|
||||||
|
artifacts/
|
||||||
|
{persona}/
|
||||||
|
{YYYY-MM-DD}/
|
||||||
|
{id}.html | {id}.pdf | {id}.png ...
|
||||||
|
{id}.md # kept when source is markdown (for editing)
|
||||||
|
```
|
||||||
|
|
||||||
|
Files on disk stay greppable, portable, and trivially backed up; the DB is an
|
||||||
|
index over them, not the source of truth for content.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Django + Django REST Framework** — API, metadata, viewer.
|
||||||
|
- **SQLite** (dev) → **PostgreSQL** (later) for the metadata index.
|
||||||
|
- **Files on disk** for artifact content.
|
||||||
|
- **LibreOffice headless** in the container for office/deck → PDF.
|
||||||
|
- **markdown** (python-markdown) for md rendering; **reveal.js** for decks.
|
||||||
|
- **pdf.js** optional, only if the native PDF viewer is unsatisfactory.
|
||||||
|
- **Docker** — single image, self-hosted.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
1. **M1 — Core ingest + storage.** `Artifact` model, JSON POST for
|
||||||
|
`html_fragment`/`html_full`/`markdown`, theme wrapping, disk write, `/a/{id}` view.
|
||||||
|
2. **M2 — Viewer.** Feed (`/`) with reverse-chron listing and persona filter;
|
||||||
|
inline iframe/img rendering.
|
||||||
|
3. **M3 — Binary + conversion.** Multipart upload; image/PDF passthrough;
|
||||||
|
LibreOffice headless for office/deck → PDF.
|
||||||
|
4. **M4 — Decks.** reveal.js theme + `deck` kind.
|
||||||
|
5. **M5 — Editing.** In-browser markdown edit + re-render.
|
||||||
|
6. **M6 — Sharing.** Share tokens with password + expiry.
|
||||||
|
7. **M7 — Search/tags.** Tag filter and full-text search.
|
||||||
|
|
||||||
|
## Future features
|
||||||
|
|
||||||
|
- Per-persona dashboards / stats.
|
||||||
|
- Webhooks or "latest artifact" endpoints for chaining agents.
|
||||||
|
- Retention/archival policies per persona.
|
||||||
|
- Multi-user reader auth with per-persona visibility.
|
||||||
|
|
||||||
|
## Open questions / decisions
|
||||||
|
|
||||||
|
- **Output standardization:** push agents toward HTML/markdown/reveal.js
|
||||||
|
(cheap, robust) vs. accept native office files (LibreOffice conversion is the
|
||||||
|
heaviest, flakiest path). Recommendation: prefer the former, keep conversion
|
||||||
|
as a fallback.
|
||||||
|
- **Agent auth:** one API token per persona, or a shared token + persona field?
|
||||||
|
- **Reader auth:** single-user (simplest) vs. multi-user from the start?
|
||||||
|
- **ID scheme:** random short id vs. date-prefixed sortable id.
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Django>=5.0,<6.0
|
||||||
|
djangorestframework>=3.15,<4.0
|
||||||
|
Markdown>=3.6,<4.0
|
||||||
411
static/daybook.css
Normal file
411
static/daybook.css
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #f4f1ea;
|
||||||
|
color: #1d2528;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: center;
|
||||||
|
background: #17313b;
|
||||||
|
color: #fffaf0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-columns: minmax(13rem, 25%) 1fr;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandline {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.38rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandline p {
|
||||||
|
color: #d9e5e8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #e2b66a;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
margin: 0 0 0.18rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.2rem, 2vw, 1.75rem);
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
background: #d9c9ad;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.32rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter label {
|
||||||
|
color: #304149;
|
||||||
|
display: grid;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter select,
|
||||||
|
.filter input {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.32rem 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter input {
|
||||||
|
width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter button {
|
||||||
|
background: #17313b;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fffaf0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.32rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
background: rgba(23, 49, 59, 0.12);
|
||||||
|
border: 1px solid rgba(23, 49, 59, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
padding: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle a {
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #17313b;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
padding: 0.3rem 0.58rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle a.selected {
|
||||||
|
background: #17313b;
|
||||||
|
color: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed {
|
||||||
|
--artifacts-width: 280px;
|
||||||
|
--days-width: 144px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr);
|
||||||
|
min-height: calc(100vh - 3.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-sidebar,
|
||||||
|
.artifact-sidebar,
|
||||||
|
.preview-pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-sidebar {
|
||||||
|
background: #ebe4d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-sidebar {
|
||||||
|
background: #f8f2e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter {
|
||||||
|
background: #d9c9ad;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter::after {
|
||||||
|
background: transparent;
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
left: -4px;
|
||||||
|
position: absolute;
|
||||||
|
right: -4px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter:hover,
|
||||||
|
.is-resizing .splitter {
|
||||||
|
background: #a98d5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-resizing {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-sidebar h2,
|
||||||
|
.pane-heading h2 {
|
||||||
|
color: #68543b;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-list,
|
||||||
|
.artifact-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-link,
|
||||||
|
.artifact-row {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-link {
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-link:hover,
|
||||||
|
.day-link.selected {
|
||||||
|
background: #fffaf0;
|
||||||
|
box-shadow: 0 10px 24px rgba(57, 44, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-link span,
|
||||||
|
.pane-heading span,
|
||||||
|
.artifact-row-main strong {
|
||||||
|
color: #7b6b55;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-link strong {
|
||||||
|
color: #17313b;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-heading {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row {
|
||||||
|
align-items: center;
|
||||||
|
background: #fffaf0;
|
||||||
|
border: 1px solid #e5d8c0;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row:hover {
|
||||||
|
border-color: #c9b38d;
|
||||||
|
box-shadow: 0 12px 28px rgba(57, 44, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.selected {
|
||||||
|
background: #17313b;
|
||||||
|
border-color: #17313b;
|
||||||
|
color: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row.selected .artifact-row-main strong,
|
||||||
|
.artifact-row.selected .artifact-row-main span {
|
||||||
|
color: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-icon {
|
||||||
|
align-items: center;
|
||||||
|
background: #dfe9ea;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #17313b;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 2.25rem;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 800;
|
||||||
|
height: 1.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-image {
|
||||||
|
background: #dfeee2;
|
||||||
|
color: #265336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-pdf {
|
||||||
|
background: #f3dedb;
|
||||||
|
color: #78352f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-markdown {
|
||||||
|
background: #e7e1f2;
|
||||||
|
color: #443265;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-deck {
|
||||||
|
background: #f6e5bf;
|
||||||
|
color: #6e4a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row-main span {
|
||||||
|
color: #17313b;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background: #fffaf0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.compact {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty.compact h3,
|
||||||
|
.preview-empty h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame-wrap {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-actions {
|
||||||
|
align-items: center;
|
||||||
|
background: #d9c9ad;
|
||||||
|
border-top: 1px solid #c7b48f;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.28rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links a,
|
||||||
|
.artifact-action-links button {
|
||||||
|
background: #fffaf0;
|
||||||
|
border: 1px solid rgba(23, 49, 59, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #17313b;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.42rem 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-action-links button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
background: #fff;
|
||||||
|
border: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
background: #fff;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.filter,
|
||||||
|
.artifact-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-sidebar,
|
||||||
|
.artifact-sidebar {
|
||||||
|
border-bottom: 1px solid #d9c9ad;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
static/themes/base.css
Normal file
18
static/themes/base.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
body.artifact {
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #1d2528;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 3rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
table {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
30
static/themes/deck.css
Normal file
30
static/themes/deck.css
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
body.deck {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides section {
|
||||||
|
background: linear-gradient(135deg, #1f2937, #111827);
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
min-height: 62vh;
|
||||||
|
padding: clamp(2rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides h1,
|
||||||
|
.slides h2 {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: clamp(2rem, 7vw, 5rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
41
static/themes/report.css
Normal file
41
static/themes/report.css
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@import url("/static/themes/base.css");
|
||||||
|
|
||||||
|
body.artifact {
|
||||||
|
background: #fffaf0;
|
||||||
|
color: #1e2b2f;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: #17313b;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #8a4f16;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #d5a24c;
|
||||||
|
color: #5b5449;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
background: #efe5d2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
20
static/themes/tasks.css
Normal file
20
static/themes/tasks.css
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
@import url("/static/themes/base.css");
|
||||||
|
|
||||||
|
body.artifact {
|
||||||
|
background: #f7fbff;
|
||||||
|
color: #17202a;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
border-bottom: 3px solid #66a7d8;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
transform: translateY(1px) scale(1.1);
|
||||||
|
}
|
||||||
275
templates/artifacts/feed.html
Normal file
275
templates/artifacts/feed.html
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Daybook</title>
|
||||||
|
<link rel="stylesheet" href="/static/daybook.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brandline">
|
||||||
|
<h1>Daybook</h1>
|
||||||
|
<p>Agent Artifacts</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<form method="get" class="filter">
|
||||||
|
<input type="hidden" name="day" value="{{ selected_day|date:'Y-m-d' }}">
|
||||||
|
<input type="hidden" name="view" value="{{ selected_view }}">
|
||||||
|
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
|
||||||
|
<label>
|
||||||
|
<span>Persona</span>
|
||||||
|
<select name="persona" onchange="this.form.submit()">
|
||||||
|
<option value="">All personas</option>
|
||||||
|
{% for persona in personas %}
|
||||||
|
<option value="{{ persona }}" {% if persona == selected_persona %}selected{% endif %}>{{ persona }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Type</span>
|
||||||
|
<select name="kind" onchange="this.form.submit()">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for value, label in kind_choices %}
|
||||||
|
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="feed">
|
||||||
|
<aside class="day-sidebar" aria-label="Days">
|
||||||
|
<h2>Days</h2>
|
||||||
|
<nav class="day-list">
|
||||||
|
{% for day in days %}
|
||||||
|
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}">
|
||||||
|
<span>{{ day|date:"D" }}</span>
|
||||||
|
<strong>{{ day|date:"Y-m-d" }}</strong>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize days sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
|
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
|
||||||
|
<div class="pane-heading">
|
||||||
|
<h2>{{ selected_day|date:"Y-m-d" }}</h2>
|
||||||
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="artifact-list">
|
||||||
|
{% for artifact in artifacts %}
|
||||||
|
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
|
||||||
|
href="?day={{ selected_day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||||
|
data-artifact-id="{{ artifact.id }}"
|
||||||
|
data-title="{{ artifact.title }}"
|
||||||
|
data-kind="{{ artifact.kind }}"
|
||||||
|
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||||
|
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||||
|
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">
|
||||||
|
{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="artifact-row-main">
|
||||||
|
<strong>{{ artifact.created_at|date:"H:i" }}</strong>
|
||||||
|
<span>{{ artifact.title }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty compact">
|
||||||
|
<h3>No artifacts</h3>
|
||||||
|
<p>This day is ready for new agent output.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="splitter" data-splitter="artifacts" role="separator" aria-label="Resize artifacts sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
|
<section class="preview-pane">
|
||||||
|
{% if selected_artifact %}
|
||||||
|
<div class="preview-frame-wrap">
|
||||||
|
{% if selected_view == "raw" %}
|
||||||
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}?raw=1" title="{{ selected_artifact.title }} raw source"></iframe>
|
||||||
|
{% elif selected_artifact.kind == "image" %}
|
||||||
|
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
||||||
|
{% elif selected_artifact.kind == "binary" %}
|
||||||
|
<div class="empty preview-empty">
|
||||||
|
<h2>Binary Artifact</h2>
|
||||||
|
<p>This artifact type cannot be previewed inline yet.</p>
|
||||||
|
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<iframe class="preview-frame" src="{% url 'artifact' selected_artifact.id %}{% if selected_view == 'raw' %}?raw=1{% endif %}" title="{{ selected_artifact.title }}"></iframe>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<footer class="artifact-actions" data-actions>
|
||||||
|
<nav class="view-toggle" aria-label="Preview mode">
|
||||||
|
<a class="{% if selected_view == 'rendered' %}selected{% endif %}" href="{{ rendered_url }}" data-view="rendered">Rendered</a>
|
||||||
|
<a class="{% if selected_view == 'raw' %}selected{% endif %}" href="{{ raw_url }}" data-view="raw">Raw</a>
|
||||||
|
</nav>
|
||||||
|
<div class="artifact-action-links">
|
||||||
|
<a href="{% url 'artifact' selected_artifact.id %}" target="_blank" rel="noopener">Open</a>
|
||||||
|
<a href="{% url 'artifact' selected_artifact.id %}" download>Download</a>
|
||||||
|
<button type="button" data-copy-url="{% url 'artifact' selected_artifact.id %}">Copy Link</button>
|
||||||
|
<button type="button" disabled>Send</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty preview-empty">
|
||||||
|
<h2>Select an artifact</h2>
|
||||||
|
<p>Choose an item from the middle column to preview it here.</p>
|
||||||
|
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const feed = document.querySelector(".feed");
|
||||||
|
if (!feed) return;
|
||||||
|
let selectedView = "{{ selected_view }}";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
|
||||||
|
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySizes = () => {
|
||||||
|
feed.style.setProperty("--days-width", `${sizes.days}px`);
|
||||||
|
feed.style.setProperty("--artifacts-width", `${sizes.artifacts}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
applySizes();
|
||||||
|
|
||||||
|
document.querySelectorAll(".splitter").forEach((splitter) => {
|
||||||
|
splitter.addEventListener("pointerdown", (event) => {
|
||||||
|
const target = splitter.dataset.splitter;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = sizes[target];
|
||||||
|
splitter.setPointerCapture(event.pointerId);
|
||||||
|
document.body.classList.add("is-resizing");
|
||||||
|
|
||||||
|
const move = (moveEvent) => {
|
||||||
|
sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX));
|
||||||
|
applySizes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = () => {
|
||||||
|
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
|
||||||
|
document.body.classList.remove("is-resizing");
|
||||||
|
splitter.removeEventListener("pointermove", move);
|
||||||
|
splitter.removeEventListener("pointerup", up);
|
||||||
|
splitter.removeEventListener("pointercancel", up);
|
||||||
|
};
|
||||||
|
|
||||||
|
splitter.addEventListener("pointermove", move);
|
||||||
|
splitter.addEventListener("pointerup", up);
|
||||||
|
splitter.addEventListener("pointercancel", up);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewPane = document.querySelector(".preview-pane");
|
||||||
|
const rows = Array.from(document.querySelectorAll(".artifact-row"));
|
||||||
|
|
||||||
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
}[character]));
|
||||||
|
const copyLink = async (button) => {
|
||||||
|
const absoluteUrl = new URL(button.dataset.copyUrl, window.location.origin).toString();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(absoluteUrl);
|
||||||
|
} catch {
|
||||||
|
window.prompt("Copy artifact link", absoluteUrl);
|
||||||
|
}
|
||||||
|
const original = button.textContent;
|
||||||
|
button.textContent = "Copied";
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = original;
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
||||||
|
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||||
|
const pageUrlFor = (row, view) => {
|
||||||
|
const url = new URL(row.href, window.location.origin);
|
||||||
|
url.searchParams.set("artifact", row.dataset.artifactId);
|
||||||
|
url.searchParams.set("view", view);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateActions = (row) => {
|
||||||
|
const actions = document.querySelector("[data-actions]");
|
||||||
|
if (!actions) return;
|
||||||
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||||
|
const view = link.dataset.view;
|
||||||
|
link.classList.toggle("selected", view === selectedView);
|
||||||
|
link.href = pageUrlFor(row, view).toString();
|
||||||
|
});
|
||||||
|
actions.querySelectorAll(".artifact-action-links a").forEach((link) => {
|
||||||
|
link.href = row.dataset.renderedUrl;
|
||||||
|
});
|
||||||
|
const copyButton = actions.querySelector("[data-copy-url]");
|
||||||
|
if (copyButton) copyButton.dataset.copyUrl = row.dataset.renderedUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (row, pushState = true) => {
|
||||||
|
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
||||||
|
const source = urlFor(row, selectedView);
|
||||||
|
const title = escapeHtml(row.dataset.title);
|
||||||
|
const kind = row.dataset.kind;
|
||||||
|
const content = selectedView === "raw"
|
||||||
|
? `<iframe class="preview-frame" src="${source}" title="${title} raw source"></iframe>`
|
||||||
|
: kind === "image"
|
||||||
|
? `<img class="preview-image" src="${source}" alt="${title}">`
|
||||||
|
: kind === "binary"
|
||||||
|
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||||
|
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||||
|
|
||||||
|
let frameWrap = previewPane.querySelector(".preview-frame-wrap");
|
||||||
|
if (!frameWrap) {
|
||||||
|
previewPane.innerHTML = `<div class="preview-frame-wrap"></div><footer class="artifact-actions" data-actions><nav class="view-toggle" aria-label="Preview mode"><a data-view="rendered">Rendered</a><a data-view="raw">Raw</a></nav><div class="artifact-action-links"><a target="_blank" rel="noopener">Open</a><a download>Download</a><button type="button" data-copy-url="">Copy Link</button><button type="button" disabled>Send</button></div></footer>`;
|
||||||
|
frameWrap = previewPane.querySelector(".preview-frame-wrap");
|
||||||
|
}
|
||||||
|
frameWrap.innerHTML = content;
|
||||||
|
updateActions(row);
|
||||||
|
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
renderPreview(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
previewPane.addEventListener("click", (event) => {
|
||||||
|
const copyButton = event.target.closest("[data-copy-url]");
|
||||||
|
if (copyButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
copyLink(copyButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = event.target.closest("[data-view]");
|
||||||
|
if (!toggle) return;
|
||||||
|
const row = selectedRow();
|
||||||
|
if (!row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
selectedView = toggle.dataset.view;
|
||||||
|
renderPreview(row);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue