feat(comic): M2 — UI + Single-Panel-Generierung

Die Datenschicht aus M1 wird jetzt durch UI + gpt-image-2-Flow
benutzbar. Nutzer legt eine Story an (Titel, Stil, Protagonist) und
generiert Panels einzeln über PanelEditor — jeder Panel-Call nutzt
die story-weite Referenz-Liste (face + optional body + optional
Kostüme) plus den stil-spezifischen Prompt-Prefix aus styles.ts.

- `api/generate-panel.ts` → `runPanelGenerate()` wrappt
  `/picture/generate-with-reference` analog zu wardrobe/try-on,
  schreibt picture.images mit `comicStoryId` + `comicPanelIndex`
  Back-Refs und appendet via `comicStoriesStore.appendPanel`. Größe
  defaultet auf 1024×1024 (Quadrat) bzw. 1024×1536 für Webtoon.
- Form-Komponenten: `StylePicker` (5 Presets als Radio-Tiles),
  `CharacterPicker` (face-ref Pflicht, body-ref + bis 3
  Wardrobe-Kostüme optional), `StoryForm` (Titel + Stil + Picker +
  optionaler Kontext).
- Panel-Komponenten: `PanelCard` (Bild + Caption/Dialog-Sidecar),
  `PanelStrip` (responsives Grid 2-4 Spalten), `PanelEditor`
  (inline-Sheet mit Prompt + Caption + Dialog + Quality/Format +
  Generate-Button; zeigt Credits vorher, warnt ab 8 Panels, cappt
  bei 12).
- `StoryCard` rendert Cover aus `panelImageIds[0]` via neuer
  `usePanelImage`-Query, mit Style-Badge und Favorit-Heart.
- `ListView`: Grid + "+ Neue Story"-CTA, Face-Ref-Hinweis wenn
  fehlt, leeres Empty-State-Board.
- `DetailView`: Meta-Card mit VisibilityPicker + Favorit +
  Archive/Delete, PanelStrip, "+ Panel"-CTA öffnet PanelEditor
  inline. Panel-Remove entfernt aus panelImageIds + panelMeta, die
  picture.images-Row bleibt (Final-Delete im Picture-Modul).
- Routes: `/comic` (ListView), `/comic/new` (StoryForm) und
  `/comic/[id]` (DetailView mit {#key id} Re-Mount wie wardrobe).
- i18n: comic-Label in de.json + en.json für RoutePage-Header.
- queries: `usePanelImage(id)` Helper für Cover + Panel-Rendering
  (comic-intern, nicht ins Picture-Modul eingemischt).

Sprechblasen/Captions werden gpt-image-2 per Prompt übergeben und
direkt ins Bild gerendert — kein SVG-Overlay. Englische Texte
rendern stabiler (UI-Hinweis).

Testet per `pnpm run check` + `validate:all` sauber, 5 Encryption-
Tests weiterhin grün.

Kein Batch-Mode (M3), kein AI-Storyboard (M4), keine MCP-Tools (M5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 15:42:27 +02:00
parent 19e0f33665
commit 3551652612
18 changed files with 1638 additions and 2 deletions

View file

@ -34,5 +34,47 @@
"who": "Who",
"events": "Events",
"automations": "Automationen",
"playground": "Playground"
"playground": "Playground",
"kontext": "Web-Kontext",
"news": "News",
"news-research": "News-Recherche",
"articles": "Artikel",
"research-lab": "Recherche-Labor",
"drink": "Trinken",
"recipes": "Rezepte",
"stretch": "Dehnen",
"mail": "E-Mail",
"meditate": "Meditation",
"mood": "Stimmung",
"sleep": "Schlaf",
"myday": "Mein Tag",
"activity": "Aktivität",
"companion": "Begleiter",
"ai-missions": "KI-Missionen",
"ai-agents": "KI-Agenten",
"ai-workbench": "KI-Werkbank",
"rituals": "Rituale",
"ai-policy": "KI-Richtlinien",
"ai-insights": "KI-Einblicke",
"ai-health": "KI-Gesundheit",
"goals": "Ziele",
"credits": "Credits & Abo",
"spiral": "Mana Spirale",
"settings": "Einstellungen",
"themes": "Designs",
"profile": "Profil",
"admin": "Admin",
"complexity": "Komplexität",
"api-keys": "API-Schlüssel",
"wishes": "Wünsche",
"help": "Hilfe",
"wetter": "Wetter",
"feedback": "Feedback",
"wardrobe": "Kleiderschrank",
"library": "Bibliothek",
"spaces": "Bereiche",
"website": "Website",
"quiz": "Quiz",
"guides": "Anleitungen",
"comic": "Comic"
}

View file

@ -34,5 +34,47 @@
"who": "Who",
"events": "Events",
"automations": "Automations",
"playground": "Playground"
"playground": "Playground",
"kontext": "Web Context",
"news": "News",
"news-research": "News Research",
"articles": "Articles",
"research-lab": "Research Lab",
"drink": "Drinks",
"recipes": "Recipes",
"stretch": "Stretch",
"mail": "Mail",
"meditate": "Meditate",
"mood": "Mood",
"sleep": "Sleep",
"myday": "My Day",
"activity": "Activity",
"companion": "Companion",
"ai-missions": "AI Missions",
"ai-agents": "AI Agents",
"ai-workbench": "AI Workbench",
"rituals": "Rituals",
"ai-policy": "AI Policy",
"ai-insights": "AI Insights",
"ai-health": "AI Health",
"goals": "Goals",
"credits": "Credits & Subscription",
"spiral": "Mana Spiral",
"settings": "Settings",
"themes": "Themes",
"profile": "Profile",
"admin": "Admin",
"complexity": "Complexity",
"api-keys": "API Keys",
"wishes": "Wishes",
"help": "Help",
"wetter": "Weather",
"feedback": "Feedback",
"wardrobe": "Wardrobe",
"library": "Library",
"spaces": "Spaces",
"website": "Website",
"quiz": "Quiz",
"guides": "Guides",
"comic": "Comic"
}

View file

@ -0,0 +1,30 @@
<!--
Comic module root — thin wrapper around the story grid. Wardrobe-
style face-banner is kept in ListView.svelte (the list) rather
than here, because creating a story already has its own face-ref
check in CharacterPicker. Module root is as small as possible.
-->
<script lang="ts">
import ListView from './views/ListView.svelte';
</script>
<div class="comic-root">
<ListView />
</div>
<style>
.comic-root {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
padding: 0.5rem 0.75rem 0.75rem;
container-type: inline-size;
}
@container (min-width: 640px) {
.comic-root {
padding: 0.75rem 1rem 1rem;
gap: 1rem;
}
}
</style>

View file

@ -0,0 +1,210 @@
/**
* Panel generation client. Composes a reference-based image-edit call
* against `/api/v1/picture/generate-with-reference` using the story's
* fixed `characterMediaIds` plus the story-wide style-prefix, then
* persists the result into `picture.images` with `comicStoryId` +
* `comicPanelIndex` back-refs and appends the panel to the story via
* `comicStoriesStore.appendPanel`.
*
* Same HTTP shape as `wardrobe/api/try-on.ts` Comics reuse the
* endpoint verbatim. Only difference: character refs come from the
* story row (not reactively from useImageByPrimary), and the result
* goes through appendPanel into the story's ordered panel list.
*
* Plan: docs/plans/comic-module.md M2.
*/
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import { comicStoriesStore } from '../stores/stories.svelte';
import { composePanelPrompt } from '../styles';
import type { ComicPanelMeta, ComicStory } from '../types';
/**
* Panel size. 1024×1024 is the comic-default square panels compose
* into a strip or grid cleanly. 1024×1536 is available for verticaly-
* oriented "Webtoon"-style long shots. The backend supports more but
* M2 keeps the picker small.
*/
export type PanelSize = '1024x1024' | '1024x1536';
export interface RunPanelGenerateParams {
story: ComicStory;
panelPrompt: string;
caption?: string;
dialogue?: string;
/** Tags the panel with the module-entry it was seeded from (M4 AI-
* Storyboard). Ignored in M2 single-panel flow. */
sourceInput?: ComicPanelMeta['sourceInput'];
quality?: 'low' | 'medium' | 'high';
size?: PanelSize;
}
export interface RunPanelGenerateResult {
imageId: string;
imageUrl: string;
prompt: string;
model: string;
panelIndex: number;
}
function dimsForSize(size: PanelSize): { width: number; height: number } {
if (size === '1024x1536') return { width: 1024, height: 1536 };
return { width: 1024, height: 1024 };
}
/**
* Shared low-level POST. Mirrors wardrobe's callGenerateWithReference
* so the error matrix stays identical across the two consumers of
* this endpoint.
*/
async function callGenerateWithReference(opts: {
prompt: string;
referenceMediaIds: string[];
quality: 'low' | 'medium' | 'high';
size: PanelSize;
}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
const token = await authStore.getValidToken();
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
prompt: opts.prompt,
referenceMediaIds: opts.referenceMediaIds,
model: 'openai/gpt-image-2',
quality: opts.quality,
size: opts.size,
n: 1,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
detail?: string;
required?: number;
missing?: string[];
};
if (res.status === 402) {
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
}
if (res.status === 404) {
throw new Error(
'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — prüfe, ob Face/Body in diesem Space existieren.'
);
}
const label = body.error ?? `Panel-Generierung fehlgeschlagen (${res.status})`;
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string }>;
imageUrl?: string;
mediaId?: string;
prompt: string;
model: string;
};
const first =
(data.images && data.images[0]) ??
(data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
if (!first?.imageUrl || !first.mediaId) {
throw new Error('Keine Bilder zurückgegeben');
}
return {
imageUrl: first.imageUrl,
mediaId: first.mediaId,
prompt: data.prompt,
model: data.model,
};
}
/**
* Generate one panel for a story. The story provides the fixed
* reference-image list (face + optional body + optional garments
* chosen once at story-create time); this call only adds the panel
* prompt + caption + dialogue on top of the story's style prefix.
*/
export async function runPanelGenerate(
params: RunPanelGenerateParams
): Promise<RunPanelGenerateResult> {
const { story, panelPrompt, caption, dialogue, sourceInput } = params;
if (story.characterMediaIds.length === 0) {
throw new Error('Story hat keine Character-Referenz — bitte Face-Ref hinterlegen.');
}
if (!panelPrompt.trim()) {
throw new Error('Panel-Prompt ist leer.');
}
// Style-prefix + panelPrompt + caption/dialog hints, composed in
// styles.ts. The backend never sees the style enum — it only sees
// the final prompt string.
const composedPrompt = composePanelPrompt({
style: story.style,
panelPrompt,
caption,
dialogue,
});
const effectiveSize: PanelSize =
params.size ?? (story.style === 'webtoon' ? '1024x1536' : '1024x1024');
const effectiveQuality = params.quality ?? 'medium';
// Cap at 8 references (server limit). If the story somehow has more
// in its characterMediaIds (shouldn't — UI caps at ~5), truncate and
// warn. Face-ref is [0] by convention.
const referenceMediaIds = story.characterMediaIds.slice(0, 8);
const result = await callGenerateWithReference({
prompt: composedPrompt,
referenceMediaIds,
quality: effectiveQuality,
size: effectiveSize,
});
const now = new Date().toISOString();
const localImageId = crypto.randomUUID();
const dims = dimsForSize(effectiveSize);
const panelIndex = story.panelImageIds.length; // zero-based
await imagesStore.insert({
id: localImageId,
prompt: result.prompt,
negativePrompt: null,
model: result.model,
publicUrl: result.imageUrl,
storagePath: result.mediaId,
filename: `comic-panel-${story.id}-${panelIndex + 1}.png`,
format: 'png',
width: dims.width,
height: dims.height,
visibility: 'private',
isFavorite: false,
downloadCount: 0,
generationMode: 'reference',
referenceImageIds: referenceMediaIds,
comicStoryId: story.id,
comicPanelIndex: panelIndex,
createdAt: now,
updatedAt: now,
});
await comicStoriesStore.appendPanel(story.id, localImageId, {
caption: caption?.trim() || undefined,
dialogue: dialogue?.trim() || undefined,
promptUsed: composedPrompt,
sourceInput,
});
return {
imageId: localImageId,
imageUrl: result.imageUrl,
prompt: result.prompt,
model: result.model,
panelIndex,
};
}

View file

@ -0,0 +1,285 @@
<!--
CharacterPicker — selects the reference-image set that every panel
in the story renders against. At minimum: primary face-ref from the
active space's meImages. Optional add-ons:
- primary body-ref (for full-body framing)
- up to 3 wardrobe-garment photos (costume setup)
Mirrors wardrobe's try-on composition (face + body + garments) but
here the list is chosen ONCE at story-create time and fixed on the
story row — every panel uses the same refs for visual continuity.
Outputs: `value: string[]` (mediaIds, face-ref at [0]). Emits via
`onChange` on every add/remove.
-->
<script lang="ts">
import { Plus, X, UserCircle, TShirt } from '@mana/shared-icons';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { useAllGarments } from '$lib/modules/wardrobe/queries';
import { garmentPhotoUrl } from '$lib/modules/wardrobe/api/media-url';
import type { Garment } from '$lib/modules/wardrobe/types';
interface Props {
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
}
let { value, onChange, disabled = false }: Props = $props();
const face$ = useImageByPrimary('face-ref');
const body$ = useImageByPrimary('body-ref');
const garments$ = useAllGarments();
const face = $derived(face$.value);
const body = $derived(body$.value);
const allGarments = $derived(garments$.value ?? []);
// Auto-seed face-ref at position [0] the first time it becomes
// available and value is still empty. After that, mutations go
// through the Add/Remove buttons.
let seeded = false;
$effect(() => {
if (!seeded && face?.mediaId && value.length === 0) {
seeded = true;
onChange([face.mediaId]);
}
});
const hasFace = $derived(Boolean(face?.mediaId));
const hasBody = $derived(Boolean(body?.mediaId));
const bodyInValue = $derived(body?.mediaId ? value.includes(body.mediaId) : false);
// Garment slots = everything beyond [face, body] if present.
const garmentIdsInValue = $derived.by<string[]>(() => {
const exclude = new Set<string>();
if (face?.mediaId) exclude.add(face.mediaId);
if (body?.mediaId) exclude.add(body.mediaId);
return value.filter((id) => !exclude.has(id));
});
const garmentPicks = $derived.by<Garment[]>(() => {
const out: Garment[] = [];
for (const id of garmentIdsInValue) {
const g = allGarments.find((g) => g.mediaIds[0] === id);
if (g) out.push(g);
}
return out;
});
// Garments the user can still add (not already picked, has a photo,
// not archived). Max 3 slots.
const MAX_GARMENTS = 3;
const canAddGarment = $derived(garmentIdsInValue.length < MAX_GARMENTS);
const availableGarments = $derived(
allGarments.filter(
(g) => !g.isArchived && g.mediaIds[0] && !garmentIdsInValue.includes(g.mediaIds[0])
)
);
let showGarmentPicker = $state(false);
function toggleBody() {
if (!body?.mediaId) return;
if (bodyInValue) {
onChange(value.filter((id) => id !== body.mediaId));
} else {
// Insert body at [1] (right after face), before garments.
const next = [...value];
const insertAt = face?.mediaId && next[0] === face.mediaId ? 1 : 0;
next.splice(insertAt, 0, body.mediaId);
onChange(next);
}
}
function addGarment(g: Garment) {
const mediaId = g.mediaIds[0];
if (!mediaId || value.includes(mediaId)) return;
onChange([...value, mediaId]);
showGarmentPicker = false;
}
function removeGarment(mediaId: string) {
onChange(value.filter((id) => id !== mediaId));
}
</script>
<div class="space-y-3">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Protagonist
</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
Dein Gesicht ist Pflicht. Body-Ref und bis zu {MAX_GARMENTS} Kostüm-Fotos sind optional.
</p>
</div>
<div class="flex flex-wrap items-start gap-2">
<!-- Face ref tile — mandatory -->
<div class="flex flex-col items-center gap-1">
{#if face?.publicUrl}
<img
src={face.thumbnailUrl ?? face.publicUrl}
alt="Face-Ref"
class="h-20 w-20 rounded-md border border-primary/30 object-cover"
/>
{:else}
<div
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/50 text-[10px] text-muted-foreground"
>
<UserCircle size={20} />
<span>Face fehlt</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground">Face</span>
</div>
<!-- Body ref tile — optional toggle -->
<div class="flex flex-col items-center gap-1">
{#if body?.publicUrl}
<button
type="button"
{disabled}
onclick={toggleBody}
class="relative h-20 w-20 overflow-hidden rounded-md border transition-colors
{bodyInValue ? 'border-primary/50' : 'border-border opacity-50 hover:opacity-100'}"
aria-pressed={bodyInValue}
title={bodyInValue ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
>
<img
src={body.thumbnailUrl ?? body.publicUrl}
alt="Body-Ref"
class="h-full w-full object-cover"
/>
{#if !bodyInValue}
<div
class="absolute inset-0 flex items-center justify-center bg-background/40 text-xs text-foreground"
>
<Plus size={16} />
</div>
{/if}
</button>
{:else}
<div
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/30 text-[10px] text-muted-foreground"
title="Kein Body-Ref im aktiven Space"
>
<UserCircle size={18} />
<span>Body fehlt</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground">Body</span>
</div>
<!-- Garment tiles (picked) -->
{#each garmentPicks as g (g.id)}
{@const mediaId = g.mediaIds[0]}
<div class="flex flex-col items-center gap-1">
<div class="relative h-20 w-20 overflow-hidden rounded-md border border-primary/30">
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
class="h-full w-full object-cover"
/>
{/if}
<button
type="button"
{disabled}
onclick={() => mediaId && removeGarment(mediaId)}
class="absolute right-0 top-0 m-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background/80 text-foreground shadow-sm hover:bg-background"
aria-label={`${g.name} entfernen`}
title="Entfernen"
>
<X size={10} />
</button>
</div>
<span class="max-w-20 truncate text-[10px] font-medium text-muted-foreground">
{g.name}
</span>
</div>
{/each}
<!-- Add-garment button -->
{#if canAddGarment}
<div class="flex flex-col items-center gap-1">
<button
type="button"
{disabled}
onclick={() => (showGarmentPicker = !showGarmentPicker)}
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-expanded={showGarmentPicker}
>
<Plus size={16} />
<span class="text-[10px] font-medium">Kostüm</span>
</button>
<span class="text-[10px] text-muted-foreground">
{garmentIdsInValue.length}/{MAX_GARMENTS}
</span>
</div>
{/if}
</div>
<!-- Garment picker (collapsible). Only shows when toggled open. -->
{#if showGarmentPicker}
<div class="rounded-lg border border-border bg-muted/30 p-3">
<div class="mb-2 flex items-center justify-between">
<h4 class="text-xs font-semibold text-foreground">Kostüm aus dem Schrank wählen</h4>
<button
type="button"
onclick={() => (showGarmentPicker = false)}
class="text-xs text-muted-foreground hover:text-foreground"
>
Schließen
</button>
</div>
{#if availableGarments.length === 0}
<p class="text-xs text-muted-foreground">
Keine weiteren Kleidungsstücke verfügbar — lade welche in <a
href="/wardrobe"
class="text-primary hover:underline">/wardrobe</a
> hoch.
</p>
{:else}
<div class="grid max-h-48 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6">
{#each availableGarments as g (g.id)}
{@const mediaId = g.mediaIds[0]}
<button
type="button"
onclick={() => addGarment(g)}
class="group flex flex-col items-center gap-1 overflow-hidden rounded-md border border-border bg-background text-left hover:border-primary/50"
title={g.name}
>
<div class="aspect-square w-full bg-muted">
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
class="h-full w-full object-cover"
/>
{/if}
</div>
<span class="w-full truncate px-1 pb-1 text-[10px] text-foreground">
{g.name}
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{#if !hasFace}
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
Kein Gesichtsbild in diesem Space. Lade eins in
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a>
hoch — ohne Face-Ref kein Comic.
</div>
{:else if !hasBody}
<p class="text-xs text-muted-foreground">
<TShirt size={12} class="inline" /> Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen
soll.
</p>
{/if}
</div>

View file

@ -0,0 +1,78 @@
<!--
PanelCard — single rendered panel with its caption/dialogue sidecar.
In M2 captions/dialogue are baked into the image by gpt-image-2, so
the text under the image is redundant meta for the author's own
reference (quick scan without opening the full image). It's still
useful for accessibility and for regenerating / reviewing prompts.
-->
<script lang="ts">
import { X } from '@mana/shared-icons';
import { usePanelImage } from '../queries';
import type { ComicPanelMeta } from '../types';
interface Props {
panelId: string;
panelIndex: number;
meta: ComicPanelMeta | undefined;
/** Shows a small remove-from-story button. Wired by the DetailView. */
onRemove?: () => void;
}
let { panelId, panelIndex, meta, onRemove }: Props = $props();
// svelte-ignore state_referenced_locally
const image$ = usePanelImage(panelId);
const image = $derived(image$.value);
</script>
<div
class="relative flex h-full w-full flex-col overflow-hidden rounded-lg border border-border bg-card"
>
<div class="relative aspect-square bg-muted">
{#if image?.publicUrl}
<img
src={image.publicUrl}
alt="Panel {panelIndex + 1}"
loading="lazy"
class="h-full w-full object-cover"
/>
{:else if image$.loading}
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Lädt…
</div>
{:else}
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Panel nicht gefunden
</div>
{/if}
<span
class="absolute left-2 top-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur"
>
#{panelIndex + 1}
</span>
{#if onRemove}
<button
type="button"
onclick={onRemove}
class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:text-error"
aria-label="Panel aus Story entfernen"
title="Panel aus Story entfernen (Bild bleibt in der Galerie)"
>
<X size={12} />
</button>
{/if}
</div>
{#if meta?.caption || meta?.dialogue}
<div class="space-y-1 px-2.5 py-1.5 text-[11px] leading-snug">
{#if meta.caption}
<p class="text-muted-foreground"><em>{meta.caption}</em></p>
{/if}
{#if meta.dialogue}
<p class="text-foreground">{meta.dialogue}"</p>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,271 @@
<!--
PanelEditor — inline "new panel" sheet. Three textareas (prompt,
caption, dialogue) plus a quality toggle and the Generieren button.
On submit, runPanelGenerate fires the API call and appends the new
panel to the story; on error the sheet stays mounted so the user
can adjust without retyping.
Prompt and the optional caption/dialogue get composed with the
story-wide style prefix inside `composePanelPrompt` before the call
— the user doesn't repeat style instructions per panel.
M2 scope: single panel per click. Batch-mode (n panels in one submit)
is M3 via the plan.
-->
<script lang="ts">
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
import type { ComicStory } from '../types';
interface Props {
story: ComicStory;
onClose: () => void;
onGenerated?: (panelId: string) => void;
}
let { story, onClose, onGenerated }: Props = $props();
let panelPrompt = $state('');
let caption = $state('');
let dialogue = $state('');
let quality = $state<'low' | 'medium' | 'high'>('medium');
// Size defaults based on the story's style at mount time — users
// can flip the toggle per panel afterwards, so capturing the
// initial value is intentional here.
// svelte-ignore state_referenced_locally
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
let submitting = $state(false);
let errorMsg = $state<string | null>(null);
const panelCount = $derived(story.panelImageIds.length);
const atCap = $derived(panelCount >= MAX_PANELS_PER_STORY);
const warn = $derived(panelCount >= PANEL_COUNT_WARN_THRESHOLD && !atCap);
const canSubmit = $derived(panelPrompt.trim().length > 0 && !submitting && !atCap);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
errorMsg = null;
try {
const result = await runPanelGenerate({
story,
panelPrompt,
caption: caption.trim() || undefined,
dialogue: dialogue.trim() || undefined,
quality,
size,
});
onGenerated?.(result.imageId);
// Reset local state so the next panel-add starts fresh.
panelPrompt = '';
caption = '';
dialogue = '';
submitting = false;
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen';
submitting = false;
}
}
type Quality = 'low' | 'medium' | 'high';
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
const CREDIT_COST: Record<Quality, number> = {
low: 3,
medium: 10,
high: 25,
};
</script>
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
<header class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-foreground">Neues Panel</h3>
<p class="text-xs text-muted-foreground">
Panel {panelCount + 1} · nutzt {story.characterMediaIds.length} Referenz{story
.characterMediaIds.length === 1
? ''
: 'en'}
</p>
</div>
<button
type="button"
onclick={onClose}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Panel-Editor schließen"
>
<X size={14} />
</button>
</header>
{#if atCap}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
Hart-Limit von {MAX_PANELS_PER_STORY} Panels erreicht. Ältere Panels entfernen oder neue Story anlegen.
</div>
{:else if warn}
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar
schwerer.
</p>
{/if}
<form
onsubmit={handleSubmit}
class="mt-3 space-y-3"
class:pointer-events-none={atCap}
class:opacity-60={atCap}
>
<div class="space-y-1.5">
<label
for="panel-prompt"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Panel-Prompt
</label>
<textarea
id="panel-prompt"
bind:value={panelPrompt}
rows={3}
placeholder="Was passiert in diesem Panel? z.B. 'Protagonist sitzt am Schreibtisch, starrt auf Monitor mit rotem X'"
maxlength={600}
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
required
></textarea>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<label
for="panel-caption"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Caption <span class="font-normal normal-case text-muted-foreground">(optional)</span>
</label>
<input
id="panel-caption"
type="text"
bind:value={caption}
placeholder="Montag, 9 Uhr."
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
/>
</div>
<div class="space-y-1.5">
<label
for="panel-dialogue"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Dialog <span class="font-normal normal-case text-muted-foreground">(optional)</span>
</label>
<input
id="panel-dialogue"
type="text"
bind:value={dialogue}
placeholder="Schon wieder rot."
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">
Caption und Dialog werden direkt in das Bild gerendert. Englische Texte rendern stabiler als
deutsche, kurze Sätze funktionieren am besten.
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
{#each QUALITIES as q (q)}
<button
type="button"
onclick={() => (quality = q)}
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
{quality === q
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
disabled={submitting}
aria-pressed={quality === q}
>
{q} ({CREDIT_COST[q]}c)
</button>
{/each}
</div>
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
<button
type="button"
onclick={() => (size = '1024x1024')}
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
{size === '1024x1024'
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
disabled={submitting}
aria-pressed={size === '1024x1024'}
>
Quadrat
</button>
<button
type="button"
onclick={() => (size = '1024x1536')}
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
{size === '1024x1536'
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
disabled={submitting}
aria-pressed={size === '1024x1536'}
>
Hoch
</button>
</div>
</div>
{#if errorMsg}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{errorMsg}
</div>
{/if}
<div class="flex items-center gap-2">
<button
type="submit"
disabled={!canSubmit}
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if submitting}
<SpinnerGap size={14} class="spinner" weight="bold" />
Generiert…
{:else}
<Sparkle size={14} />
Panel generieren ({CREDIT_COST[quality]}c)
{/if}
</button>
</div>
</form>
</div>
<style>
:global(.spinner) {
animation: panel-spin 0.9s linear infinite;
}
@keyframes panel-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,40 @@
<!--
PanelStrip — horizontal scrollable list of panels in story order. On
small screens the strip overflows horizontally (iOS-style momentum
scroll); on wide screens it wraps into a 23 column grid. Avoids
`grid-flow-col` so a long story doesn't force a monster horizontal
scroll on desktop.
-->
<script lang="ts">
import type { ComicPanelMeta } from '../types';
import PanelCard from './PanelCard.svelte';
interface Props {
panelImageIds: string[];
panelMeta: Record<string, ComicPanelMeta>;
onRemove?: (panelId: string) => void;
}
let { panelImageIds, panelMeta, onRemove }: Props = $props();
</script>
{#if panelImageIds.length === 0}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-6 text-center">
<p class="text-sm font-medium text-foreground">Noch keine Panels.</p>
<p class="mt-1 text-sm text-muted-foreground">
Klick unten auf <strong class="text-foreground">+ Panel</strong>, um die erste Szene zu
generieren.
</p>
</div>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each panelImageIds as panelId, index (panelId)}
<PanelCard
{panelId}
panelIndex={index}
meta={panelMeta[panelId]}
onRemove={onRemove ? () => onRemove(panelId) : undefined}
/>
{/each}
</div>
{/if}

View file

@ -0,0 +1,68 @@
<!--
Grid tile for a comic story. Cover = first panel's publicUrl from
picture.images. Stories without any panels yet render a placeholder
with the style badge so the user still has something to click.
-->
<script lang="ts">
import { Sparkle, Heart } from '@mana/shared-icons';
import { STYLE_LABELS } from '../constants';
import { usePanelImage } from '../queries';
import type { ComicStory } from '../types';
interface Props {
story: ComicStory;
}
let { story }: Props = $props();
const coverPanelId = $derived(story.panelImageIds[0] ?? null);
// svelte-ignore state_referenced_locally
const cover$ = usePanelImage(coverPanelId);
const cover = $derived(cover$.value);
const panelCount = $derived(story.panelImageIds.length);
</script>
<a
href="/comic/{story.id}"
class="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-shadow hover:shadow-md"
>
<div class="relative aspect-square overflow-hidden bg-muted">
{#if cover?.publicUrl}
<img
src={cover.publicUrl}
alt={story.title}
loading="lazy"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
{:else}
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-muted to-muted/50 text-muted-foreground"
>
<Sparkle size={24} />
<span class="text-xs">Noch kein Panel</span>
</div>
{/if}
<!-- Style badge -->
<span
class="absolute bottom-2 left-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur"
>
{STYLE_LABELS[story.style].de}
</span>
{#if story.isFavorite}
<span class="absolute right-2 top-2 text-rose-500" aria-label="Favorit">
<Heart size={14} weight="fill" />
</span>
{/if}
</div>
<div class="space-y-0.5 px-3 py-2">
<h3 class="truncate text-sm font-medium text-foreground">{story.title}</h3>
<p class="text-xs text-muted-foreground">
{panelCount}
{panelCount === 1 ? 'Panel' : 'Panels'}
</p>
</div>
</a>

View file

@ -0,0 +1,142 @@
<!--
StoryForm — create a new comic story. Title + Style + Characters +
optional Kontext. On submit, createStory() lands the row in Dexie
and we navigate to /comic/[id] so the user can start adding panels
immediately.
No edit mode yet — update-story is a future concern (users who want
to change the style/characters can just create a new story). The
form is tuned for the "fresh idea → first panel in <60s"-flow.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Sparkle } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { comicStoriesStore } from '../stores/stories.svelte';
import type { ComicStyle } from '../types';
import StylePicker from './StylePicker.svelte';
import CharacterPicker from './CharacterPicker.svelte';
let title = $state('');
let style = $state<ComicStyle>('comic');
let characterMediaIds = $state<string[]>([]);
let storyContext = $state('');
let submitting = $state(false);
let submitError = $state<string | null>(null);
const activeSpace = $derived(getActiveSpace());
const canSubmit = $derived(
title.trim().length > 0 && characterMediaIds.length > 0 && !submitting
);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
submitError = null;
try {
const story = await comicStoriesStore.createStory({
title: title.trim(),
style,
characterMediaIds,
storyContext: storyContext.trim() || null,
});
await goto(`/comic/${story.id}`);
} catch (err) {
submitError = err instanceof Error ? err.message : 'Erstellung fehlgeschlagen';
submitting = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-5">
<!-- Title -->
<div class="space-y-1.5">
<label
for="comic-title"
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Titel
</label>
<input
id="comic-title"
type="text"
bind:value={title}
placeholder="Bug-Hunt-Frust, Urlaubs-Abenteuer, …"
maxlength={120}
autocomplete="off"
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting}
required
/>
</div>
<!-- Style -->
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Stil</div>
<StylePicker value={style} onChange={(next) => (style = next)} disabled={submitting} />
<p class="text-[11px] text-muted-foreground">
Der Stil gilt für alle Panels der Geschichte. Wechsel ist später nicht möglich — dafür neue
Story anlegen.
</p>
</div>
<!-- Character refs -->
<CharacterPicker
value={characterMediaIds}
onChange={(next) => (characterMediaIds = next)}
disabled={submitting}
/>
<!-- Story context (optional) -->
<div class="space-y-1.5">
<label
for="comic-context"
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Kontext <span class="font-normal normal-case text-muted-foreground">(optional)</span>
</label>
<textarea
id="comic-context"
bind:value={storyContext}
rows={3}
maxlength={800}
placeholder="Kurze Zusammenfassung, Ton, Ziel der Geschichte. Wird im AI-Storyboard-Flow (M4) als Briefing genutzt."
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting}
></textarea>
</div>
{#if activeSpace && activeSpace.type !== 'personal'}
<p class="text-xs text-muted-foreground">
Diese Story gehört zu <strong class="text-foreground">{activeSpace.name}</strong> — nur Mitglieder
dieses Space sehen sie.
</p>
{/if}
{#if submitError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{submitError}
</div>
{/if}
<div class="flex items-center gap-2">
<button
type="submit"
disabled={!canSubmit}
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
<Sparkle size={14} />
{submitting ? 'Wird erstellt…' : 'Story anlegen'}
</button>
<a
href="/comic"
class="rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
Abbrechen
</a>
</div>
</form>

View file

@ -0,0 +1,46 @@
<!--
StylePicker — radio-tiles for the five ComicStyle presets. Chosen at
story-create time, fixed afterward (restyling = new story). Each
tile carries a short "what this looks like" hint so the user can
pick without having to memorise the preset mapping in styles.ts.
-->
<script lang="ts">
import { STYLE_ORDER, STYLE_LABELS } from '../constants';
import type { ComicStyle } from '../types';
interface Props {
value: ComicStyle;
onChange: (next: ComicStyle) => void;
disabled?: boolean;
}
let { value, onChange, disabled = false }: Props = $props();
// Short descriptive hint per preset, shown under the label. Keep
// each line ≤ 60 chars so 2-column layouts don't wrap.
const HINTS: Record<ComicStyle, string> = {
comic: 'Kräftige Linien, Cell-Shading, US-Comic-Look',
manga: 'Schwarz/weiß, Screen-Tones, dynamische Perspektive',
cartoon: 'Weich, pastellig, Saturday-Morning-Feeling',
'graphic-novel': 'Aquarell / painterly, stimmungsvoll',
webtoon: 'Vertikale Panels, moderne Farben, Soft-Shading',
};
</script>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
{#each STYLE_ORDER as style (style)}
<button
type="button"
{disabled}
onclick={() => onChange(style)}
class="rounded-lg border px-3 py-2.5 text-left transition-colors
{value === style
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-foreground hover:bg-muted'}"
aria-pressed={value === style}
>
<div class="text-sm font-medium">{STYLE_LABELS[style].de}</div>
<div class="mt-0.5 text-xs text-muted-foreground">{HINTS[style]}</div>
</button>
{/each}
</div>

View file

@ -15,6 +15,7 @@ export {
useStory,
useStoryPanels,
useStoriesByInput,
usePanelImage,
} from './queries';
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
export { STYLE_PREFIXES, composePanelPrompt } from './styles';

View file

@ -44,6 +44,26 @@ export function useStoriesByStyle(style: ComicStyle) {
}, [] as ComicStory[]);
}
/**
* Load a single picture.images row by id used for panel rendering
* (cover on StoryCard, thumbnails on PanelStrip, full-size on
* PanelCard). Lives here (not in picture/queries) because it's
* comic-specific convenience; picture's own queries don't need a
* single-image hook today.
*/
export function usePanelImage(imageId: string | null) {
return useLiveQueryWithDefault<Image | null>(async () => {
if (!imageId) return null;
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
.and((row) => row.id === imageId)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('images', [local]);
return toImage(decrypted);
}, null);
}
/** A single story by id, live-updating. Null while loading / missing. */
export function useStory(id: string | null) {
return useLiveQueryWithDefault<ComicStory | null>(async () => {

View file

@ -0,0 +1,231 @@
<!--
Comic story detail — meta card (title + style + visibility +
favorite + archive/delete) and panel strip with a "+ Panel" CTA
that opens the PanelEditor sheet inline.
Removing a panel here strips it from the story's `panelImageIds`
and `panelMeta` only — the picture.images row itself survives so
the user can keep the render in their Picture gallery. Final
deletion happens from Picture, per decision in the plan.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { comicStoriesTable } from '../collections';
import { comicStoriesStore } from '../stores/stories.svelte';
import { useStory } from '../queries';
import { STYLE_LABELS } from '../constants';
import PanelStrip from '../components/PanelStrip.svelte';
import PanelEditor from '../components/PanelEditor.svelte';
import { encryptRecord } from '$lib/data/crypto';
import type { ComicPanelMeta, LocalComicStory } from '../types';
interface Props {
id: string;
}
let { id }: Props = $props();
// svelte-ignore state_referenced_locally
const story$ = useStory(id);
const story = $derived(story$.value);
let showEditor = $state(false);
async function handleToggleFavorite() {
if (!story) return;
await comicStoriesStore.toggleFavorite(story.id);
}
async function handleArchive() {
if (!story) return;
await comicStoriesStore.archiveStory(story.id, !story.isArchived);
}
async function handleDelete() {
if (!story) return;
if (!confirm(`Story "${story.title}" wirklich löschen?`)) return;
await comicStoriesStore.deleteStory(story.id);
await goto('/comic');
}
async function handleVisibilityChange(next: VisibilityLevel) {
if (!story) return;
await comicStoriesStore.setVisibility(story.id, next);
}
/**
* Strip a panel from the story without touching the image row.
* Re-encrypts `panelMeta` because it's one JSON blob per the
* registry; we can't partially update without decrypting first.
*/
async function handleRemovePanel(panelId: string) {
if (!story) return;
if (
!confirm(
'Panel aus der Story entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden.'
)
)
return;
const existing = await comicStoriesTable.get(story.id);
if (!existing) return;
const nextIds = (existing.panelImageIds ?? []).filter((pid) => pid !== panelId);
const nextMeta: Record<string, ComicPanelMeta> = { ...(existing.panelMeta ?? {}) };
delete nextMeta[panelId];
const patch = {
panelImageIds: nextIds,
panelMeta: nextMeta,
} as Partial<LocalComicStory>;
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('comicStories', wrapped);
await comicStoriesTable.update(story.id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
}
</script>
<div class="mx-auto max-w-4xl space-y-5 p-4 sm:p-6">
<nav class="flex items-center gap-2 text-sm">
<a
href="/comic"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label="Zurück zu Comics"
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">Comics</span>
</nav>
{#if !story}
{#if story$.loading}
<p class="text-sm text-muted-foreground">Lädt…</p>
{:else}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<p class="text-sm font-medium text-foreground">Story nicht gefunden.</p>
<p class="mt-1 text-sm text-muted-foreground">Gelöscht oder in einem anderen Space.</p>
</div>
{/if}
{:else}
<!-- Meta -->
<div class="space-y-3 rounded-2xl border border-border bg-card p-5">
<header class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-foreground">{story.title}</h1>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
{STYLE_LABELS[story.style].de}
</span>
<span>
{story.panelImageIds.length}
{story.panelImageIds.length === 1 ? 'Panel' : 'Panels'}
</span>
{#if story.characterMediaIds.length > 0}
<span class="text-border">·</span>
<span>
{story.characterMediaIds.length} Referenz{story.characterMediaIds.length === 1
? ''
: 'en'}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<VisibilityPicker
level={story.visibility ?? 'space'}
onChange={handleVisibilityChange}
compact
/>
<button
type="button"
onclick={handleToggleFavorite}
aria-label={story.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
title={story.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {story.isFavorite
? 'text-rose-500 hover:bg-rose-500/10'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<Heart size={16} weight={story.isFavorite ? 'fill' : 'regular'} />
</button>
</div>
</header>
{#if story.description}
<p class="whitespace-pre-wrap text-sm text-foreground">{story.description}</p>
{/if}
{#if story.storyContext}
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<strong class="text-foreground">Kontext:</strong>
{story.storyContext}
</div>
{/if}
</div>
<!-- Panels -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Panels</h2>
{#if !showEditor && !story.isArchived}
<button
type="button"
onclick={() => (showEditor = true)}
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={12} />
Panel
</button>
{/if}
</div>
<PanelStrip
panelImageIds={story.panelImageIds}
panelMeta={story.panelMeta}
onRemove={handleRemovePanel}
/>
{#if showEditor && !story.isArchived}
<PanelEditor
{story}
onClose={() => (showEditor = false)}
onGenerated={() => {
// Keep the editor open for rapid iteration — the user
// usually wants to generate 35 panels in a row. Reset
// happens inside PanelEditor on success.
}}
/>
{/if}
</div>
<!-- Secondary actions -->
<div class="flex gap-2">
<button
type="button"
onclick={handleArchive}
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
<Archive size={14} />
{story.isArchived ? 'Wieder aktiv' : 'Archivieren'}
</button>
<button
type="button"
onclick={handleDelete}
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
>
<Trash size={14} />
Löschen
</button>
</div>
{#if story.isArchived}
<p
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
>
<Sparkle size={12} class="inline" /> Archivierte Story — keine Panel-Generierung möglich, bis
wieder aktiviert.
</p>
{/if}
{/if}
</div>

View file

@ -0,0 +1,78 @@
<!--
Comic list view — grid of stories in the active space, with a "+"
CTA at the top to jump into the create flow. Empty-state nudges
first-time users to check their face-ref first (comics can't
render without a Protagonist).
-->
<script lang="ts">
import { Plus, UserCircle } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { useAllStories } from '../queries';
import StoryCard from '../components/StoryCard.svelte';
const stories$ = useAllStories();
const stories = $derived(stories$.value ?? []);
const activeSpace = $derived(getActiveSpace());
const face$ = useImageByPrimary('face-ref');
const hasFace = $derived(Boolean(face$.value?.mediaId));
</script>
<div class="space-y-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-foreground">Deine Comics</h2>
<p class="text-xs text-muted-foreground">
{stories.length}
{stories.length === 1 ? 'Story' : 'Stories'} in
<strong class="text-foreground">{activeSpace?.name ?? 'diesem Space'}</strong>
</p>
</div>
<a
href="/comic/new"
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={12} />
Neue Story
</a>
</header>
{#if !hasFace && !face$.loading}
<div class="rounded-xl border border-dashed border-border bg-background/50 p-4">
<div class="flex items-start gap-3 text-sm">
<UserCircle size={18} class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="font-medium text-foreground">Lade erst dein Gesichtsbild hoch</p>
<p class="text-xs text-muted-foreground">
Ohne Face-Ref im aktiven Space kann kein Comic-Panel generiert werden. Hochladen in
<a href="/profile/me-images" class="text-primary hover:underline">Profil → Bilder</a>.
</p>
</div>
</div>
</div>
{/if}
{#if stories.length > 0}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each stories as story (story.id)}
<StoryCard {story} />
{/each}
</div>
{:else if !stories$.loading}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<p class="text-sm font-medium text-foreground">Noch keine Comics.</p>
<p class="mt-1 text-sm text-muted-foreground">
Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach
einer Idee.
</p>
<a
href="/comic/new"
class="mt-4 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={14} />
Erste Story anlegen
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { RoutePage } from '$lib/components/shell';
import ListView from '$lib/modules/comic/ListView.svelte';
</script>
<svelte:head>
<title>Comic · Mana</title>
</svelte:head>
<RoutePage appId="comic">
<ListView />
</RoutePage>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import { RoutePage } from '$lib/components/shell';
import DetailView from '$lib/modules/comic/views/DetailView.svelte';
const id = $derived(page.params.id ?? '');
</script>
<svelte:head>
<title>Comic · Mana</title>
</svelte:head>
<RoutePage appId="comic" backHref="/comic">
<!-- Fresh subtree on :id change so liveQuery and local state (panel
editor open / close, scroll position) reset cleanly when
navigating between /comic/a → /comic/b. -->
{#key id}
<DetailView {id} />
{/key}
</RoutePage>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { RoutePage } from '$lib/components/shell';
import StoryForm from '$lib/modules/comic/components/StoryForm.svelte';
</script>
<svelte:head>
<title>Neuer Comic · Mana</title>
</svelte:head>
<RoutePage appId="comic" backHref="/comic">
<div class="mx-auto max-w-2xl space-y-4 p-4 sm:p-6">
<header class="space-y-1">
<h1 class="text-lg font-semibold text-foreground">Neuer Comic</h1>
<p class="text-sm text-muted-foreground">
Wähle Stil + Protagonist, dann startest du mit dem ersten Panel.
</p>
</header>
<StoryForm />
</div>
</RoutePage>