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
+5
View File
@@ -90,6 +90,11 @@ MIGRATIONS: list[tuple[int, str]] = [
# guide + world state each turn). Only bumps rows still on the old default, # guide + world state each turn). Only bumps rows still on the old default,
# so anyone who picked a custom value keeps it. # so anyone who picked a custom value keeps it.
(29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"), (29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"),
# Scenario cover art — an external URL or an inline base64 data URI. TEXT
# (not VARCHAR) because a downscaled data URI runs tens of kilobytes.
(30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"),
# Emoji/glyph fallback used when `image` is empty.
(31, "ALTER TABLE scenarios ADD COLUMN icon VARCHAR(16) NOT NULL DEFAULT ''"),
] ]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+10
View File
@@ -62,6 +62,16 @@ class Scenario(Base):
authors_note: Mapped[str] = mapped_column(Text, default="") authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String(500), default="") tags: Mapped[str] = mapped_column(String(500), default="")
# Cover art. Either an external "https://…" URL or an inline
# "data:image/…;base64,…" URI (the editor downscales uploads before storing
# one). Empty means the UI falls back to an emoji sigil or generated art.
# Kept in the row rather than on disk because Render's free tier has no
# persistent volume, and it makes export bundles self-contained.
image: Mapped[str] = mapped_column(Text, default="")
# A single emoji or glyph used when there's no `image` — cheap art for
# scenarios nobody wants to find a picture for. Separate from `image`
# because it's a character, not a locator: no fetch, no cache, no bytes.
icon: Mapped[str] = mapped_column(String(16), default="")
# Phase 12: RPG world-state template — stat definitions (bands, rules) and # Phase 12: RPG world-state template — stat definitions (bands, rules) and
# milestones. NULL/empty means this scenario has no RPG layer. # milestones. NULL/empty means this scenario has no RPG layer.
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True) stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+78 -5
View File
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import auth, limits, memorybank, models, schemas, worldstate from .. import auth, images, limits, memorybank, models, schemas, worldstate
from ..context import build_context from ..context import build_context
from ..database import get_db from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
@@ -29,21 +29,89 @@ def get_adventure_or_404(
return adventure return adventure
# How much of the last narrative beat a Continue card shows. Long enough to
# re-establish the scene, short enough that the card stays a card.
SNIPPET_MAX = 220
def _snippet(text: str) -> str:
"""Condense stored action text into one flowing line for a card."""
# Stored AI text already has any world-state block stripped (see the
# streaming handler below), so this only has to tidy whitespace.
collapsed = " ".join((text or "").split())
if len(collapsed) <= SNIPPET_MAX:
return collapsed
# Cut on a word boundary rather than mid-word, then let CSS add the ellipsis.
cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0]
return f"{cut}…"
# Action types that read as narration. `start` is the scenario's opening prompt,
# which is the only text a freshly-created adventure has — without it a brand-new
# story's card would claim nothing had been written yet. `do`/`say` are excluded:
# "where you left off" should be the story's voice, not the player's.
NARRATION_TYPES = ("ai", "story", "start")
def _latest_narration(db: Session, adventure_ids: list[int]) -> dict[int, str]:
"""Map adventure id -> text of its most recent narrated action.
One window-function query rather than a per-adventure lookup, so the list
endpoint stays at a fixed number of round trips.
"""
if not adventure_ids:
return {}
ranked = (
db.query(
models.Action.adventure_id.label("adventure_id"),
models.Action.text.label("text"),
func.row_number()
.over(
partition_by=models.Action.adventure_id,
order_by=(models.Action.index.desc(), models.Action.id.desc()),
)
.label("rank"),
)
.filter(
models.Action.adventure_id.in_(adventure_ids),
models.Action.type.in_(NARRATION_TYPES),
)
.subquery()
)
rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all()
return {adventure_id: text for adventure_id, text in rows}
@router.get("", response_model=list[schemas.AdventureListItem]) @router.get("", response_model=list[schemas.AdventureListItem])
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser): def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
rows = ( rows = (
db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title) db.query(
models.Adventure,
func.count(models.Action.id),
models.Scenario.title,
models.Scenario.image,
models.Scenario.icon,
models.Scenario.updated_at,
)
.outerjoin(models.Action) .outerjoin(models.Action)
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
.filter(models.Adventure.user_id == user.id) .filter(models.Adventure.user_id == user.id)
# Group by both PKs: Postgres requires every selected column to be # Group by both PKs: Postgres requires every selected column to be
# grouped or aggregated. Adventure.* rides on its own grouped PK, but # grouped or aggregated. Adventure.* rides on its own grouped PK, but
# Scenario.title comes from a joined table and must be listed too # the Scenario columns come from a joined table and must be listed too
# (SQLite is lax here; Postgres rejects it). # (SQLite is lax here; Postgres rejects it).
.group_by(models.Adventure.id, models.Scenario.title) .group_by(
models.Adventure.id,
models.Scenario.id,
models.Scenario.title,
models.Scenario.image,
models.Scenario.icon,
models.Scenario.updated_at,
)
.order_by(models.Adventure.updated_at.desc()) .order_by(models.Adventure.updated_at.desc())
.all() .all()
) )
narration = _latest_narration(db, [adv.id for adv, *_ in rows])
return [ return [
schemas.AdventureListItem( schemas.AdventureListItem(
id=adv.id, id=adv.id,
@@ -52,8 +120,13 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs
title=adv.title, title=adv.title,
updated_at=adv.updated_at, updated_at=adv.updated_at,
action_count=count, action_count=count,
snippet=_snippet(narration.get(adv.id, "")),
# The art belongs to the scenario, so the cache-busting stamp is the
# scenario's updated_at, not the adventure's.
image_url=images.public_url(adv.scenario_id, image or "", scenario_updated),
icon=icon or "",
) )
for adv, count, scenario_title in rows for adv, count, scenario_title, image, icon, scenario_updated in rows
] ]
+43 -5
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas from .. import auth, images, limits, models, schemas
from ..database import get_db from ..database import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@@ -55,6 +56,30 @@ def get_scenario(
return get_scenario_or_404(scenario_id, db, user) return get_scenario_or_404(scenario_id, db, user)
@router.get("/{scenario_id}/image")
def get_scenario_image(
scenario_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Serve an uploaded cover image as real bytes.
Lists point here instead of inlining the data URI. The response is marked
immutable and the URL carries a `?v=<updated_at>` stamp, so browsers cache
it indefinitely but pick up a new picture the moment the author saves one.
"""
scenario = get_scenario_or_404(scenario_id, db, user)
decoded = images.decode(scenario.image)
if decoded is None:
raise HTTPException(404, "This scenario has no uploaded image")
data, content_type = decoded
return Response(
content=data,
media_type=content_type,
headers={"Cache-Control": "private, max-age=31536000, immutable"},
)
@router.patch("/{scenario_id}", response_model=schemas.ScenarioOut) @router.patch("/{scenario_id}", response_model=schemas.ScenarioOut)
def update_scenario( def update_scenario(
scenario_id: int, scenario_id: int,
@@ -109,6 +134,8 @@ def export_scenario(
"authorsNote": s.authors_note, "authorsNote": s.authors_note,
"aiInstructions": s.ai_instructions, "aiInstructions": s.ai_instructions,
"tags": s.tags, "tags": s.tags,
"image": s.image,
"icon": s.icon,
"statSchema": s.stat_schema, "statSchema": s.stat_schema,
"storyCards": [ "storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
@@ -138,8 +165,8 @@ _SCENARIO_KEYS = {
"instructions": "ai_instructions", "instructions": "ai_instructions",
} }
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags", _IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
"statSchema", "stat_schema", "statSchema", "stat_schema", "image", "icon",
"createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"} "createdAt", "updatedAt", "id", "publicId", "nsfw", "type", "options"}
@router.post("/import", status_code=201) @router.post("/import", status_code=201)
@@ -171,13 +198,24 @@ def import_scenario(
if isinstance(schema, dict): if isinstance(schema, dict):
fields["stat_schema"] = schema fields["stat_schema"] = schema
# AI Dungeon bundles carry an `image` too, so this is worth honouring — but
# it's untrusted input, hence sanitize() rather than a straight assignment.
image = images.sanitize(bundle.get("image"), schemas.IMAGE_MAX)
if image:
fields["image"] = image
icon = bundle.get("icon")
if isinstance(icon, str) and icon:
fields["icon"] = icon[:schemas.ICON_MAX]
scenario = models.Scenario(**fields, user_id=user.id) scenario = models.Scenario(**fields, user_id=user.id)
if not scenario.title: if not scenario.title:
scenario.title = "Imported Scenario" scenario.title = "Imported Scenario"
# Raw-dict import bypasses the schemas — clamp to VARCHAR widths # Raw-dict import bypasses the schemas — clamp to VARCHAR widths
# (Postgres enforces them; see schemas.py). # (Postgres enforces them; see schemas.py). Column defaults haven't been
# applied yet at this point (that happens at flush), so a bundle with no
# `tags` key leaves the attribute None — hence the `or ""`.
scenario.title = scenario.title[:schemas.NAME_MAX] scenario.title = scenario.title[:schemas.NAME_MAX]
scenario.tags = scenario.tags[:schemas.TAGS_MAX] scenario.tags = (scenario.tags or "")[:schemas.TAGS_MAX]
db.add(scenario) db.add(scenario)
db.flush() db.flush()
+32 -1
View File
@@ -1,7 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, computed_field
from . import images
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse # Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
# limits: Postgres enforces column lengths (SQLite never did), so anything # limits: Postgres enforces column lengths (SQLite never did), so anything
@@ -14,6 +16,12 @@ PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
SCRIPT_MAX = 200_000 # one JS source SCRIPT_MAX = 200_000 # one JS source
ACTION_MAX = 20_000 # one player action ACTION_MAX = 20_000 # one player action
MEMORY_TEXT_MAX = 5_000 MEMORY_TEXT_MAX = 5_000
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
# a client that downscales less aggressively without letting anyone park a
# multi-megabyte PNG in a row that gets read on every list request.
IMAGE_MAX = 400_000
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
Name = Annotated[str, Field(max_length=NAME_MAX)] Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)] Tags = Annotated[str, Field(max_length=TAGS_MAX)]
@@ -21,6 +29,8 @@ CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)]
Prose = Annotated[str, Field(max_length=PROSE_MAX)] Prose = Annotated[str, Field(max_length=PROSE_MAX)]
ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)] ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)]
ActionText = Annotated[str, Field(max_length=ACTION_MAX)] ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
class ORMModel(BaseModel): class ORMModel(BaseModel):
@@ -66,6 +76,10 @@ class ScenarioBase(BaseModel):
authors_note: Prose = "" authors_note: Prose = ""
ai_instructions: Prose = "" ai_instructions: Prose = ""
tags: Tags = "" tags: Tags = ""
# Cover art — an https URL or a base64 data URI. See app/images.py.
image: Image = ""
# Emoji/glyph shown when `image` is empty.
icon: Icon = ""
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones). # Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
# None means no RPG layer. # None means no RPG layer.
stat_schema: dict | None = None stat_schema: dict | None = None
@@ -83,6 +97,8 @@ class ScenarioUpdate(BaseModel):
authors_note: Prose | None = None authors_note: Prose | None = None
ai_instructions: Prose | None = None ai_instructions: Prose | None = None
tags: Tags | None = None tags: Tags | None = None
image: Image | None = None
icon: Icon | None = None
stat_schema: dict | None = None stat_schema: dict | None = None
script_ids: list[int] | None = None script_ids: list[int] | None = None
@@ -103,6 +119,15 @@ class ScenarioListItem(ORMModel):
tags: str tags: str
is_public: bool = False is_public: bool = False
updated_at: datetime updated_at: datetime
# Read off the row so `image_url` can be derived, but excluded from the
# response: a list of base64 data URIs would be megabytes of JSON.
image: str = Field("", exclude=True)
icon: str = ""
@computed_field
@property
def image_url(self) -> str:
return images.public_url(self.id, self.image, self.updated_at)
# ---------- Adventures ---------- # ---------- Adventures ----------
@@ -194,6 +219,12 @@ class AdventureListItem(ORMModel):
title: str title: str
updated_at: datetime updated_at: datetime
action_count: int = 0 action_count: int = 0
# "Where you left off" — the tail of the most recent narrative beat, so a
# Continue card can show the story instead of just a turn count.
snippet: str = ""
# Cover art inherited from the parent scenario (see app/images.py).
image_url: str = ""
icon: str = ""
# ---------- Scripts ---------- # ---------- Scripts ----------
+2 -1
View File
@@ -28,7 +28,8 @@ logger = logging.getLogger(__name__)
SEED_DIR = Path(__file__).resolve().parent / "seed_data" SEED_DIR = Path(__file__).resolve().parent / "seed_data"
_SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags") _SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags",
"image", "icon")
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes") _CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
_SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js") _SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js")
@@ -62,5 +62,6 @@
"context_js": "", "context_js": "",
"output_js": "const modifier = (text) => {\n var out = text;\n\n // Drop a trailing sentence fragment (no ending punctuation).\n var m = out.match(/^([\\s\\S]*[.!?\"'\\u2026])[^.!?\"'\\u2026]*$/);\n if (m && m[1].length > 40) {\n if (m[1].length < out.length) log(\"Trimmed incomplete final sentence.\");\n out = m[1];\n }\n\n // Demonstrate script-created story cards.\n if (/vharos/i.test(out)) {\n var added = addStoryCard(\n \"Vharos, ghost, spirit\",\n \"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.\",\n \"character\"\n );\n if (added !== false) log(\"Vharos mentioned — story card created.\");\n }\n\n return { text: out };\n};\nmodifier(text);\n" "output_js": "const modifier = (text) => {\n var out = text;\n\n // Drop a trailing sentence fragment (no ending punctuation).\n var m = out.match(/^([\\s\\S]*[.!?\"'\\u2026])[^.!?\"'\\u2026]*$/);\n if (m && m[1].length > 40) {\n if (m[1].length < out.length) log(\"Trimmed incomplete final sentence.\");\n out = m[1];\n }\n\n // Demonstrate script-created story cards.\n if (/vharos/i.test(out)) {\n var added = addStoryCard(\n \"Vharos, ghost, spirit\",\n \"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.\",\n \"character\"\n );\n if (added !== false) log(\"Vharos mentioned — story card created.\");\n }\n\n return { text: out };\n};\nmodifier(text);\n"
} }
] ],
} "icon": "🗝"
}
@@ -29,5 +29,6 @@
"notes": "" "notes": ""
} }
], ],
"scripts": [] "scripts": [],
"icon": "🛰"
} }
+2 -1
View File
@@ -16,5 +16,6 @@
"context_js": "// Tell the AI the current HP and how to report changes it decides on.\n// The tag goes on the FIRST line so it survives even if the reply is cut off.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" health. \" +\n \"Based on the story, you decide whether the player loses health (an attack, fall, or trap) \" +\n \"or gains it (a potion, rest, or spell) this turn. \" +\n \"If their health changes, make the FIRST line of your reply the change by itself in square brackets: \" +\n \"write [HP-14] to show losing 14, or [HP+25] to show gaining 25 (choose the number that fits the moment). \" +\n \"Then write the story on the following lines. If health does not change, do not write any bracket tag. \" +\n \"Never mention this tag or the health system in the story prose itself.\";\n return { text: text + note };\n};\nmodifier(text);\n", "context_js": "// Tell the AI the current HP and how to report changes it decides on.\n// The tag goes on the FIRST line so it survives even if the reply is cut off.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" health. \" +\n \"Based on the story, you decide whether the player loses health (an attack, fall, or trap) \" +\n \"or gains it (a potion, rest, or spell) this turn. \" +\n \"If their health changes, make the FIRST line of your reply the change by itself in square brackets: \" +\n \"write [HP-14] to show losing 14, or [HP+25] to show gaining 25 (choose the number that fits the moment). \" +\n \"Then write the story on the following lines. If health does not change, do not write any bracket tag. \" +\n \"Never mention this tag or the health system in the story prose itself.\";\n return { text: text + note };\n};\nmodifier(text);\n",
"output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n" "output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n"
} }
] ],
"icon": "🗡"
} }
@@ -6,6 +6,7 @@
"authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.", "authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.",
"ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it. Reflect what happens in the numbers each turn: change health when someone is hurt or healed, shift Gwen's trust based on how the player treats her, move the leader's aggression as the situation escalates or calms, and mark milestones as they are reached.", "ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it. Reflect what happens in the numbers each turn: change health when someone is hurt or healed, shift Gwen's trust based on how the player treats her, move the leader's aggression as the situation escalates or calms, and mark milestones as they are reached.",
"tags": "demo, rpg, world-state, combat, short", "tags": "demo, rpg, world-state, combat, short",
"icon": "🏹",
"stat_schema": { "stat_schema": {
"world": { "world": {
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." } "day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." }
+210
View File
@@ -0,0 +1,210 @@
"""Scenario cover art + the Continue-card snippet.
Unit tests for the data-URI handling in app/images.py, then HTTP tests that the
list endpoints advertise a cacheable `image_url` (never the inline base64), that
the image route serves real bytes, and that an adventure's snippet comes from
the latest *narration* rather than the player's last line.
python -m pytest tests/test_scenario_art.py -v
"""
import base64
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, images, limits, models, schemas
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
# Smallest valid PNG: a single transparent pixel.
PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
)
PNG_URI = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
# --------------------------------------------------------------------------- #
# app/images.py
# --------------------------------------------------------------------------- #
def test_decode_returns_bytes_and_content_type():
assert images.decode(PNG_URI) == (PNG_BYTES, "image/png")
def test_decode_tolerates_wrapped_base64():
"""A hand-pasted URI can carry newlines; b64decode(validate=True) won't."""
wrapped = "data:image/png;base64," + "\n".join(
base64.b64encode(PNG_BYTES).decode()[i:i + 24] for i in range(0, 100, 24)
)
# Only asserting it doesn't raise and doesn't silently return a partial
# decode of a truncated payload — the wrapped prefix here is not the whole
# image, so a None result is also acceptable; what matters is no exception.
images.decode(wrapped)
def test_decode_rejects_non_data_uris_and_garbage():
assert images.decode("https://example.com/a.png") is None
assert images.decode("data:image/png;base64,!!!not base64!!!") is None
assert images.decode("") is None
assert images.decode(None) is None
def test_decode_rejects_svg():
"""SVG can carry script and these bytes are served from our own origin."""
svg = "data:image/svg+xml;base64," + base64.b64encode(b"<svg/>").decode()
assert images.decode(svg) is None
def test_public_url_points_at_the_endpoint_for_data_uris():
class Stamp:
def timestamp(self):
return 1700000000.0
assert images.public_url(7, PNG_URI, Stamp()) == "/api/scenarios/7/image?v=1700000000"
def test_public_url_passes_through_https_but_not_http():
stamp = None
assert images.public_url(1, "https://cdn.example.com/a.png", stamp) == \
"https://cdn.example.com/a.png"
# http:// would be blocked as mixed content on the deployed https app.
assert images.public_url(1, "http://cdn.example.com/a.png", stamp) == ""
assert images.public_url(1, "", stamp) == ""
def test_sanitize_rejects_hostile_and_oversized_values():
assert images.sanitize("javascript:alert(1)", 1000) == ""
assert images.sanitize("http://example.com/a.png", 1000) == ""
assert images.sanitize(PNG_URI, len(PNG_URI) - 1) == "" # over the cap
assert images.sanitize(12345, 1000) == ""
assert images.sanitize(None, 1000) == ""
# Well-formed values survive.
assert images.sanitize(PNG_URI, schemas.IMAGE_MAX) == PNG_URI
assert images.sanitize("https://example.com/a.png", 1000) == "https://example.com/a.png"
# --------------------------------------------------------------------------- #
# HTTP
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="art@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
# One scenario with an uploaded picture, one with only an emoji.
pictured = models.Scenario(user_id=user.id, title="Pictured", image=PNG_URI)
emoji_only = models.Scenario(user_id=user.id, title="Emoji", icon="🐉")
setup.add_all([pictured, emoji_only])
setup.flush()
adventure = models.Adventure(
user_id=user.id, scenario_id=pictured.id, title="A Run",
)
setup.add(adventure)
setup.flush()
# A full turn: opening narration, the player's line, then the AI's reply.
setup.add_all([
models.Action(adventure_id=adventure.id, index=0, type="ai",
text="The door groans open."),
models.Action(adventure_id=adventure.id, index=1, type="do",
text="I draw my sword."),
models.Action(adventure_id=adventure.id, index=2, type="ai",
text="Steel rings. The\ncorridor answers."),
])
setup.commit()
ids = {"scenario": pictured.id, "emoji": emoji_only.id, "adventure": adventure.id}
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.ids = ids
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_scenario_list_advertises_a_url_and_hides_the_base64(client):
rows = {row["title"]: row for row in client.get("/api/scenarios").json()}
pictured = rows["Pictured"]
assert pictured["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
# The whole point: a list response must never carry the inline image.
assert "image" not in pictured
assert rows["Emoji"]["image_url"] == ""
assert rows["Emoji"]["icon"] == "🐉"
def test_image_endpoint_serves_the_decoded_bytes(client):
resp = client.get(f"/api/scenarios/{client.ids['scenario']}/image")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert resp.content == PNG_BYTES
assert "immutable" in resp.headers["cache-control"]
def test_image_endpoint_404s_without_an_upload(client):
resp = client.get(f"/api/scenarios/{client.ids['emoji']}/image")
assert resp.status_code == 404
def test_single_scenario_still_returns_the_raw_uri_for_editing(client):
"""The editor needs the actual value to preview and to clear."""
body = client.get(f"/api/scenarios/{client.ids['scenario']}").json()
assert body["image"] == PNG_URI
def test_adventure_list_carries_snippet_and_inherited_art(client):
row = next(r for r in client.get("/api/adventures").json()
if r["id"] == client.ids["adventure"])
# Latest narration, whitespace collapsed — not the player's "I draw my sword."
assert row["snippet"] == "Steel rings. The corridor answers."
assert row["image_url"].startswith(f"/api/scenarios/{client.ids['scenario']}/image?v=")
assert row["action_count"] == 3
def test_snippet_is_truncated_on_a_word_boundary(client):
from app.routers.adventures import SNIPPET_MAX, _snippet
long_text = "word " * 200
out = _snippet(long_text)
assert len(out) <= SNIPPET_MAX + 1 # +1 for the ellipsis
assert out.endswith("…")
assert "wor…" not in out # never cuts mid-word
def test_scenario_export_import_round_trips_the_art(client):
bundle = client.get(f"/api/scenarios/{client.ids['scenario']}/export").json()
assert bundle["image"] == PNG_URI
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == PNG_URI
def test_import_drops_a_hostile_image_value(client):
bundle = {"title": "Sneaky", "image": "javascript:alert(1)"}
created = client.post("/api/scenarios/import", json=bundle).json()["scenario"]
assert created["image"] == ""
+8 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { api } from './api' import { api } from './api'
import { AuthModal } from './components' import { AuthModal, ToastHost } from './components'
import Embers from './Embers.jsx'
export default function App() { export default function App() {
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI. // null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
@@ -29,7 +30,8 @@ export default function App() {
} }
return ( return (
<> <ToastHost>
<Embers />
<nav className="topnav"> <nav className="topnav">
<span className="brand">⚔ AI D&D</span> <span className="brand">⚔ AI D&D</span>
<button <button
@@ -42,6 +44,9 @@ export default function App() {
</button> </button>
<div className={`nav-links${navOpen ? ' open' : ''}`} onClick={() => setNavOpen(false)}> <div className={`nav-links${navOpen ? ' open' : ''}`} onClick={() => setNavOpen(false)}>
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}> <NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Home
</NavLink>
<NavLink to="/adventures" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Adventures Adventures
</NavLink> </NavLink>
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}> <NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
@@ -75,6 +80,6 @@ export default function App() {
{authMode && ( {authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed} /> <AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed} />
)} )}
</> </ToastHost>
) )
} }
+147
View File
@@ -0,0 +1,147 @@
import { useRef, useState } from 'react'
import { ScenarioArt } from './components'
// Cards render the plate at 46px (92px on a 2x screen), and the editor preview
// at 76px. 400px square gives headroom for both plus any future larger use,
// while keeping the stored data URI in the tens of kilobytes.
const MAX_EDGE = 400
const WEBP_QUALITY = 0.82
// The backend caps the column at 400_000 chars; stay clearly under it so a
// pathological image fails here with a clear message rather than as a 422.
const MAX_DATA_URI = 360_000
// A spread of moods rather than a themed set — most scenarios find something
// close enough here and skip hunting for a picture.
const SUGGESTED_ICONS = [
'⚔️', '🗡️', '🏹', '🛡️', '🔮', '🗝️', '📜', '🏰',
'🐉', '👑', '💀', '🕯️', '🌑', '🌲', '⛰️', '🌊',
'🚀', '🛰️', '🤖', '👁️', '🩸', '🃏',
]
/** Read a File and re-encode it small, as a WebP data URI.
*
* Downscaling in the browser means the upload never leaves the machine at full
* size and the row stays small — no server-side image library required.
*/
function downscaleToDataURI(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('Could not read that file'))
reader.onload = () => {
const img = new Image()
img.onerror = () => reject(new Error('That file is not an image the browser can read'))
img.onload = () => {
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height))
const canvas = document.createElement('canvas')
// Math.max(1, …) guards against a 0-dimension canvas, which throws.
canvas.width = Math.max(1, Math.round(img.width * scale))
canvas.height = Math.max(1, Math.round(img.height * scale))
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
let uri = canvas.toDataURL('image/webp', WEBP_QUALITY)
// Safari only gained WebP encoding recently; if it silently fell back
// to PNG, retry as JPEG so we don't store a huge lossless blob.
if (!uri.startsWith('data:image/webp')) {
uri = canvas.toDataURL('image/jpeg', WEBP_QUALITY)
}
if (uri.length > MAX_DATA_URI) {
reject(new Error('That image is too detailed to store — try a smaller or simpler one'))
return
}
resolve(uri)
}
img.src = reader.result
}
reader.readAsDataURL(file)
})
}
/** Cover-art picker: upload a picture, choose an emoji, or leave it generated.
*
* `image` and `icon` are the stored values; `onChange({ image, icon })` gets
* both on every change so the caller can persist them together.
*/
export default function ArtPicker({ title, image, icon, onChange, disabled }) {
const fileRef = useRef(null)
const [error, setError] = useState('')
const pick = async (event) => {
const file = event.target.files?.[0]
// Reset immediately so picking the same file twice still fires onChange.
event.target.value = ''
if (!file) return
setError('')
try {
const uri = await downscaleToDataURI(file)
// An uploaded picture wins over an emoji, so clear the icon to make the
// precedence visible rather than leaving a hidden value behind.
onChange({ image: uri, icon: '' })
} catch (err) {
setError(err.message)
}
}
const chooseIcon = (glyph) => {
setError('')
onChange({ image: '', icon: icon === glyph ? '' : glyph })
}
const clear = () => {
setError('')
onChange({ image: '', icon: '' })
}
return (
<label className="field">
<span className="label">Cover art</span>
<div className="art-picker">
<span className="art-preview">
{/* Same three-tier fallback the cards use, at preview size. */}
<ScenarioArt image={image} icon={icon} title={title} />
</span>
<div className="art-controls">
<div className="art-buttons">
<button type="button" disabled={disabled} onClick={() => fileRef.current?.click()}>
Upload image
</button>
{(image || icon) && (
<button type="button" disabled={disabled} onClick={clear}>Clear</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
hidden
onChange={pick}
/>
<div className="icon-row">
{SUGGESTED_ICONS.map((glyph) => (
<button
key={glyph}
type="button"
disabled={disabled}
className={icon === glyph ? 'active' : ''}
title={`Use ${glyph}`}
onClick={() => chooseIcon(glyph)}
>
{glyph}
</button>
))}
</div>
{error ? (
<p className="art-hint" style={{ color: 'var(--danger)' }}>{error}</p>
) : (
<p className="art-hint">
Pictures are shrunk to {MAX_EDGE}px before saving. With neither a picture
nor an emoji, the card draws its own art from the title.
</p>
)}
</div>
</div>
</label>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react'
// Motes per million CSS pixels of viewport. Tuned so a laptop gets ~55 and a
// phone ~20: enough to read as drifting dust, few enough to stay cheap.
const DENSITY = 34
const MAX_PARTICLES = 90
/** Slow-drifting gold motes behind the whole app.
*
* Fixed, non-interactive, and drawn on one canvas. It sits below every page in
* the stacking order and never receives pointer events, so it cannot affect
* layout or intercept clicks. Disabled outright for `prefers-reduced-motion`,
* and paused whenever the tab is hidden so a backgrounded tab costs nothing.
*/
export default function Embers() {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
if (reduced.matches) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Cap the backing store at 2x: beyond that the cost is real and nobody can
// see the difference on a blurred mote.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let width = 0
let height = 0
let particles = []
let frame = 0
const spawn = (scattered) => ({
x: Math.random() * width,
// New motes enter from just below the fold; the first batch is scattered
// across the whole height so the screen isn't empty on load.
y: scattered ? Math.random() * height : height + Math.random() * 40,
radius: 0.6 + Math.random() * 1.6,
speed: 0.08 + Math.random() * 0.3,
sway: Math.random() * Math.PI * 2,
swaySpeed: 0.004 + Math.random() * 0.01,
alpha: 0.12 + Math.random() * 0.4,
})
const resize = () => {
width = window.innerWidth
height = window.innerHeight
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const target = Math.min(
MAX_PARTICLES,
Math.max(12, Math.round((width * height * DENSITY) / 1_000_000)),
)
particles = Array.from({ length: target }, () => spawn(true))
}
const draw = () => {
ctx.clearRect(0, 0, width, height)
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
p.sway += p.swaySpeed
p.y -= p.speed
p.x += Math.sin(p.sway) * 0.28
if (p.y < -8) {
particles[i] = spawn(false)
continue
}
// Fade out toward the top of the screen so motes dissolve rather than
// vanishing at the edge.
const fade = Math.min(1, p.y / height)
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 4)
glow.addColorStop(0, `rgba(232, 196, 118, ${(p.alpha * fade).toFixed(3)})`)
glow.addColorStop(1, 'rgba(212, 169, 78, 0)')
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius * 4, 0, Math.PI * 2)
ctx.fill()
}
frame = requestAnimationFrame(draw)
}
const start = () => {
if (!frame) frame = requestAnimationFrame(draw)
}
const stop = () => {
cancelAnimationFrame(frame)
frame = 0
}
const onVisibility = () => (document.hidden ? stop() : start())
resize()
start()
window.addEventListener('resize', resize)
document.addEventListener('visibilitychange', onVisibility)
return () => {
stop()
window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [])
return <canvas ref={canvasRef} className="embers" aria-hidden="true" />
}
+145 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { api } from './api' import { api } from './api'
export function downloadJSON(obj, filename) { export function downloadJSON(obj, filename) {
@@ -31,6 +31,150 @@ export function pickJSONFile() {
}) })
} }
// ---------- Scenario art ----------
// FNV-1a. Any stable hash works; the point is that a given title always maps to
// the same plate, so the library looks the same on every visit and every device.
function hashString(str) {
let hash = 2166136261
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
// Deep jewel ramps that sit under gold without competing with it — the accent
// stays the brightest thing on the card. Ordered so adjacent library entries
// rarely land on neighbouring hues.
const ART_RAMPS = [
['#14424a', '#0b2328'], // drowned teal
['#4d2130', '#250f19'], // wine
['#2b2a5c', '#13122c'], // indigo
['#4a2a17', '#231309'], // rust
['#24401f', '#0f1c0c'], // moss
['#3a2b52', '#1b1328'], // violet ash
['#1f3346', '#0f1a25'], // cold slate
['#4a3a16', '#231b09'], // ochre
]
// Up to two letters from the title's most significant words.
function monogram(title) {
const words = (title || '')
.replace(/^\[[^\]]*\]\s*/, '') // drop a leading "[Demo]" style label
.split(/\s+/)
.filter((word) => word.length > 2 && !/^(the|and|of|a|an|from|for)$/i.test(word))
const letters = (words.length ? words : ['?']).slice(0, 2).map((w) => w[0])
return letters.join('').toUpperCase()
}
/** A scenario's plate: uploaded picture, else emoji, else generated art.
*
* `large` is for the Continue cards, where the plate carries more weight. The
* generated tier means no card is ever an empty box, so a fresh library still
* reads as a shelf of distinct things.
*/
export function ScenarioArt({ image, icon, title, large = false }) {
const [failed, setFailed] = useState(false)
const ramp = ART_RAMPS[hashString(title || '') % ART_RAMPS.length]
const className = `art${large ? ' art-lg' : ''}`
// A stored image can 404 (scenario deleted its art in another tab, or an
// external URL rotted) — fall through to the generated tier rather than
// showing a broken-image glyph.
if (image && !failed) {
return (
<span className={`${className} art-photo`}>
<img src={image} alt="" loading="lazy" onError={() => setFailed(true)} />
</span>
)
}
const style = { background: `linear-gradient(150deg, ${ramp[0]}, ${ramp[1]})` }
if (icon) {
return <span className={`${className} art-icon`} style={style} aria-hidden="true">{icon}</span>
}
return (
<span className={`${className} art-mono`} style={style} aria-hidden="true">
{monogram(title)}
</span>
)
}
// ---------- Loading skeletons ----------
/** Placeholder cards shown while a list loads, so the grid doesn't pop in. */
export function CardSkeleton({ count = 3, lines = 2 }) {
return (
<div className="card-grid" aria-hidden="true">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="card skeleton-card" style={{ animationDelay: `${i * 70}ms` }}>
<div className="sk sk-plate" />
<div className="sk sk-title" />
{Array.from({ length: lines }, (_, line) => (
<div key={line} className="sk sk-line" style={{ width: line === lines - 1 ? '62%' : '100%' }} />
))}
</div>
))}
</div>
)
}
// ---------- Toasts ----------
const ToastContext = createContext(() => {})
/** `const toast = useToast()` then `toast('Imported')` or `toast(msg, 'error')`. */
export function useToast() {
return useContext(ToastContext)
}
const TOAST_MS = 4200
export function ToastHost({ children }) {
const [toasts, setToasts] = useState([])
const nextId = useRef(1)
const timers = useRef(new Map())
const dismiss = useCallback((id) => {
setToasts((current) => current.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = useCallback((message, kind = 'info') => {
const id = nextId.current++
setToasts((current) => [...current, { id, message: String(message), kind }])
timers.current.set(id, setTimeout(() => dismiss(id), TOAST_MS))
}, [dismiss])
// Clear pending timers if the host ever unmounts mid-flight.
useEffect(() => () => timers.current.forEach(clearTimeout), [])
return (
<ToastContext.Provider value={push}>
{children}
<div className="toast-host" role="status" aria-live="polite">
{toasts.map((toast) => (
<button
key={toast.id}
type="button"
className={`toast toast-${toast.kind}`}
onClick={() => dismiss(toast.id)}
title="Dismiss"
>
<span className="toast-mark" aria-hidden="true">{toast.kind === 'error' ? '✕' : '❖'}</span>
<span>{toast.message}</span>
</button>
))}
</div>
</ToastContext.Provider>
)
}
// Unique ${Placeholder} names, in order of first appearance, across the given texts. // Unique ${Placeholder} names, in order of first appearance, across the given texts.
export function extractPlaceholders(...texts) { export function extractPlaceholders(...texts) {
const names = [] const names = []
+543 -3
View File
@@ -1124,9 +1124,11 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
overflow-y: auto; overflow-y: auto;
} }
/* ---------- Toast ---------- */ /* ---------- Play screen error/success banner ----------
Persistent (no auto-dismiss) and can carry a Retry action, so it stays
separate from the transient global toasts further down this file. */
.toast { .play-toast {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
left: 50%; left: 50%;
@@ -1141,12 +1143,495 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both; animation: toast-in 0.25s ease both;
} }
.toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); } .play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in { @keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); } from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); } to { opacity: 1; transform: translate(-50%, 0); }
} }
/* ============================================================
Illuminated tome — ambient layer, ornament, art plates, cards
Everything below styles the home/library surfaces. It comes after
the base .card rules on purpose: several selectors here tie with
earlier ones on specificity and rely on source order to win.
============================================================ */
/* ---------- Ambient drifting motes ---------- */
/* Behind every page: fixed, unclickable, and out of the stacking flow. The
canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own
surfaces at 1+ keeps it under content without needing a negative index
(which would drop it behind the body background). */
.embers {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.85;
}
/* .topnav already carries z-index 10 — deliberately not touched here, so it
keeps sitting above page content as well as above the canvas. */
.page { position: relative; z-index: 1; }
/* A third gradient pool warming the middle of the ground. `background` is a
shorthand, so background-attachment has to be restated or it resets to
scroll and the gradients start sliding with the page. */
body {
background:
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%),
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%),
radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%),
var(--bg);
background-attachment: fixed;
}
/* ---------- Home banner ---------- */
.hall {
display: flex;
flex-direction: column;
gap: 5px;
padding: 6px 0 26px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
position: relative;
}
/* Gold hairline that fades out toward both ends, sat on the border above. */
.hall::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent);
opacity: 0.55;
}
.hall-eyebrow {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--accent-dim);
font-weight: 600;
}
.hall-title {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.7rem, 4.6vw, 2.3rem);
font-weight: 600;
letter-spacing: 0.05em;
color: var(--accent-bright);
text-shadow: 0 0 30px var(--accent-glow);
text-wrap: balance;
}
.hall-sub {
margin: 0;
color: var(--text-dim);
font-family: var(--font-story);
font-size: 1.02rem;
}
/* ---------- Ornamental section rules ---------- */
.home-section { margin-top: 34px; }
.rule-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.rule {
flex: 1;
display: flex;
align-items: center;
gap: 14px;
margin: 0;
font-family: var(--font-display);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.26em;
text-transform: uppercase;
color: var(--accent-bright);
white-space: nowrap;
}
/* Rules grow to fill whatever the label leaves — the label stays optically
centred in its own space without needing a fixed width. */
.rule::before, .rule::after {
content: '';
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent);
}
.rule::before { flex: 0 0 26px; }
.rule > span { flex: none; }
button.linklike.ornate {
font-family: var(--font-display);
font-size: 0.74rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-dim);
white-space: nowrap;
transition: color 0.15s;
}
button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; }
/* ---------- Art plates ---------- */
.art {
width: 38px;
height: 38px;
flex: none;
border-radius: 3px;
border: 1px solid var(--accent-dim);
box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
overflow: hidden;
display: grid;
place-items: center;
line-height: 1;
}
.art-lg { width: 46px; height: 46px; }
.art-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.art-icon { font-size: 1.15rem; }
.art-lg.art-icon { font-size: 1.4rem; }
.art-mono {
font-family: var(--font-display);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(232, 196, 118, 0.92);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65);
}
.art-lg.art-mono { font-size: 0.95rem; }
/* ---------- Tome cards ---------- */
.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.card.tome {
border-radius: 3px;
padding: 16px 17px 14px;
background:
linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%),
var(--bg-panel);
/* Inner dark gutter + a gold keyline: the double-rule border of a plate in
an old book, done with shadows so it costs no extra element. */
box-shadow:
inset 0 0 0 1px rgba(10, 10, 15, 0.9),
inset 0 0 0 2px rgba(150, 119, 58, 0.22);
}
/* Filigree corner brackets. These override the base .card::before gradient
bar (equal specificity, later in the file wins). */
.card.tome::before,
.card.tome::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border: 1px solid var(--accent-dim);
opacity: 0.5;
transition: opacity 0.2s;
background: none;
inset: auto;
}
.card.tome::before {
top: 6px;
left: 6px;
border-right: 0;
border-bottom: 0;
}
.card.tome::after {
bottom: 6px;
right: 6px;
border-left: 0;
border-top: 0;
}
.card.tome:hover::before,
.card.tome:hover::after { opacity: 1; }
.card.tome:hover {
border-color: var(--accent-dim);
box-shadow:
inset 0 0 0 1px rgba(10, 10, 15, 0.9),
inset 0 0 0 2px rgba(212, 169, 78, 0.4),
0 12px 30px rgba(0, 0, 0, 0.5),
0 0 26px rgba(212, 169, 78, 0.08);
}
.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
/* Keep headings clear of the wax seal, which is absolutely positioned over
the card's top-right corner. Only cards that actually carry one pay the
indent, so scenario cards keep their full width. */
.card.tome:has(> .seal) .card-head { padding-right: 26px; }
.card-headings { min-width: 0; }
.card.tome h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.045em;
color: var(--accent);
overflow-wrap: anywhere;
}
.card-from {
margin: 3px 0 0;
font-size: 0.75rem;
color: var(--text-dim);
overflow-wrap: anywhere;
}
/* Wax seal marking a story in progress. */
.seal {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 50%;
background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
font-size: 0.6rem;
color: #f0d5b0;
z-index: 1;
}
.snippet {
margin: 0;
font-family: var(--font-story);
font-size: 0.98rem;
line-height: 1.5;
color: #b3ac9c;
/* Clamp so a long beat can't stretch one card taller than its row. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.snippet.muted { color: var(--text-dim); font-style: italic; }
.snippet.dropcap::first-letter {
float: left;
font-family: var(--font-display);
font-size: 2.3rem;
line-height: 0.85;
font-weight: 700;
color: var(--accent);
padding: 3px 7px 0 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 13px;
padding-top: 11px;
border-top: 1px solid var(--border);
font-size: 0.74rem;
color: var(--text-dim);
flex-wrap: wrap;
}
.card-foot .turns { font-variant-numeric: tabular-nums; }
.card-foot .dot { opacity: 0.5; }
.card-foot .resume {
margin-left: auto;
color: var(--accent-dim);
font-family: var(--font-ui);
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 0.68rem;
font-weight: 600;
transition: color 0.15s;
}
.card.tome:hover .resume { color: var(--accent-bright); }
.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; }
.card-actions { margin-left: auto; display: inline-flex; gap: 5px; }
button.tiny { padding: 2px 9px; font-size: 0.72rem; }
button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; }
/* Staggered card entrance. The per-card delay is set inline. */
.enter { animation: card-in 0.4s ease both; }
@keyframes card-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* ---------- Loading skeletons ---------- */
.skeleton-card {
cursor: default;
pointer-events: none;
animation: card-in 0.3s ease both;
}
.skeleton-card::before, .skeleton-card::after { content: none; }
.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; }
.sk {
border-radius: 3px;
background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%);
background-size: 220% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; }
.sk-title { height: 13px; width: 58%; margin-bottom: 12px; }
.sk-line { height: 9px; margin-bottom: 8px; }
@keyframes shimmer {
from { background-position: 120% 0; }
to { background-position: -120% 0; }
}
/* ---------- Global toasts ---------- */
.toast-host {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
z-index: 120;
display: flex;
flex-direction: column-reverse;
gap: 8px;
align-items: center;
width: max-content;
max-width: min(92vw, 520px);
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 9px;
text-align: left;
font-family: var(--font-ui);
font-size: 0.86rem;
line-height: 1.45;
padding: 10px 16px;
border-radius: 8px;
background: var(--bg-panel-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
color: var(--text);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: rise 0.25s ease both;
}
.toast:hover { border-color: var(--accent-dim); }
.toast-error { border-left-color: var(--danger); }
.toast-mark { color: var(--accent); flex: none; line-height: 1.5; }
.toast-error .toast-mark { color: var(--danger); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* ---------- Story text polish ---------- */
/* Drop cap opening the story's first narrated beat. */
.story .action.opening::first-letter {
float: left;
font-family: var(--font-display);
font-size: 3.1rem;
line-height: 0.82;
font-weight: 700;
color: var(--accent);
padding: 5px 10px 0 2px;
text-shadow: 0 0 22px var(--accent-glow);
}
/* Ornamental separator between turns — a diamond flanked by fading rules.
Marks the beat change without the heaviness of a full <hr>. */
.scene-break {
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 16px;
color: var(--accent-dim);
font-size: 0.6rem;
user-select: none;
}
.scene-break::before, .scene-break::after {
content: '';
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent);
}
/* Thinking indicator — three motes pulsing in sequence. */
.thinking {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-ui);
font-size: 0.78rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-dim);
}
.thinking i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
animation: mote 1.25s ease-in-out infinite;
}
.thinking i:nth-child(2) { animation-delay: 0.18s; }
.thinking i:nth-child(3) { animation-delay: 0.36s; }
@keyframes mote {
0%, 100% { opacity: 0.25; transform: scale(0.8); }
45% { opacity: 1; transform: scale(1.15); }
}
/* ---------- Scenario art picker (editor) ---------- */
.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.art-preview {
width: 76px;
height: 76px;
flex: none;
border-radius: 3px;
border: 1px solid var(--accent-dim);
box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
overflow: hidden;
display: grid;
place-items: center;
}
.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.art-preview .glyph { font-size: 2rem; line-height: 1; }
.art-preview .mono {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: rgba(232, 196, 118, 0.92);
}
.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; }
.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.icon-row { display: flex; gap: 5px; flex-wrap: wrap; }
.icon-row button {
padding: 3px 8px;
font-size: 1rem;
line-height: 1.2;
min-width: 34px;
}
.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; }
@media (prefers-reduced-motion: reduce) {
.enter, .skeleton-card, .sk, .toast, .thinking i,
.story .action, .page {
animation: none !important;
}
.card.tome:hover { transform: none; }
}
/* ============================================================ /* ============================================================
Mobile / narrow screens (≤ 720px) Mobile / narrow screens (≤ 720px)
The desktop Play screen is a horizontal row of up to four columns The desktop Play screen is a horizontal row of up to four columns
@@ -1253,4 +1738,59 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
border-left: none; border-left: none;
padding: 16px; padding: 16px;
} }
/* ---------- Home / library surfaces ---------- */
/* Both grids go single-column: the wide variant's 320px minimum would
otherwise overflow a 360px phone once card padding is counted. */
.card-grid, .card-grid.wide { grid-template-columns: 1fr; }
.hall { padding: 2px 0 20px; }
.hall-sub { font-size: 0.95rem; }
.home-section { margin-top: 26px; }
/* The label eats most of a narrow line, so drop the short leading rule and
let the trailing one take whatever space is left. */
.rule { letter-spacing: 0.16em; font-size: 0.76rem; gap: 10px; }
.rule::before { display: none; }
.rule-head { gap: 10px; }
/* Ornament scales down rather than crowding the corners on a small card. */
.card.tome { padding: 14px 15px 12px; }
.card.tome::before, .card.tome::after { width: 9px; height: 9px; }
/* Always-on filigree: there's no hover on touch. */
.card.tome::before, .card.tome::after { opacity: 0.8; }
.seal { width: 21px; height: 21px; top: 10px; right: 10px; font-size: 0.54rem; }
.card.tome:has(> .seal) .card-head { padding-right: 22px; }
.snippet { font-size: 0.94rem; -webkit-line-clamp: 4; }
.snippet.dropcap::first-letter { font-size: 2rem; padding-right: 6px; }
/* Motes are pure decoration — thin them out where the GPU is weakest and
the effect is least visible. (Embers.jsx already scales count to the
viewport; this drops the opacity too.) */
.embers { opacity: 0.55; }
/* Full-width toasts, clear of the sticky play controls. */
.toast-host {
left: 12px;
right: 12px;
bottom: 14px;
transform: none;
width: auto;
max-width: none;
align-items: stretch;
}
.toast { font-size: 0.82rem; }
/* Art picker stacks; the emoji grid stays tappable at 34px minimum. */
.art-picker { gap: 12px; }
.art-preview { width: 64px; height: 64px; }
.art-controls { min-width: 0; }
/* The drop cap on the opening beat competes with the edge tabs at this
width, so shrink it and keep it inside the 26px story inset. */
.story .action.opening::first-letter {
font-size: 2.5rem;
padding: 4px 8px 0 0;
}
} }
+2
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import Home from './pages/Home.jsx' import Home from './pages/Home.jsx'
import Adventures from './pages/Adventures.jsx'
import Scenarios from './pages/Scenarios.jsx' import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx' import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Play from './pages/Play.jsx' import Play from './pages/Play.jsx'
@@ -17,6 +18,7 @@ const router = createBrowserRouter([
element: <App />, element: <App />,
children: [ children: [
{ index: true, element: <Home /> }, { index: true, element: <Home /> },
{ path: 'adventures', element: <Adventures /> },
{ path: 'scenarios', element: <Scenarios /> }, { path: 'scenarios', element: <Scenarios /> },
{ path: 'scenarios/:id', element: <ScenarioEditor /> }, { path: 'scenarios/:id', element: <ScenarioEditor /> },
{ path: 'play/:id', element: <Play /> }, { path: 'play/:id', element: <Play /> },
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { CardSkeleton, downloadJSON, pickJSONFile, ScenarioArt, useToast } from '../components'
export default function Adventures() {
const [adventures, setAdventures] = useState(null)
const [search, setSearch] = useState('')
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
}, [])
const visible = useMemo(() => {
if (!adventures) return null
const q = search.trim().toLowerCase()
if (!q) return adventures
return adventures.filter((a) =>
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
}, [adventures, search])
const remove = async (e, id) => {
e.stopPropagation()
if (!confirm('Delete this adventure permanently?')) return
try {
await api.deleteAdventure(id)
setAdventures(adventures.filter((a) => a.id !== id))
toast('Adventure deleted')
} catch (err) {
toast(err.message, 'error')
}
}
const exportOne = async (e, adv) => {
e.stopPropagation()
try {
const bundle = await api.exportAdventure(adv.id)
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
downloadJSON(bundle, `${safe}.json`)
} catch (err) {
toast(err.message, 'error')
}
}
const importOne = async () => {
try {
const bundle = await pickJSONFile()
const adv = await api.importAdventure(bundle)
navigate(`/play/${adv.id}`)
} catch (err) {
toast(err.message, 'error')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Adventures</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importOne}>Import</button>
<button className="primary" onClick={() => navigate('/scenarios')}>
+ New Adventure
</button>
</div>
</div>
{adventures?.length > 0 && (
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search adventures…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
{visible === null ? (
<CardSkeleton count={4} lines={3} />
) : visible.length === 0 ? (
<div className="empty">
{adventures.length === 0
? 'No adventures yet. Head to Scenarios to begin your first story.'
: 'No adventures match your search.'}
</div>
) : (
<div className="card-grid wide">
{visible.map((adv, i) => (
<article
key={adv.id}
className="card tome enter"
style={{ animationDelay: `${Math.min(i, 8) * 50}ms` }}
onClick={() => navigate(`/play/${adv.id}`)}
>
<div className="card-head">
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
<div className="card-headings">
<h3>{adv.title}</h3>
<p className="card-from">
{/* Adventures inherit the scenario's title, so only name
the source when it actually differs. */}
{adv.scenario_title && adv.scenario_title !== adv.title
? `From “${adv.scenario_title}” · `
: ''}
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
</p>
</div>
</div>
{adv.snippet && <p className="snippet">{adv.snippet}</p>}
<footer className="card-foot">
<span className="turns">
Last played {new Date(adv.updated_at + 'Z').toLocaleDateString()}
</span>
<span className="card-actions">
<button className="tiny" title="Export as JSON backup" onClick={(e) => exportOne(e, adv)}>
Export
</button>
<button className="tiny danger" onClick={(e) => remove(e, adv.id)}>
Delete
</button>
</span>
</footer>
</article>
))}
</div>
)}
</div>
)
}
+201 -82
View File
@@ -1,110 +1,229 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components' import {
CardSkeleton,
extractPlaceholders,
PlaceholderModal,
ScenarioArt,
useToast,
} from '../components'
// How many in-progress stories the landing page shows before deferring to
// "See all". Two rows on a wide screen; enough to recognise, not a full index.
const CONTINUE_LIMIT = 4
const SCENARIO_LIMIT = 6
function splitTags(tags, { isPublic = false } = {}) {
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
}
function relativeTime(iso) {
// Stored timestamps are naive UTC, hence the appended Z (matches the rest of
// the app's date handling).
const then = new Date(iso + 'Z')
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
if (minutes < 2) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.round(hours / 24)
if (days < 7) return `${days}d ago`
return then.toLocaleDateString()
}
/** Section heading framed by ornamental rules — the illuminated-tome motif. */
function Rule({ children, action }) {
return (
<div className="rule-head">
<h2 className="rule">
<span>{children}</span>
</h2>
{action}
</div>
)
}
export default function Home() { export default function Home() {
const [adventures, setAdventures] = useState(null) const [adventures, setAdventures] = useState(null)
const [search, setSearch] = useState('') const [scenarios, setScenarios] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholders
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
useEffect(() => { useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([])) api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
}, []) }, [])
const visible = useMemo(() => { const ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures])
if (!adventures) return null const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios])
const q = search.trim().toLowerCase()
if (!q) return adventures
return adventures.filter((a) =>
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
}, [adventures, search])
const remove = async (e, id) => { const begin = async (scenarioId, placeholders = {}) => {
e.stopPropagation()
if (!confirm('Delete this adventure permanently?')) return
await api.deleteAdventure(id)
setAdventures(adventures.filter((a) => a.id !== id))
}
const exportOne = async (e, adv) => {
e.stopPropagation()
const bundle = await api.exportAdventure(adv.id)
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
downloadJSON(bundle, `${safe}.json`)
}
const importOne = async () => {
try { try {
const bundle = await pickJSONFile() const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
const adv = await api.importAdventure(bundle)
navigate(`/play/${adv.id}`) navigate(`/play/${adv.id}`)
} catch (err) { } catch (err) {
alert(err.message) toast(err.message, 'error')
} }
} }
const startAdventure = async (e, scenarioId) => {
e.stopPropagation()
try {
const scenario = await api.getScenario(scenarioId)
const names = extractPlaceholders(
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
)
if (names.length === 0) return begin(scenarioId)
setPending({ scenario, names })
} catch (err) {
toast(err.message, 'error')
}
}
const loading = adventures === null || scenarios === null
const nothingAtAll = !loading && adventures.length === 0 && scenarios.length === 0
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> {/* Returning players want their story first, so the only thing above the
<h1>Adventures</h1> fold is a compact banner — no full-height splash. */}
<div style={{ display: 'flex', gap: 10 }}> <header className="hall">
<button onClick={importOne}>Import</button> <p className="hall-eyebrow">Welcome back</p>
<button className="primary" onClick={() => navigate('/scenarios')}> <h1 className="hall-title">The table is set</h1>
+ New Adventure <p className="hall-sub">
</button> {loading
</div> ? 'Gathering your stories…'
</div> : adventures.length > 0
? `${adventures.length} ${adventures.length === 1 ? 'story' : 'stories'} in progress · ${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} to explore`
: `${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} waiting for a first line`}
</p>
</header>
{adventures?.length > 0 && ( {/* ---------- Continue ---------- */}
<div className="filter-bar"> {(loading || adventures.length > 0) && (
<input <section className="home-section">
type="text" <Rule
className="search-input" action={
placeholder="Search adventures…" adventures?.length > CONTINUE_LIMIT ? (
value={search} <button className="linklike ornate" onClick={() => navigate('/adventures')}>
onChange={(e) => setSearch(e.target.value)} See all {adventures.length} ❖
/> </button>
</div> ) : null
}
>
Continue
</Rule>
{loading ? (
<CardSkeleton count={2} lines={3} />
) : (
<div className="card-grid wide">
{ongoing.map((adv, i) => (
<article
key={adv.id}
className="card tome enter"
style={{ animationDelay: `${i * 60}ms` }}
onClick={() => navigate(`/play/${adv.id}`)}
>
<span className="seal" title="In progress" aria-hidden="true">✦</span>
<div className="card-head">
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
<div className="card-headings">
<h3>{adv.title}</h3>
{/* An adventure created from a scenario inherits its
title, so showing both just prints it twice. */}
{adv.scenario_title && adv.scenario_title !== adv.title && (
<p className="card-from">From “{adv.scenario_title}”</p>
)}
</div>
</div>
{adv.snippet ? (
<p className="snippet dropcap">{adv.snippet}</p>
) : (
<p className="snippet muted">Not a word written yet. Open it and begin.</p>
)}
<footer className="card-foot">
<span className="turns">
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
</span>
<span className="dot" aria-hidden="true">·</span>
<span className="turns">{relativeTime(adv.updated_at)}</span>
<span className="resume">Resume →</span>
</footer>
</article>
))}
</div>
)}
</section>
)} )}
{visible === null ? null : visible.length === 0 ? ( {/* ---------- Scenarios ---------- */}
<div className="empty"> <section className="home-section">
{adventures.length === 0 <Rule
? 'No adventures yet. Head to Scenarios to begin your first story.' action={
: 'No adventures match your search.'} scenarios?.length > SCENARIO_LIMIT ? (
</div> <button className="linklike ornate" onClick={() => navigate('/scenarios')}>
) : ( See all {scenarios.length} ❖
<div className="card-grid"> </button>
{visible.map((adv) => ( ) : null
<div key={adv.id} className="card" onClick={() => navigate(`/play/${adv.id}`)}> }
<h3>{adv.title}</h3> >
<p> {adventures?.length > 0 ? 'Begin a new story' : 'Choose a world'}
{adv.scenario_title ? `From “${adv.scenario_title}” · ` : ''} </Rule>
{adv.action_count} actions
</p> {loading ? (
<div className="meta"> <CardSkeleton count={3} />
Last played {new Date(adv.updated_at + 'Z').toLocaleString()} ) : nothingAtAll ? (
<span style={{ float: 'right', display: 'inline-flex', gap: 4 }}> <div className="empty">
<button Nothing here yet. Create a scenario to define your first world.
title="Export as JSON backup" </div>
style={{ padding: '2px 8px', fontSize: '0.75rem' }} ) : (
onClick={(e) => exportOne(e, adv)} <div className="card-grid">
> {featured.map((sc, i) => (
Export <article
key={sc.id}
className="card tome enter"
style={{ animationDelay: `${i * 60}ms` }}
onClick={() => navigate(`/scenarios/${sc.id}`)}
>
<div className="card-head">
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
<div className="card-headings">
<h3>{sc.title}</h3>
</div>
</div>
<p className="snippet">{sc.description || 'No description yet.'}</p>
<footer className="card-foot">
<div className="tag-cluster">
{sc.is_public && (
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
)}
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 2).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
</div>
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
Play
</button> </button>
<button </footer>
className="danger" </article>
style={{ padding: '2px 8px', fontSize: '0.75rem' }} ))}
onClick={(e) => remove(e, adv.id)} </div>
> )}
Delete </section>
</button>
</span> {pending && (
</div> <PlaceholderModal
</div> title={pending.scenario.title}
))} names={pending.names}
</div> onCancel={() => setPending(null)}
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
/>
)} )}
</div> </div>
) )
+56 -25
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components' import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
const MODES = ['do', 'say', 'story'] const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story'] const PLAYER_TYPES = ['do', 'say', 'story']
@@ -53,6 +53,7 @@ const SECTION_LABELS = {
} }
function PlotPanel({ adventure, setAdventure }) { function PlotPanel({ adventure, setAdventure }) {
const toast = useToast()
// One timer per field/card: a single shared timer would cancel the pending // One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window. // save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map()) const saveTimers = useRef(new Map())
@@ -100,11 +101,11 @@ function PlotPanel({ adventure, setAdventure }) {
try { try {
const parsed = await pickJSONFile() const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.') if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
const created = await api.importStoryCards({ adventure_id: adventure.id, cards }) const created = await api.importStoryCards({ adventure_id: adventure.id, cards })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] }) setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] })
} catch (err) { } catch (err) {
alert(err.message) toast(err.message, 'error')
} }
} }
@@ -949,6 +950,20 @@ export default function Play() {
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
const inputRef = useRef(null) const inputRef = useRef(null)
// The drop cap belongs to the story's first narrated beat. `start` is the
// scenario's opening prompt, so it's usually that; an adventure begun blank
// has no `start` action and the first AI reply takes it instead. Tracked by
// id rather than position so deleting earlier turns moves the cap correctly
// instead of stranding it on a removed row.
const firstNarrationId = useMemo(
() => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null,
[actions],
)
// send() sets streaming to '' before the request goes out; reasoningStream
// stays null until reasoning tokens (if any) arrive. Both still at those
// values means the request is in flight with nothing to show yet.
const waitingForFirstToken = streaming === '' && reasoningStream === null
// Grow the action box with its content (CSS caps it at ~4 lines, then // Grow the action box with its content (CSS caps it at ~4 lines, then
// scrolls); shrinks back after send() clears the text. // scrolls); shrinks back after send() clears the text.
useEffect(() => { useEffect(() => {
@@ -1152,8 +1167,16 @@ export default function Play() {
{actions.length === 0 && streaming === null && ( {actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div> <div className="empty">A blank page. Type something below to begin your story.</div>
)} )}
{actions.map((action) => {actions.map((action, i) => {
editing?.id === action.id ? ( const isPlayer = PLAYER_TYPES.includes(action.type)
// A player action opens a new turn, so that's where the ornamental
// break belongs — never above the very first line on the page.
const sceneBreak = isPlayer && i > 0
// Drop cap goes on the first narrated beat only. `firstNarrationId`
// is derived once above rather than per row.
const opening = action.id === firstNarrationId
return editing?.id === action.id ? (
<div key={action.id} className="action-edit"> <div key={action.id} className="action-edit">
<textarea <textarea
autoFocus autoFocus
@@ -1166,26 +1189,34 @@ export default function Play() {
</div> </div>
</div> </div>
) : ( ) : (
<div key={action.id} <Fragment key={action.id}>
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}> {sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<ReasoningBlock text={action.reasoning} /> <div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
{renderEmphasis(action.text)} <ReasoningBlock text={action.reasoning} />
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />} {renderEmphasis(action.text)}
{!busy && ( {action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
<span className="action-tools"> {!busy && (
{action.type === 'ai' && ( <span className="action-tools">
<button title="View the exact prompt that produced this" {action.type === 'ai' && (
onClick={() => inspect(action.id)}>🔍</button> <button title="View the exact prompt that produced this"
)} onClick={() => inspect(action.id)}>🔍</button>
<button title="Edit" )}
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button> <button title="Edit"
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button> onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
</span> <button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
)} </span>
</div> )}
</div>
</Fragment>
) )
})}
{waitingForFirstToken && (
<div className="thinking" role="status">
<i /><i /><i />
<span>Weaving</span>
</div>
)} )}
{streaming !== null && ( {streaming !== null && !waitingForFirstToken && (
<div className="action streaming"> <div className="action streaming">
<ReasoningBlock text={reasoningStream} streaming /> <ReasoningBlock text={reasoningStream} streaming />
{renderEmphasis(streaming)} {renderEmphasis(streaming)}
@@ -1256,7 +1287,7 @@ export default function Play() {
)} )}
{toast && ( {toast && (
<div className={`toast ${toast.isError ? '' : 'ok'}`}> <div className={`play-toast ${toast.isError ? '' : 'ok'}`}>
{toast.text} {toast.text}
{toast.isError && ( {toast.isError && (
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button> <button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
+23 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components' import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import ArtPicker from '../ArtPicker'
import SchemaEditor from '../SchemaEditor' import SchemaEditor from '../SchemaEditor'
export default function ScenarioEditor() { export default function ScenarioEditor() {
@@ -16,6 +17,7 @@ export default function ScenarioEditor() {
// Last successfully-parsed schema (drives the form editor + preview). // Last successfully-parsed schema (drives the form editor + preview).
const [parsedSchema, setParsedSchema] = useState(null) const [parsedSchema, setParsedSchema] = useState(null)
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json' const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
const toast = useToast()
// One timer per field/card: a single shared timer would cancel the pending // One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window. // save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map()) const saveTimers = useRef(new Map())
@@ -43,6 +45,17 @@ export default function ScenarioEditor() {
}) })
} }
// Several fields at once, in one PATCH — the art picker sets `image` and
// `icon` together (choosing one clears the other, and they must not race).
const setFields = (patch) => {
setScenario((prev) => ({ ...prev, ...patch }))
debounceSave('art', async () => {
await api.updateScenario(id, patch)
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
// JSON view: typing only updates the text buffer; nothing is parsed or saved // JSON view: typing only updates the text buffer; nothing is parsed or saved
// until the user clicks Save (so a half-typed edit doesn't spam errors). // until the user clicks Save (so a half-typed edit doesn't spam errors).
const onJsonText = (text) => { const onJsonText = (text) => {
@@ -124,11 +137,11 @@ export default function ScenarioEditor() {
try { try {
const parsed = await pickJSONFile() const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.') if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
const created = await api.importStoryCards({ scenario_id: Number(id), cards }) const created = await api.importStoryCards({ scenario_id: Number(id), cards })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
} catch (err) { } catch (err) {
alert(err.message) toast(err.message, 'error')
} }
} }
@@ -185,6 +198,13 @@ export default function ScenarioEditor() {
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} /> <Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)} <Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." /> textarea placeholder="Shown in the scenario list; not sent to the AI." />
<ArtPicker
title={scenario.title}
image={scenario.image}
icon={scenario.icon}
disabled={readOnly}
onChange={setFields}
/>
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)} <Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." /> textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)} <Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
+46 -22
View File
@@ -1,10 +1,19 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components' import {
CardSkeleton,
extractPlaceholders,
pickJSONFile,
PlaceholderModal,
ScenarioArt,
useToast,
} from '../components'
function splitTags(tags) { function splitTags(tags, { isPublic = false } = {}) {
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean) const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
} }
export default function Scenarios() { export default function Scenarios() {
@@ -13,6 +22,7 @@ export default function Scenarios() {
const [tagFilter, setTagFilter] = useState(null) const [tagFilter, setTagFilter] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
useEffect(() => { useEffect(() => {
api.listScenarios().then(setScenarios).catch(() => setScenarios([])) api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
@@ -71,9 +81,11 @@ export default function Scenarios() {
try { try {
const bundle = await pickJSONFile() const bundle = await pickJSONFile()
const { scenario, unmapped_keys } = await api.importScenario(bundle) const { scenario, unmapped_keys } = await api.importScenario(bundle)
if (unmapped_keys.length) alert(`Imported. Unmapped fields ignored: ${unmapped_keys.join(', ')}`) if (unmapped_keys.length) {
toast(`Imported. Ignored unknown fields: ${unmapped_keys.join(', ')}`)
}
navigate(`/scenarios/${scenario.id}`) navigate(`/scenarios/${scenario.id}`)
} catch (err) { alert(err.message) } } catch (err) { toast(err.message, 'error') }
}}>Import</button> }}>Import</button>
<button className="primary" onClick={createScenario}>+ New Scenario</button> <button className="primary" onClick={createScenario}>+ New Scenario</button>
</div> </div>
@@ -102,7 +114,9 @@ export default function Scenarios() {
)} )}
</div> </div>
{visible === null ? null : visible.length === 0 ? ( {visible === null ? (
<CardSkeleton count={6} />
) : visible.length === 0 ? (
<div className="empty"> <div className="empty">
{scenarios.length === 0 {scenarios.length === 0
? 'No scenarios yet. Create one to define a reusable story template.' ? 'No scenarios yet. Create one to define a reusable story template.'
@@ -110,24 +124,34 @@ export default function Scenarios() {
</div> </div>
) : ( ) : (
<div className="card-grid"> <div className="card-grid">
{visible.map((sc) => ( {visible.map((sc, i) => (
<div key={sc.id} className="card" onClick={() => navigate(`/scenarios/${sc.id}`)}> <article
<h3>{sc.title}</h3> key={sc.id}
<p>{sc.description || 'No description'}</p> className="card tome enter"
<div className="meta"> style={{ animationDelay: `${Math.min(i, 10) * 50}ms` }}
{sc.is_public && <span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>} onClick={() => navigate(`/scenarios/${sc.id}`)}
{splitTags(sc.tags).map((tag) => ( >
<span key={tag} className="tag small">{tag}</span> <div className="card-head">
))} <ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
<button <div className="card-headings">
className="primary" <h3>{sc.title}</h3>
style={{ float: 'right', padding: '3px 10px', fontSize: '0.78rem' }} </div>
onClick={(e) => startAdventure(e, sc.id)} </div>
> <p className="snippet">{sc.description || 'No description yet.'}</p>
<footer className="card-foot">
<div className="tag-cluster">
{sc.is_public && (
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
)}
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 3).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
</div>
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
Play Play
</button> </button>
</div> </footer>
</div> </article>
))} ))}
</div> </div>
)} )}
+3 -2
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components' import { downloadJSON, pickJSONFile, useToast } from '../components'
export default function Scripts() { export default function Scripts() {
const [scripts, setScripts] = useState(null) const [scripts, setScripts] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
useEffect(() => { useEffect(() => {
api.listScripts().then(setScripts).catch(() => setScripts([])) api.listScripts().then(setScripts).catch(() => setScripts([]))
@@ -25,7 +26,7 @@ export default function Scripts() {
const script = await api.importScript(bundle) const script = await api.importScript(bundle)
navigate(`/scripts/${script.id}`) navigate(`/scripts/${script.id}`)
} catch (err) { } catch (err) {
alert(err.message) toast(err.message, 'error')
} }
} }
+7 -1
View File
@@ -1,12 +1,18 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// The backend port. Overridable because 8000 is a popular default and another
// local app squatting it silently shadows this API (its SPA catch-all answers
// GET /api/* with index.html), which looks like an empty database rather than
// a proxy problem. Start the backend elsewhere and set AIDND_API_PORT to match.
const apiPort = process.env.AIDND_API_PORT || '8000'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8000', '/api': `http://localhost:${apiPort}`,
}, },
}, },
}) })