From 3551652612e3cc61258e315a5f92ac8ae6e0c78c Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 15:42:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(comic):=20M2=20=E2=80=94=20UI=20+=20Single?= =?UTF-8?q?-Panel-Generierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../web/src/lib/i18n/locales/apps/de.json | 44 ++- .../web/src/lib/i18n/locales/apps/en.json | 44 ++- .../web/src/lib/modules/comic/ListView.svelte | 30 ++ .../lib/modules/comic/api/generate-panel.ts | 210 +++++++++++++ .../comic/components/CharacterPicker.svelte | 285 ++++++++++++++++++ .../modules/comic/components/PanelCard.svelte | 78 +++++ .../comic/components/PanelEditor.svelte | 271 +++++++++++++++++ .../comic/components/PanelStrip.svelte | 40 +++ .../modules/comic/components/StoryCard.svelte | 68 +++++ .../modules/comic/components/StoryForm.svelte | 142 +++++++++ .../comic/components/StylePicker.svelte | 46 +++ .../apps/web/src/lib/modules/comic/index.ts | 1 + .../apps/web/src/lib/modules/comic/queries.ts | 20 ++ .../lib/modules/comic/views/DetailView.svelte | 231 ++++++++++++++ .../lib/modules/comic/views/ListView.svelte | 78 +++++ .../web/src/routes/(app)/comic/+page.svelte | 12 + .../src/routes/(app)/comic/[id]/+page.svelte | 20 ++ .../src/routes/(app)/comic/new/+page.svelte | 20 ++ 18 files changed, 1638 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/comic/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/comic/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json index 5036702ce..60e97a641 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json @@ -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" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json index 10c8d9868..838b0d1eb 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json @@ -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" } diff --git a/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte new file mode 100644 index 000000000..dacc6112b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte @@ -0,0 +1,30 @@ + + + +
+ +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts b/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts new file mode 100644 index 000000000..c44a3e03d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts @@ -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 { + 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, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte new file mode 100644 index 000000000..8277104b4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte @@ -0,0 +1,285 @@ + + + +
+
+

+ Protagonist +

+

+ Dein Gesicht ist Pflicht. Body-Ref und bis zu {MAX_GARMENTS} Kostüm-Fotos sind optional. +

+
+ +
+ +
+ {#if face?.publicUrl} + Face-Ref + {:else} +
+ + Face fehlt +
+ {/if} + Face +
+ + +
+ {#if body?.publicUrl} + + {:else} +
+ + Body fehlt +
+ {/if} + Body +
+ + + {#each garmentPicks as g (g.id)} + {@const mediaId = g.mediaIds[0]} +
+
+ {#if mediaId} + {g.name} + {/if} + +
+ + {g.name} + +
+ {/each} + + + {#if canAddGarment} +
+ + + {garmentIdsInValue.length}/{MAX_GARMENTS} + +
+ {/if} +
+ + + {#if showGarmentPicker} +
+
+

Kostüm aus dem Schrank wählen

+ +
+ {#if availableGarments.length === 0} +

+ Keine weiteren Kleidungsstücke verfügbar — lade welche in /wardrobe hoch. +

+ {:else} +
+ {#each availableGarments as g (g.id)} + {@const mediaId = g.mediaIds[0]} + + {/each} +
+ {/if} +
+ {/if} + + {#if !hasFace} + + {:else if !hasBody} +

+ Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen + soll. +

+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte new file mode 100644 index 000000000..2457ade9d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte @@ -0,0 +1,78 @@ + + + +
+
+ {#if image?.publicUrl} + Panel {panelIndex + 1} + {:else if image$.loading} +
+ Lädt… +
+ {:else} +
+ Panel nicht gefunden +
+ {/if} + + + #{panelIndex + 1} + + + {#if onRemove} + + {/if} +
+ + {#if meta?.caption || meta?.dialogue} +
+ {#if meta.caption} +

{meta.caption}

+ {/if} + {#if meta.dialogue} +

„{meta.dialogue}"

+ {/if} +
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte new file mode 100644 index 000000000..8ff80002a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte @@ -0,0 +1,271 @@ + + + +
+
+
+

Neues Panel

+

+ Panel {panelCount + 1} · nutzt {story.characterMediaIds.length} Referenz{story + .characterMediaIds.length === 1 + ? '' + : 'en'} +

+
+ +
+ + {#if atCap} + + {:else if warn} +

+ Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar + schwerer. +

+ {/if} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

+ Caption und Dialog werden direkt in das Bild gerendert. Englische Texte rendern stabiler als + deutsche, kurze Sätze funktionieren am besten. +

+ +
+
+ Qualität: + {#each QUALITIES as q (q)} + + {/each} +
+
+ Format: + + +
+
+ + {#if errorMsg} + + {/if} + +
+ +
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte new file mode 100644 index 000000000..668c70806 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte @@ -0,0 +1,40 @@ + + + +{#if panelImageIds.length === 0} +
+

Noch keine Panels.

+

+ Klick unten auf + Panel, um die erste Szene zu + generieren. +

+
+{:else} +
+ {#each panelImageIds as panelId, index (panelId)} + onRemove(panelId) : undefined} + /> + {/each} +
+{/if} diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte new file mode 100644 index 000000000..eef824a89 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte @@ -0,0 +1,68 @@ + + + + +
+ {#if cover?.publicUrl} + {story.title} + {:else} +
+ + Noch kein Panel +
+ {/if} + + + + {STYLE_LABELS[story.style].de} + + + {#if story.isFavorite} + + + + {/if} +
+ +
+

{story.title}

+

+ {panelCount} + {panelCount === 1 ? 'Panel' : 'Panels'} +

+
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte new file mode 100644 index 000000000..d7b80cbff --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte @@ -0,0 +1,142 @@ + + + +
+ +
+ + +
+ + +
+
Stil
+ (style = next)} disabled={submitting} /> +

+ Der Stil gilt für alle Panels der Geschichte. Wechsel ist später nicht möglich — dafür neue + Story anlegen. +

+
+ + + (characterMediaIds = next)} + disabled={submitting} + /> + + +
+ + +
+ + {#if activeSpace && activeSpace.type !== 'personal'} +

+ Diese Story gehört zu {activeSpace.name} — nur Mitglieder + dieses Space sehen sie. +

+ {/if} + + {#if submitError} + + {/if} + +
+ + + Abbrechen + +
+ diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte new file mode 100644 index 000000000..5db300a9b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte @@ -0,0 +1,46 @@ + + + +
+ {#each STYLE_ORDER as style (style)} + + {/each} +
diff --git a/apps/mana/apps/web/src/lib/modules/comic/index.ts b/apps/mana/apps/web/src/lib/modules/comic/index.ts index 3e10abb70..0f723ad01 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/index.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/comic/queries.ts b/apps/mana/apps/web/src/lib/modules/comic/queries.ts index 50ae9caba..2b215f53b 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/queries.ts @@ -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(async () => { + if (!imageId) return null; + const locals = await scopedForModule('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(async () => { diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte new file mode 100644 index 000000000..778eea7fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte @@ -0,0 +1,231 @@ + + + +
+ + + {#if !story} + {#if story$.loading} +

Lädt…

+ {:else} +
+

Story nicht gefunden.

+

Gelöscht oder in einem anderen Space.

+
+ {/if} + {:else} + +
+
+
+

{story.title}

+
+ + {STYLE_LABELS[story.style].de} + + + {story.panelImageIds.length} + {story.panelImageIds.length === 1 ? 'Panel' : 'Panels'} + + {#if story.characterMediaIds.length > 0} + · + + {story.characterMediaIds.length} Referenz{story.characterMediaIds.length === 1 + ? '' + : 'en'} + + {/if} +
+
+
+ + +
+
+ + {#if story.description} +

{story.description}

+ {/if} + + {#if story.storyContext} +
+ Kontext: + {story.storyContext} +
+ {/if} +
+ + +
+
+

Panels

+ {#if !showEditor && !story.isArchived} + + {/if} +
+ + + + {#if showEditor && !story.isArchived} + (showEditor = false)} + onGenerated={() => { + // Keep the editor open for rapid iteration — the user + // usually wants to generate 3–5 panels in a row. Reset + // happens inside PanelEditor on success. + }} + /> + {/if} +
+ + +
+ + +
+ + {#if story.isArchived} +

+ Archivierte Story — keine Panel-Generierung möglich, bis + wieder aktiviert. +

+ {/if} + {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte new file mode 100644 index 000000000..64f445e53 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte @@ -0,0 +1,78 @@ + + + +
+
+
+

Deine Comics

+

+ {stories.length} + {stories.length === 1 ? 'Story' : 'Stories'} in + {activeSpace?.name ?? 'diesem Space'} +

+
+ + + Neue Story + +
+ + {#if !hasFace && !face$.loading} +
+
+ +
+

Lade erst dein Gesichtsbild hoch

+

+ Ohne Face-Ref im aktiven Space kann kein Comic-Panel generiert werden. Hochladen in + Profil → Bilder. +

+
+
+
+ {/if} + + {#if stories.length > 0} +
+ {#each stories as story (story.id)} + + {/each} +
+ {:else if !stories$.loading} +
+

Noch keine Comics.

+

+ Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach + einer Idee. +

+ + + Erste Story anlegen + +
+ {/if} +
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte new file mode 100644 index 000000000..7be93a212 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte @@ -0,0 +1,12 @@ + + + + Comic · Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte new file mode 100644 index 000000000..4937a9d72 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte @@ -0,0 +1,20 @@ + + + + Comic · Mana + + + + + {#key id} + + {/key} + diff --git a/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte new file mode 100644 index 000000000..27ea01c45 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte @@ -0,0 +1,20 @@ + + + + Neuer Comic · Mana + + + +
+
+

Neuer Comic

+

+ Wähle Stil + Protagonist, dann startest du mit dem ersten Panel. +

+
+ +
+