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:
co-authored by
Claude Opus 5
parent
970d71a5b6
commit
57c24a07d8
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func
|
||||
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 ..database import get_db
|
||||
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
||||
@@ -29,21 +29,89 @@ def get_adventure_or_404(
|
||||
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])
|
||||
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
|
||||
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.Scenario, models.Adventure.scenario_id == models.Scenario.id)
|
||||
.filter(models.Adventure.user_id == user.id)
|
||||
# Group by both PKs: Postgres requires every selected column to be
|
||||
# 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).
|
||||
.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())
|
||||
.all()
|
||||
)
|
||||
narration = _latest_narration(db, [adv.id for adv, *_ in rows])
|
||||
return [
|
||||
schemas.AdventureListItem(
|
||||
id=adv.id,
|
||||
@@ -52,8 +120,13 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs
|
||||
title=adv.title,
|
||||
updated_at=adv.updated_at,
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import auth, limits, models, schemas
|
||||
from .. import auth, images, limits, models, schemas
|
||||
from ..database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
@@ -55,6 +56,30 @@ def get_scenario(
|
||||
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)
|
||||
def update_scenario(
|
||||
scenario_id: int,
|
||||
@@ -109,6 +134,8 @@ def export_scenario(
|
||||
"authorsNote": s.authors_note,
|
||||
"aiInstructions": s.ai_instructions,
|
||||
"tags": s.tags,
|
||||
"image": s.image,
|
||||
"icon": s.icon,
|
||||
"statSchema": s.stat_schema,
|
||||
"storyCards": [
|
||||
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
|
||||
@@ -138,8 +165,8 @@ _SCENARIO_KEYS = {
|
||||
"instructions": "ai_instructions",
|
||||
}
|
||||
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
|
||||
"statSchema", "stat_schema",
|
||||
"createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"}
|
||||
"statSchema", "stat_schema", "image", "icon",
|
||||
"createdAt", "updatedAt", "id", "publicId", "nsfw", "type", "options"}
|
||||
|
||||
|
||||
@router.post("/import", status_code=201)
|
||||
@@ -171,13 +198,24 @@ def import_scenario(
|
||||
if isinstance(schema, dict):
|
||||
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)
|
||||
if not scenario.title:
|
||||
scenario.title = "Imported Scenario"
|
||||
# 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.tags = scenario.tags[:schemas.TAGS_MAX]
|
||||
scenario.tags = (scenario.tags or "")[:schemas.TAGS_MAX]
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user