Redesign the library UI and add scenario cover art

Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.

Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.

Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.

Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().

Two fixes found along the way:

- Importing a scenario bundle with no "tags" key returned a 500. Column
  defaults are not applied until flush, so the attribute was still None
  when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
  "start", which is the only text a freshly created adventure has, so new
  adventures looked empty. Collected as NARRATION_TYPES.

Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
This commit is contained in:
parththakkar106
2026-07-26 13:17:46 +05:30
co-authored by Claude Opus 5
parent 970d71a5b6
commit 57c24a07d8
25 changed files with 1879 additions and 158 deletions
+66
View File
@@ -0,0 +1,66 @@
"""Scenario cover art: inline data URIs in, cacheable URLs out.
A scenario's `image` column holds either an `https://` URL or a base64
`data:image/…` URI (the editor downscales uploads before storing one). Sending
those data URIs inside list responses would balloon them, so lists advertise a
`image_url` pointing at `GET /api/scenarios/{id}/image` instead, and the bytes
are fetched once and cached by the browser.
"""
import base64
import binascii
import re
# Only raster formats a browser renders in an <img>. SVG is deliberately absent:
# it can carry script, and these bytes are served from our own origin.
DATA_URI_RE = re.compile(
r"^data:(image/(?:png|jpeg|webp|gif|avif));base64,([A-Za-z0-9+/=\s]+)$",
re.IGNORECASE,
)
def public_url(scenario_id: int, image: str, version: object) -> str:
"""The URL a client should load for this scenario's art ("" if none).
`version` (any object with a stable repr — normally the row's updated_at)
becomes a cache-buster, letting the image response be marked immutable
while still refreshing the moment the author swaps the picture.
"""
if not image:
return ""
if DATA_URI_RE.match(image):
stamp = int(version.timestamp()) if hasattr(version, "timestamp") else 0
return f"/api/scenarios/{scenario_id}/image?v={stamp}"
# Anything else must be an absolute https URL. http:// is rejected rather
# than passed through: the deployed app is https, so a browser would block
# it as mixed content and the author would just see a broken image.
return image if image.startswith("https://") else ""
def sanitize(value: object, max_length: int) -> str:
"""Coerce an untrusted `image` value from an import bundle to a safe one.
Anything that isn't a supported data URI or an https URL — or that is too
large to store — becomes "", so a hostile or merely foreign bundle can't
smuggle in a `javascript:` URI or blow past the column cap.
"""
if not isinstance(value, str) or not value or len(value) > max_length:
return ""
if DATA_URI_RE.match(value):
return value if decode(value) is not None else ""
return value if value.startswith("https://") else ""
def decode(image: str) -> tuple[bytes, str] | None:
"""`(bytes, content_type)` for a stored data URI, or None if it isn't one."""
match = DATA_URI_RE.match(image or "")
if not match:
return None
try:
# validate=True rejects anything outside the base64 alphabet, so strip
# the newlines a hand-pasted or line-wrapped URI may carry first.
payload = re.sub(r"\s+", "", match.group(2))
return base64.b64decode(payload, validate=True), match.group(1).lower()
except (binascii.Error, ValueError):
# Truncated or hand-edited base64 — treat as "no image" rather than 500.
return None