chore(mana): comic aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

Comic-Surface ist nach Comicello (comicello.mana.how + comicello.com)
umgezogen. Comicello hat seit Phase ω-2 (2026-05-18) Feature-Parität
+ Cross-Module-Hooks via mana-share:
- Character-Variant-Render mit pinned-Variant-Anker
- Panel-Render via mana-image-edits
- Panel-Edit/Delete/Reorder
- POST /api/v1/share/receive für externe Apps (Journal/Notes/Calendar/
  Library/Writing können Text-Snippet rüberschicken, Story wird als
  Draft angelegt)
- /comics/new?text=…&sourceModule=… URL-Prefill als Alternative zum
  Server-Roundtrip

Gelöscht / abgebaut:
- Module: apps/mana/.../modules/comic + Routen + Locales
- Backend: apps/api/src/modules/comic, picture/routes
  verifyMediaOwnership-Allowlist auf nur `['me']` reduziert (comic-
  Tag war hier Identity-Anchor-Quelle für panel renders, jetzt
  Comicello-intern)
- shared-branding: APP_ICONS.comic + MANA_APPS comic-Entry
- shared-ai/tools/schemas: ganzer Comic-Block (list_comic_stories,
  create_comic_story, generate_comic_panel, list_comic_characters,
  create_comic_character, generate_character_variant,
  pin_character_variant)
- shared-ai/agents/templates: comic-author.ts + index.ts Eintrag
- mana-tool-registry: modules/comic.ts + types ModuleId 'comic' raus
- Cross-Module: website/embeds resolveComicStories +
  LocalComicStory-Import + 'comic.stories' EmbedSource + Inspector-
  Option; crypto-registry comicStories+comicCharacters; exposed-records
  comic-Eintrag
- picture/types: comicStoryId, comicPanelIndex, comicCharacterId
  Back-Ref-Felder (sowohl LocalImage als auch Image-Public-DTO);
  picture/queries to-Public-Mapping
- Registries: app-registry/apps.ts (Comic registerApp + FilmStrip-
  Icon + Header), categories, help-content, module-registry,
  data/tools/init
- i18n: comic in apps/{de,en,es,fr,it}.json

Was BLEIBT:
- cloudflared `comicello.mana.how` + `comicello-api.mana.how` —
  Standalone-Routes
- docker-compose mana-auth CORS_ORIGINS comicello.com + .mana.how —
  SSO für Standalone

Dexie v66:
- droppt comicStories + comicCharacters
- Upgrade-Callback strippt comicStoryId/comicPanelIndex/comicCharacterId
  aus existierenden Image-Rows (waren nie indiziert, nur Properties)

mana-web svelte-check 0/0 (7281 files), snapshot test 10/10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-18 15:54:11 +02:00
parent 6c13308cf4
commit 2b08e2f3a2
73 changed files with 36 additions and 8519 deletions

View file

@ -39,7 +39,6 @@ import { articlesRoutes } from './modules/articles/routes';
import { startArticleImportWorker } from './modules/articles/import-worker';
import { tracesRoutes } from './modules/traces/routes';
import { writingRoutes } from './modules/writing/routes';
import { comicRoutes } from './modules/comic/routes';
import { presiRoutes } from './modules/presi/routes';
import { researchRoutes } from './modules/research/routes';
import { websiteRoutes } from './modules/website/routes';
@ -137,7 +136,6 @@ app.route('/api/v1/research', researchRoutes);
app.route('/api/v1/website', websiteRoutes);
app.route('/api/v1/unlisted', unlistedRoutes);
app.route('/api/v1/writing', writingRoutes);
app.route('/api/v1/comic', comicRoutes);
app.route('/api/v1/personas/admin', personasAdminRoutes);
// ─── Background Workers ─────────────────────────────────────

View file

@ -91,10 +91,9 @@ export async function getMediaBufferAsPng(
* doesn't land in the owned set the caller turns that into an HTTP
* response.
*
* Accepts a single app string or an array. Comic character-ref flows
* pass `['me', 'comic']` in one call when both face/body portraits and
* comic-specific anchors are legitimate inputs for the same
* `/v1/images/edits` POST.
* Accepts a single app string or an array. `['me']` covers the
* portrait flow; future apps may extend the list with their own
* upload tags.
*
* One `list()` round-trip per app. For N apps this is N calls, each
* capped at 500 rows far beyond the product's intended per-app shape

View file

@ -1,217 +0,0 @@
/**
* Comic module server endpoints.
*
* Current scope (M4):
* - POST /storyboard one-shot panel-sequence suggestion from a text
* input (journal entry, note, library review, writing draft,
* calendar event description). The client decrypts the source
* locally, sends the plaintext + style, and we round-trip to
* mana-llm with a JSON-schema system prompt, returning
* `{ panels: Array<{ prompt, caption?, dialogue? }> }`. Panel
* rendering itself still happens through /picture/generate-with-
* reference this endpoint is pure text plan.
*
* Future (M5+):
* - Upload endpoint for comic-specific anchor / backdrop images if
* M6 character-cast scope happens; the 'comic' upload slot is
* already allowed by verifyMediaOwnership (set in M1).
*
* Why not reuse /api/v1/writing/generations?
* That endpoint is a free-text prose endpoint (no JSON parsing) and
* is wired for one-shot writing drafts. Comic storyboarding wants a
* structured Panel[] envelope the client can iterate over cheaply
* different prompt shape, different parser, different observability
* tag. Keeping them apart avoids prompt-contamination between the
* two use-cases and keeps each module's logs grep-able.
*/
import { Hono } from 'hono';
import { llmJson, LlmError } from '../../lib/llm';
import { MANA_LLM } from '@mana/shared-ai';
import { logger, type AuthVariables } from '@mana/shared-hono';
const STORYBOARD_MODEL = MANA_LLM.STRUCTURED;
type ComicStyle = 'comic' | 'manga' | 'cartoon' | 'graphic-novel' | 'webtoon';
const STYLE_HINTS: Record<ComicStyle, string> = {
comic: 'US comic book, bold linework, cell-shading, dramatic framing',
manga: 'Japanese manga, black-and-white with screen tones, dynamic perspective',
cartoon: 'soft pastel cartoon, rounded shapes, Saturday-morning animation',
'graphic-novel': 'graphic novel, painterly watercolor, muted atmospheric palette',
webtoon: 'webtoon, vertical framing, bright saturated colors, soft cel-shading',
};
const VALID_STYLES = Object.keys(STYLE_HINTS) as readonly ComicStyle[];
const MAX_SOURCE_TEXT_CHARS = 8_000;
const MIN_PANEL_COUNT = 2;
const MAX_PANEL_COUNT = 8;
interface StoryboardRequest {
style: ComicStyle;
sourceText: string;
/** Optional — if omitted we ask for 4 panels (plan default). */
panelCount?: number;
/** Optional story-level briefing the author wrote at create-time.
* Gets prepended to the source-text so Claude knows the tonal
* register ("make it funny" / "stay serious"). */
storyContext?: string | null;
/** Where this text came from logged only, not sent to the LLM.
* Useful for observability ("which module drives most storyboards"). */
sourceModule?: string;
}
interface StoryboardPanel {
prompt: string;
caption?: string;
dialogue?: string;
}
interface StoryboardResponse {
panels: StoryboardPanel[];
model: string;
durationMs: number;
}
function isValidStyle(v: unknown): v is ComicStyle {
return typeof v === 'string' && (VALID_STYLES as readonly string[]).includes(v);
}
function buildSystemPrompt(style: ComicStyle): string {
const hint = STYLE_HINTS[style];
return [
`You are a comic-story editor. Given a short piece of text (journal entry, note, review, or event description), break it into a sequence of visual comic panels.`,
`Style: ${hint}.`,
`Return ONLY a JSON object with this exact shape:`,
`{"panels": [{"prompt": string, "caption"?: string, "dialogue"?: string}, ...]}`,
`Rules:`,
`- "prompt" is the visual scene description (what the artist draws). One or two short English sentences. Focus on composition, action, mood, setting. Do NOT describe style — the style prefix is added downstream.`,
`- "caption" (optional) is a short narration line rendered at the top or bottom of the panel, max 80 chars. Use sparingly — only when scene-setting or transitions need it.`,
`- "dialogue" (optional) is what the protagonist says inside a speech bubble, max 80 chars. Use when the scene has a spoken moment.`,
`- Do not number panels. Do not add meta commentary. Do not explain your choices.`,
`- The protagonist of every panel is the same person (the story's author).`,
].join('\n');
}
function buildUserPrompt(
sourceText: string,
panelCount: number,
storyContext: string | null | undefined
): string {
const trimmed = sourceText.trim().slice(0, MAX_SOURCE_TEXT_CHARS);
const contextBlock = storyContext?.trim()
? `Story briefing from the author:\n${storyContext.trim()}\n\n---\n\n`
: '';
return [
contextBlock,
`Source text:\n${trimmed}\n\n---\n\n`,
`Generate exactly ${panelCount} panels that tell this as a comic. Output the JSON object described in the system message.`,
].join('');
}
const routes = new Hono<{ Variables: AuthVariables }>();
routes.post('/storyboard', async (c) => {
const userId = c.get('userId');
const body = (await c.req.json()) as Partial<StoryboardRequest>;
if (!isValidStyle(body.style)) {
return c.json({ error: `Invalid style, expected one of: ${VALID_STYLES.join(', ')}` }, 400);
}
if (!body.sourceText || typeof body.sourceText !== 'string') {
return c.json({ error: 'sourceText required' }, 400);
}
if (body.sourceText.trim().length === 0) {
return c.json({ error: 'sourceText must not be blank' }, 400);
}
const panelCount = Math.max(
MIN_PANEL_COUNT,
Math.min(MAX_PANEL_COUNT, Number(body.panelCount) || 4)
);
const startedAt = Date.now();
try {
const parsed = await llmJson<{ panels?: unknown }>({
model: STORYBOARD_MODEL,
system: buildSystemPrompt(body.style),
user: buildUserPrompt(body.sourceText, panelCount, body.storyContext),
temperature: 0.7,
maxTokens: 2000,
});
const rawPanels = Array.isArray(parsed?.panels) ? parsed.panels : [];
// Defense-in-depth: coerce + strip unknown shapes, clamp to
// requested count. If the model returns more panels than asked
// for we keep the first N; less is fine (fewer credits later).
const panels: StoryboardPanel[] = rawPanels
.map((raw): StoryboardPanel | null => {
if (!raw || typeof raw !== 'object') return null;
const entry = raw as Record<string, unknown>;
const prompt = typeof entry.prompt === 'string' ? entry.prompt.trim() : '';
if (!prompt) return null;
const caption =
typeof entry.caption === 'string' && entry.caption.trim().length > 0
? entry.caption.trim().slice(0, 200)
: undefined;
const dialogue =
typeof entry.dialogue === 'string' && entry.dialogue.trim().length > 0
? entry.dialogue.trim().slice(0, 200)
: undefined;
return { prompt: prompt.slice(0, 800), caption, dialogue };
})
.filter((p): p is StoryboardPanel => p !== null)
.slice(0, panelCount);
const durationMs = Date.now() - startedAt;
if (panels.length === 0) {
logger.warn('comic.storyboard_empty', {
userId,
style: body.style,
sourceModule: body.sourceModule,
model: STORYBOARD_MODEL,
durationMs,
});
return c.json(
{
error: 'Model returned no usable panels',
detail: 'Try again, shorten the input, or pick a different style',
durationMs,
},
502
);
}
logger.info('comic.storyboard_ok', {
userId,
style: body.style,
sourceModule: body.sourceModule,
panelCount: panels.length,
model: STORYBOARD_MODEL,
durationMs,
});
const response: StoryboardResponse = {
panels,
model: STORYBOARD_MODEL,
durationMs,
};
return c.json(response);
} catch (err) {
const durationMs = Date.now() - startedAt;
const message = err instanceof Error ? err.message : String(err);
logger.error('comic.storyboard_failed', {
userId,
style: body.style,
sourceModule: body.sourceModule,
model: STORYBOARD_MODEL,
error: message,
status: err instanceof LlmError ? err.status : undefined,
durationMs,
});
return c.json({ error: 'Storyboard generation failed', detail: message, durationMs }, 500);
}
});
export { routes as comicRoutes };

View file

@ -315,14 +315,12 @@ routes.post('/generate-with-reference', async (c) => {
}
// Ownership check before we spend credits or burn OpenAI quota.
// References span two upload tags today:
// - `me` — face/body portraits from the profile module
// - `comic` — comic-specific anchor / backdrop uploads
// Anything outside these apps is treated as not-owned regardless of
// mana-media's own view.
// Currently only `me` (face/body portraits from the profile module)
// is a valid upload tag — anything else is treated as not-owned
// regardless of mana-media's own view.
try {
const { verifyMediaOwnership } = await import('../../lib/media');
await verifyMediaOwnership(userId, refIds, ['me', 'comic']);
await verifyMediaOwnership(userId, refIds, ['me']);
} catch (err) {
const e = err as Error & { status?: number; missing?: string[] };
if (e.status === 404) {

View file

@ -72,7 +72,6 @@ import {
Exam,
Globe,
NotePencil,
FilmStrip,
Hourglass,
HeartHalf,
Eye,
@ -96,7 +95,7 @@ import {
// mood · sleep · activity · times · finance
// Knowledge: chat · kontext · cards · quiz · guides ·
// news-research · research-lab · articles ·
// library · writing · comic · presi
// library · writing · presi
// Body & life: body · meditate · stretch · period ·
// dreams · firsts · lasts · habits · recipes
// Places & ev.: places · events
@ -1300,30 +1299,6 @@ registerApp({
}),
});
registerApp({
id: 'comic',
name: 'Comic',
color: '#f97316',
icon: FilmStrip,
views: {
// /comic/new (StoryForm) and /comic/[id] (DetailView) live as
// SvelteKit routes; the workbench card hosts the ListView root.
// Quick-action "Neue Story" navigates to /comic/new directly —
// the create flow has its own page, no inline modal in the card.
list: { load: () => import('$lib/modules/comic/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-story',
label: 'Neue Story',
icon: Plus,
action: () => {
window.location.href = '/comic/new';
},
},
],
});
registerApp({
id: 'spaces',
name: 'Spaces',

View file

@ -881,24 +881,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
],
tips: ['System-Auto folgt deinem OS-Dark-Mode automatisch zur richtigen Uhrzeit'],
},
comic: {
description:
'Aus Text wird ein Comic — Tagebuch-Eintrag, Notiz oder Library-Review als Vorlage, gpt-image-2 oder Nano Banana rendert Panels in fünf Stilen (Comic, Manga, Cartoon, Graphic Novel, Webtoon). Du selbst bist der Protagonist — Face-Ref aus deinem Profil-Modul wird automatisch genutzt.',
features: [
'Drei Generate-Modi: Einzel-Panel, Batch (2-4 parallel), KI-Storyboard aus existierendem Text',
'Fünf Stil-Presets pro Story fix gewählt — alle Panels nutzen denselben Prefix für Konsistenz',
'Sprechblasen + Captions werden direkt ins Bild gerendert (kein SVG-Overlay)',
'Modell wählbar pro Klick: OpenAI gpt-image-2, Nano Banana Pro, Nano Banana 2',
'Cross-Modul-Storyboard: Claude liest Journal/Notes/Library und schlägt 4-6 Panels vor',
'MCP-Tools: listStories / createStory / generatePanel / reorderPanels für Agents',
],
tips: [
'Ohne Face-Ref im aktiven Space kein Comic — Banner führt direkt zum Upload.',
'Englische Captions/Dialoge rendern stabiler als deutsche; kurze Sätze funktionieren am besten.',
'Style-Wechsel ist nicht möglich nach Story-Create — dafür einfach neue Story anlegen.',
'Ab ~8 Panels pro Story wird Character-Konsistenz spürbar schwerer (gpt-image-2-Limit).',
],
},
'research-lab': {
description:
'Web-Research-Anbieter Seite-an-Seite vergleichen: gleiche Query an bis zu fünf Provider parallel, Antworten + Latenz + Kosten nebeneinander. Alle Runs werden serverseitig persistiert für spätere Auswertung.',

View file

@ -95,7 +95,6 @@ import type {
LocalGeneration,
LocalWritingStyle,
} from '../../modules/writing/types';
import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types';
import type { LocalAugurEntry } from '../../modules/augur/types';
import type { LocalForm, LocalFormResponse } from '../../modules/forms/types';
@ -544,47 +543,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// lives in MinIO behind owner-RLS, not in Dexie.
meImages: entry<LocalMeImage>(['label', 'tags']),
// ─── Comic (stories + inline panel metadata) ─────────────
// docs/plans/comic-module.md M1. Single space-scoped table.
//
// `title`, `description`, `storyContext`, `tags` are user-typed
// prose and get the same treatment as journal.title / notes.content.
// `panelMeta` is the per-panel sidecar (Record<panelImageId,
// {caption, dialogue, promptUsed, sourceInput}>) — aes.ts JSON-
// stringifies the whole blob before wrap, same pattern as
// food.foods / recipes.ingredients / quiz.options. Caption +
// dialogue are prose fragments the user authored; promptUsed is
// the reproduce-key (would-be-convenient for regeneration but
// leaks story content if plaintext); sourceInput FKs are
// low-risk but ship inside the encrypted blob anyway because
// splitting the Record per-field would double the storage cost.
//
// Plaintext (intentional): id, style enum (drives listStories
// filter + per-style prompt-prefix lookup), characterMediaIds
// (FKs to meImages / wardrobeGarments), panelImageIds (ordered
// FKs to picture.images), isFavorite / isArchived / visibility
// fields — all needed by the index or query layer.
comicStories: entry<LocalComicStory>([
'title',
'description',
'storyContext',
'tags',
'panelMeta',
]),
// ─── Comic-Characters (variant pool + pinned identity) ────
// docs/plans/comic-module.md §11. User-scoped sibling table to
// comicStories. Encrypted: `name` (display label), `description`
// (optional context), `addPrompt` (the user's free-text prompt
// add-on like "freundlicher Ausdruck"), `tags`. Plaintext:
// `style` (filter discriminator), `sourceFaceMediaId` /
// `sourceBodyMediaId` (FKs to meImages), `variantMediaIds` (FK
// array to picture.images), `pinnedVariantId`, booleans.
// Same encryption envelope as a wardrobe-outfit — name + free-
// text + tags travel encrypted, structural fields stay plaintext
// for query/sort.
comicCharacters: entry<LocalComicCharacter>(['name', 'description', 'addPrompt', 'tags']),
// ─── Augur (signs: omens / fortunes / hunches) ───────────
// docs/plans/augur-module.md M1. Single space-scoped table.
//

View file

@ -1591,6 +1591,29 @@ db.version(65).stores({
newsCachedFeed: null,
});
// v66 — Comic module retirement (2026-05-18).
// Comic-Surface ist nach Comicello (comicello.mana.how / comicello.com)
// umgezogen, das mit eigener Postgres-DB läuft. Tabellen werden hier
// komplett gedroppt; Picture-Image back-ref-Properties (comicStoryId /
// comicPanelIndex / comicCharacterId) waren nie indiziert und werden
// per .upgrade() aus alten Image-Rows gestrippt, damit keine orphane
// FKs auf nicht-mehr-existierende Comic-Records zurückbleiben.
db.version(66)
.stores({
comicStories: null,
comicCharacters: null,
})
.upgrade(async (tx) => {
await tx
.table('images')
.toCollection()
.modify((image) => {
if ('comicStoryId' in image) delete image.comicStoryId;
if ('comicPanelIndex' in image) delete image.comicPanelIndex;
if ('comicCharacterId' in image) delete image.comicCharacterId;
});
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -270,7 +270,6 @@ describe('module-registry — snapshot', () => {
wetter: ['wetterLocations', 'wetterSettings'],
website: ['websites', 'websitePages', 'websiteBlocks'],
writing: ['writingDrafts', 'writingDraftVersions', 'writingGenerations', 'writingStyles'],
comic: ['comicStories', 'comicCharacters'],
augur: ['augurEntries'],
forms: ['forms', 'formResponses'],
ai: ['aiMissions', 'agents', 'agentKontextDocs'],

View file

@ -96,7 +96,6 @@ import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { writingModuleConfig } from '$lib/modules/writing/module.config';
import { comicModuleConfig } from '$lib/modules/comic/module.config';
import { augurModuleConfig } from '$lib/modules/augur/module.config';
import { formsModuleConfig } from '$lib/modules/forms/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
@ -151,7 +150,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
wetterModuleConfig,
websiteModuleConfig,
writingModuleConfig,
comicModuleConfig,
augurModuleConfig,
formsModuleConfig,
aiModuleConfig,

View file

@ -139,18 +139,6 @@ const TABLES: TableConfig[] = [
return recipesStore.setVisibility(id, next);
},
},
{
module: 'comic',
collection: 'comicStories',
moduleLabel: 'Comics',
encrypted: true,
title: (r) => asString(r.title),
href: () => '/comic',
setVisibility: async (id, next) => {
const { comicStoriesStore } = await import('$lib/modules/comic/stores/stories.svelte');
return comicStoriesStore.setVisibility(id, next);
},
},
{
module: 'habits',
collection: 'habits',

View file

@ -43,7 +43,6 @@ import { libraryTools } from '$lib/modules/library/tools';
import { broadcastTools } from '$lib/modules/broadcasts/tools';
import { websiteTools } from '$lib/modules/website/tools';
import { writingTools } from '$lib/modules/writing/tools';
import { comicTools } from '$lib/modules/comic/tools';
import { augurTools } from '$lib/modules/augur/tools';
import { formsTools } from '$lib/modules/forms/tools';
@ -90,7 +89,6 @@ export function initTools(): void {
registerTools(broadcastTools);
registerTools(websiteTools);
registerTools(writingTools);
registerTools(comicTools);
registerTools(augurTools);
registerTools(formsTools);
initialized = true;

View file

@ -67,6 +67,5 @@
"spaces": "Bereiche",
"website": "Website",
"quiz": "Quiz",
"guides": "Anleitungen",
"comic": "Comic"
"guides": "Anleitungen"
}

View file

@ -67,6 +67,5 @@
"spaces": "Spaces",
"website": "Website",
"quiz": "Quiz",
"guides": "Guides",
"comic": "Comic"
"guides": "Guides"
}

View file

@ -67,6 +67,5 @@
"spaces": "Espacios",
"website": "Sitio web",
"quiz": "Quiz",
"guides": "Guías",
"comic": "Cómic"
"guides": "Guías"
}

View file

@ -67,6 +67,5 @@
"spaces": "Espaces",
"website": "Site web",
"quiz": "Quiz",
"guides": "Guides",
"comic": "Bande dessinée"
"guides": "Guides"
}

View file

@ -67,6 +67,5 @@
"spaces": "Spazi",
"website": "Sito web",
"quiz": "Quiz",
"guides": "Guide",
"comic": "Fumetto"
"guides": "Guide"
}

View file

@ -1,80 +0,0 @@
{
"detail": {
"back_aria": "Zurück zu Comics",
"breadcrumb": "Comics",
"loading": "Lädt…",
"not_found": "Story nicht gefunden.",
"not_found_hint": "Gelöscht oder in einem anderen Space.",
"panel_one": "{n} Panel",
"panel_other": "{n} Panels",
"reference_one": "{n} Referenz",
"reference_other": "{n} Referenzen",
"favorite_remove": "Favorit entfernen",
"favorite_set": "Als Favorit markieren",
"context_label": "Kontext:",
"section_panels": "Panels",
"add_panel": "Panel",
"add_batch": "Batch",
"add_batch_title": "24 Panels in einem Rutsch generieren",
"add_ai": "Mit KI",
"add_ai_title": "KI schlägt Panels aus einem Tagebuch-Eintrag, Notiz oder Review vor",
"unarchive": "Wieder aktiv",
"archive": "Archivieren",
"delete": "Löschen",
"archived_hint": "Archivierte Story — keine Panel-Generierung möglich, bis wieder aktiviert.",
"confirm_delete_story": "Story \"{title}\" wirklich löschen?",
"confirm_remove_panel": "Panel aus der Story entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden."
},
"styles": {
"comic": "US-Comic",
"manga": "Manga",
"cartoon": "Cartoon",
"graphic-novel": "Graphic Novel",
"webtoon": "Webtoon"
},
"picker": {
"section_title": "Protagonist",
"section_hint": "Dein Gesicht ist Pflicht. Body-Ref und bis zu {max} Kostüm-Fotos sind optional — klicke ein Bild oder das ✕, um es wieder zu entfernen.",
"face_required_title": "Face-Ref ist Pflicht — kann nicht entfernt werden",
"face_required_badge": "Pflicht",
"face_alt": "Face-Ref",
"face_missing": "Face fehlt",
"face_label": "Face",
"body_alt": "Body-Ref",
"body_missing": "Body fehlt",
"body_label": "Body",
"body_no_in_space": "Kein Body-Ref im aktiven Space",
"toggle_remove": "Klick zum Entfernen",
"toggle_add": "Klick zum Hinzufügen",
"garment_remove_aria": "{name} entfernen",
"garment_label": "Kostüm",
"garment_picker_title": "Kostüm aus dem Schrank wählen",
"garment_picker_close": "Schließen",
"garment_picker_empty_html": "Keine weiteren Kleidungsstücke verfügbar — lade welche in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a> hoch.",
"no_face_alert_html": "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.",
"body_tip": "Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen soll."
},
"character_detail": {
"back_aria": "Zurück zu Characters",
"breadcrumb": "Comic · Characters",
"loading": "Lädt…",
"not_found": "Character nicht gefunden.",
"not_found_hint": "Gelöscht oder in einem anderen Space.",
"variant_one": "{n} Variante",
"variant_other": "{n} Varianten",
"pin_open": "Pin offen",
"favorite_remove": "Favorit entfernen",
"favorite_set": "Als Favorit markieren",
"prompt_add_label": "Prompt-Add:",
"section_variants": "Varianten",
"action_more_variants": "Mehr Varianten",
"empty_variants_title": "Noch keine Varianten.",
"empty_variants_hint_html": "Klick oben rechts auf <strong class=\"text-foreground\">+ Mehr Varianten</strong>, um die ersten 4 zu generieren.",
"unarchive": "Wieder aktiv",
"archive": "Archivieren",
"delete": "Löschen",
"archived_hint": "Archivierter Character — keine Variant-Generierung möglich, bis wieder aktiviert.",
"confirm_delete_character": "Character \"{name}\" wirklich löschen?",
"confirm_remove_variant": "Variante aus dem Character entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden."
}
}

View file

@ -1,80 +0,0 @@
{
"detail": {
"back_aria": "Back to comics",
"breadcrumb": "Comics",
"loading": "Loading…",
"not_found": "Story not found.",
"not_found_hint": "Deleted or in another space.",
"panel_one": "{n} panel",
"panel_other": "{n} panels",
"reference_one": "{n} reference",
"reference_other": "{n} references",
"favorite_remove": "Remove favorite",
"favorite_set": "Mark as favorite",
"context_label": "Context:",
"section_panels": "Panels",
"add_panel": "Panel",
"add_batch": "Batch",
"add_batch_title": "Generate 24 panels in one go",
"add_ai": "With AI",
"add_ai_title": "AI suggests panels from a journal entry, note, or review",
"unarchive": "Reactivate",
"archive": "Archive",
"delete": "Delete",
"archived_hint": "Archived story — no panel generation possible until reactivated.",
"confirm_delete_story": "Really delete story \"{title}\"?",
"confirm_remove_panel": "Remove panel from the story? The image stays in your Picture gallery and can be deleted there."
},
"styles": {
"comic": "US Comic",
"manga": "Manga",
"cartoon": "Cartoon",
"graphic-novel": "Graphic Novel",
"webtoon": "Webtoon"
},
"picker": {
"section_title": "Protagonist",
"section_hint": "Your face is required. Body-ref and up to {max} costume photos are optional — click an image or the ✕ to remove it.",
"face_required_title": "Face-ref is required — cannot be removed",
"face_required_badge": "Required",
"face_alt": "Face ref",
"face_missing": "No face",
"face_label": "Face",
"body_alt": "Body ref",
"body_missing": "No body",
"body_label": "Body",
"body_no_in_space": "No body-ref in active space",
"toggle_remove": "Click to remove",
"toggle_add": "Click to add",
"garment_remove_aria": "Remove {name}",
"garment_label": "Costume",
"garment_picker_title": "Pick a costume from the wardrobe",
"garment_picker_close": "Close",
"garment_picker_empty_html": "No more garments available — upload some in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
"no_face_alert_html": "No face photo in this space. Upload one in <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profile → Images</a> — without a face ref no comic.",
"body_tip": "Tip: a body ref helps when the comic shows full-body panels."
},
"character_detail": {
"back_aria": "Back to characters",
"breadcrumb": "Comic · Characters",
"loading": "Loading…",
"not_found": "Character not found.",
"not_found_hint": "Deleted or in another space.",
"variant_one": "{n} variant",
"variant_other": "{n} variants",
"pin_open": "Pin pending",
"favorite_remove": "Remove favorite",
"favorite_set": "Mark as favorite",
"prompt_add_label": "Prompt add:",
"section_variants": "Variants",
"action_more_variants": "More variants",
"empty_variants_title": "No variants yet.",
"empty_variants_hint_html": "Click <strong class=\"text-foreground\">+ More variants</strong> in the top right to generate the first 4.",
"unarchive": "Reactivate",
"archive": "Archive",
"delete": "Delete",
"archived_hint": "Archived character — no variant generation possible until reactivated.",
"confirm_delete_character": "Really delete character \"{name}\"?",
"confirm_remove_variant": "Remove variant from this character? The image stays in your Picture gallery and can be deleted there."
}
}

View file

@ -1,80 +0,0 @@
{
"detail": {
"back_aria": "Volver a cómics",
"breadcrumb": "Cómics",
"loading": "Cargando…",
"not_found": "Historia no encontrada.",
"not_found_hint": "Eliminada o en otro espacio.",
"panel_one": "{n} panel",
"panel_other": "{n} paneles",
"reference_one": "{n} referencia",
"reference_other": "{n} referencias",
"favorite_remove": "Quitar favorito",
"favorite_set": "Marcar como favorito",
"context_label": "Contexto:",
"section_panels": "Paneles",
"add_panel": "Panel",
"add_batch": "Lote",
"add_batch_title": "Generar 24 paneles de una vez",
"add_ai": "Con IA",
"add_ai_title": "La IA sugiere paneles a partir de un diario, nota o reseña",
"unarchive": "Reactivar",
"archive": "Archivar",
"delete": "Eliminar",
"archived_hint": "Historia archivada — no se pueden generar paneles hasta que se reactive.",
"confirm_delete_story": "¿Eliminar realmente la historia \"{title}\"?",
"confirm_remove_panel": "¿Quitar panel de la historia? La imagen permanece en tu galería de Picture y puede eliminarse allí."
},
"styles": {
"comic": "Cómic US",
"manga": "Manga",
"cartoon": "Cartoon",
"graphic-novel": "Novela gráfica",
"webtoon": "Webtoon"
},
"picker": {
"section_title": "Protagonista",
"section_hint": "Tu cara es obligatoria. La body-ref y hasta {max} fotos de vestuario son opcionales — haz clic en una imagen o en la ✕ para quitarla.",
"face_required_title": "Face-ref es obligatoria — no se puede quitar",
"face_required_badge": "Obligatorio",
"face_alt": "Face ref",
"face_missing": "Sin cara",
"face_label": "Cara",
"body_alt": "Body ref",
"body_missing": "Sin cuerpo",
"body_label": "Cuerpo",
"body_no_in_space": "No hay body-ref en el espacio activo",
"toggle_remove": "Clic para quitar",
"toggle_add": "Clic para añadir",
"garment_remove_aria": "Quitar {name}",
"garment_label": "Vestuario",
"garment_picker_title": "Elegir vestuario del armario",
"garment_picker_close": "Cerrar",
"garment_picker_empty_html": "No hay más prendas disponibles — sube algunas en <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
"no_face_alert_html": "No hay foto de cara en este espacio. Sube una en <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Perfil → Imágenes</a> — sin face-ref no hay cómic.",
"body_tip": "Tip: una body-ref ayuda cuando el cómic muestra paneles de cuerpo entero."
},
"character_detail": {
"back_aria": "Volver a personajes",
"breadcrumb": "Cómic · Personajes",
"loading": "Cargando…",
"not_found": "Personaje no encontrado.",
"not_found_hint": "Eliminado o en otro espacio.",
"variant_one": "{n} variante",
"variant_other": "{n} variantes",
"pin_open": "Pin pendiente",
"favorite_remove": "Quitar favorito",
"favorite_set": "Marcar como favorito",
"prompt_add_label": "Prompt extra:",
"section_variants": "Variantes",
"action_more_variants": "Más variantes",
"empty_variants_title": "Aún sin variantes.",
"empty_variants_hint_html": "Haz clic en <strong class=\"text-foreground\">+ Más variantes</strong> arriba a la derecha para generar las primeras 4.",
"unarchive": "Reactivar",
"archive": "Archivar",
"delete": "Eliminar",
"archived_hint": "Personaje archivado — no se pueden generar variantes hasta reactivar.",
"confirm_delete_character": "¿Eliminar realmente el personaje \"{name}\"?",
"confirm_remove_variant": "¿Quitar variante del personaje? La imagen permanece en tu galería de Picture y puede eliminarse allí."
}
}

View file

@ -1,80 +0,0 @@
{
"detail": {
"back_aria": "Retour aux comics",
"breadcrumb": "Comics",
"loading": "Chargement…",
"not_found": "Histoire introuvable.",
"not_found_hint": "Supprimée ou dans un autre espace.",
"panel_one": "{n} panneau",
"panel_other": "{n} panneaux",
"reference_one": "{n} référence",
"reference_other": "{n} références",
"favorite_remove": "Retirer des favoris",
"favorite_set": "Marquer comme favori",
"context_label": "Contexte :",
"section_panels": "Panneaux",
"add_panel": "Panneau",
"add_batch": "Lot",
"add_batch_title": "Générer 2 à 4 panneaux d'un coup",
"add_ai": "Avec IA",
"add_ai_title": "L'IA propose des panneaux à partir d'une entrée de journal, note ou critique",
"unarchive": "Réactiver",
"archive": "Archiver",
"delete": "Supprimer",
"archived_hint": "Histoire archivée — pas de génération de panneaux possible jusqu'à réactivation.",
"confirm_delete_story": "Vraiment supprimer l'histoire « {title} » ?",
"confirm_remove_panel": "Retirer le panneau de l'histoire ? L'image reste dans ta galerie Picture et peut y être supprimée."
},
"styles": {
"comic": "Comic US",
"manga": "Manga",
"cartoon": "Cartoon",
"graphic-novel": "Roman graphique",
"webtoon": "Webtoon"
},
"picker": {
"section_title": "Protagoniste",
"section_hint": "Ton visage est obligatoire. La body-ref et jusqu'à {max} photos de costume sont optionnelles — clique sur une image ou la ✕ pour la retirer.",
"face_required_title": "Face-ref est obligatoire — impossible à retirer",
"face_required_badge": "Obligatoire",
"face_alt": "Face ref",
"face_missing": "Pas de visage",
"face_label": "Visage",
"body_alt": "Body ref",
"body_missing": "Pas de corps",
"body_label": "Corps",
"body_no_in_space": "Aucune body-ref dans l'espace actif",
"toggle_remove": "Clic pour retirer",
"toggle_add": "Clic pour ajouter",
"garment_remove_aria": "Retirer {name}",
"garment_label": "Costume",
"garment_picker_title": "Choisir un costume dans la garde-robe",
"garment_picker_close": "Fermer",
"garment_picker_empty_html": "Aucun autre vêtement disponible — ajoutes-en dans <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
"no_face_alert_html": "Aucune photo de visage dans cet espace. Ajoute-en une dans <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profil → Images</a> — sans face-ref pas de comic.",
"body_tip": "Astuce : une body-ref aide quand le comic montre des panneaux corps entier."
},
"character_detail": {
"back_aria": "Retour aux personnages",
"breadcrumb": "Comic · Personnages",
"loading": "Chargement…",
"not_found": "Personnage introuvable.",
"not_found_hint": "Supprimé ou dans un autre espace.",
"variant_one": "{n} variante",
"variant_other": "{n} variantes",
"pin_open": "Pin en attente",
"favorite_remove": "Retirer des favoris",
"favorite_set": "Marquer comme favori",
"prompt_add_label": "Prompt extra :",
"section_variants": "Variantes",
"action_more_variants": "Plus de variantes",
"empty_variants_title": "Pas encore de variantes.",
"empty_variants_hint_html": "Clique sur <strong class=\"text-foreground\">+ Plus de variantes</strong> en haut à droite pour générer les 4 premières.",
"unarchive": "Réactiver",
"archive": "Archiver",
"delete": "Supprimer",
"archived_hint": "Personnage archivé — pas de génération de variantes possible jusqu'à réactivation.",
"confirm_delete_character": "Vraiment supprimer le personnage « {name} » ?",
"confirm_remove_variant": "Retirer la variante du personnage ? L'image reste dans ta galerie Picture et peut y être supprimée."
}
}

View file

@ -1,80 +0,0 @@
{
"detail": {
"back_aria": "Torna ai fumetti",
"breadcrumb": "Fumetti",
"loading": "Caricamento…",
"not_found": "Storia non trovata.",
"not_found_hint": "Eliminata o in un altro spazio.",
"panel_one": "{n} pannello",
"panel_other": "{n} pannelli",
"reference_one": "{n} riferimento",
"reference_other": "{n} riferimenti",
"favorite_remove": "Rimuovi preferito",
"favorite_set": "Segna come preferito",
"context_label": "Contesto:",
"section_panels": "Pannelli",
"add_panel": "Pannello",
"add_batch": "Batch",
"add_batch_title": "Genera 24 pannelli in una volta",
"add_ai": "Con IA",
"add_ai_title": "L'IA propone pannelli da un diario, nota o recensione",
"unarchive": "Riattiva",
"archive": "Archivia",
"delete": "Elimina",
"archived_hint": "Storia archiviata — generazione pannelli non possibile finché non viene riattivata.",
"confirm_delete_story": "Eliminare davvero la storia \"{title}\"?",
"confirm_remove_panel": "Rimuovere il pannello dalla storia? L'immagine resta nella tua galleria Picture e può essere eliminata lì."
},
"styles": {
"comic": "Comic USA",
"manga": "Manga",
"cartoon": "Cartoon",
"graphic-novel": "Graphic Novel",
"webtoon": "Webtoon"
},
"picker": {
"section_title": "Protagonista",
"section_hint": "Il tuo volto è obbligatorio. La body-ref e fino a {max} foto di costume sono opzionali — clicca un'immagine o la ✕ per rimuoverla.",
"face_required_title": "Face-ref è obbligatoria — non rimovibile",
"face_required_badge": "Obbligatorio",
"face_alt": "Face ref",
"face_missing": "Nessun volto",
"face_label": "Volto",
"body_alt": "Body ref",
"body_missing": "Nessun corpo",
"body_label": "Corpo",
"body_no_in_space": "Nessuna body-ref nello spazio attivo",
"toggle_remove": "Clic per rimuovere",
"toggle_add": "Clic per aggiungere",
"garment_remove_aria": "Rimuovi {name}",
"garment_label": "Costume",
"garment_picker_title": "Scegli un costume dall'armadio",
"garment_picker_close": "Chiudi",
"garment_picker_empty_html": "Nessun altro indumento disponibile — caricane in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
"no_face_alert_html": "Nessuna foto del volto in questo spazio. Caricane una in <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profilo → Immagini</a> — senza face-ref niente comic.",
"body_tip": "Suggerimento: una body-ref aiuta quando il comic mostra pannelli a figura intera."
},
"character_detail": {
"back_aria": "Torna ai personaggi",
"breadcrumb": "Comic · Personaggi",
"loading": "Caricamento…",
"not_found": "Personaggio non trovato.",
"not_found_hint": "Eliminato o in un altro spazio.",
"variant_one": "{n} variante",
"variant_other": "{n} varianti",
"pin_open": "Pin in attesa",
"favorite_remove": "Rimuovi preferito",
"favorite_set": "Segna come preferito",
"prompt_add_label": "Prompt extra:",
"section_variants": "Varianti",
"action_more_variants": "Altre varianti",
"empty_variants_title": "Ancora nessuna variante.",
"empty_variants_hint_html": "Clicca <strong class=\"text-foreground\">+ Altre varianti</strong> in alto a destra per generare le prime 4.",
"unarchive": "Riattiva",
"archive": "Archivia",
"delete": "Elimina",
"archived_hint": "Personaggio archiviato — generazione varianti non possibile fino alla riattivazione.",
"confirm_delete_character": "Eliminare davvero il personaggio \"{name}\"?",
"confirm_remove_variant": "Rimuovere la variante dal personaggio? L'immagine resta nella tua galleria Picture e può essere eliminata lì."
}
}

View file

@ -1,290 +0,0 @@
<!--
Comic module root — Tab-Switcher zwischen Stories und Characters.
Stories sind das primäre Output-Artefakt, Characters die
wiederverwendbaren Identity-Anchors. Tab-State ist lokal und
bleibt erhalten solange ListView gemountet ist (SvelteKit hält
uns gemountet bei Navigation innerhalb /comic).
Face-Ref-Banner (oben, oberhalb der Tabs) übernimmt das Wardrobe-
Pattern 1:1 — wenn der aktive Space kein face-ref hat, kann der
User das Bild direkt hier inline droppen statt in Profil → Bilder
navigieren zu müssen. Banner zeigt sich für beide Tabs (Stories
UND Characters brauchen ein Face-Ref) und blendet sich nach
erfolgreichem Upload mit einem 2.5s Success-Card aus.
-->
<script lang="ts">
import { fade } from 'svelte/transition';
import { CheckCircle, SpinnerGap, UserCircle } from '@mana/shared-icons';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import MeImageUploadZone from '$lib/modules/profile/components/MeImageUploadZone.svelte';
import { ingestMeImageFile } from '$lib/modules/profile/api/me-images';
import StoriesView from './views/ListView.svelte';
import CharactersView from './views/CharactersView.svelte';
import { useAllCharacters } from './queries';
type Tab = 'stories' | 'characters';
let activeTab = $state<Tab>('stories');
const characters$ = useAllCharacters();
const characterCount = $derived(characters$.value?.length ?? 0);
const TABS: { key: Tab; label: string; count?: number }[] = $derived([
{ key: 'stories', label: 'Stories' },
{ key: 'characters', label: 'Characters', count: characterCount },
]);
// Face-ref banner — same UX as Wardrobe's ListView. Without a
// face-ref no Comic-Panel and no Comic-Character can render
// (they all flow through /picture/generate-with-reference with
// face/body refs as required inputs). Banner sits at the
// module root above the tabs so both sub-views see it.
const face$ = useImageByPrimary('face-ref');
const face = $derived(face$.value);
type UploadPhase = 'idle' | 'uploading' | 'success';
let uploadPhase = $state<UploadPhase>('idle');
let uploadedPreviewUrl = $state<string | null>(null);
let faceUploadError = $state<string | null>(null);
let successTimeout: ReturnType<typeof setTimeout> | null = null;
const showBanner = $derived(!face$.loading && (!face || uploadPhase === 'success'));
async function handleFaceUpload(files: File[]) {
if (files.length === 0) return;
if (successTimeout) {
clearTimeout(successTimeout);
successTimeout = null;
}
uploadPhase = 'uploading';
faceUploadError = null;
try {
const image = await ingestMeImageFile(files[0], {
kind: 'face',
claimSlot: 'face-ref',
});
uploadedPreviewUrl = image.thumbnailUrl ?? image.publicUrl ?? null;
uploadPhase = 'success';
// Hold the success card visible briefly so the user sees the
// confirmation, then let the banner unmount and the active
// tab take over as the next step.
successTimeout = setTimeout(() => {
uploadPhase = 'idle';
uploadedPreviewUrl = null;
successTimeout = null;
}, 2500);
} catch (err) {
faceUploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
uploadPhase = 'idle';
}
}
function dismissSuccess() {
if (successTimeout) {
clearTimeout(successTimeout);
successTimeout = null;
}
uploadPhase = 'idle';
uploadedPreviewUrl = null;
}
</script>
<div class="comic-root">
<nav class="comic-tabs" aria-label="Ansicht wechseln">
{#each TABS as tab (tab.key)}
<button
type="button"
class="comic-tab"
class:active={activeTab === tab.key}
aria-pressed={activeTab === tab.key}
onclick={() => (activeTab = tab.key)}
>
{tab.label}
{#if tab.count !== undefined && tab.count > 0}
<span class="comic-tab-count">{tab.count}</span>
{/if}
</button>
{/each}
</nav>
{#if showBanner}
<div
class="face-banner space-y-3 rounded-xl border border-dashed p-4"
class:face-banner-success={uploadPhase === 'success'}
transition:fade={{ duration: 250 }}
>
{#if uploadPhase === 'success'}
<div class="flex items-center gap-3" role="status" aria-live="polite">
{#if uploadedPreviewUrl}
<img
src={uploadedPreviewUrl}
alt=""
class="h-12 w-12 flex-shrink-0 rounded-full border border-primary/30 object-cover"
/>
{:else}
<span
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<CheckCircle size={24} weight="fill" />
</span>
{/if}
<div class="flex-1 space-y-0.5">
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
<CheckCircle size={14} weight="fill" class="text-primary" />
Gesichtsbild gespeichert
</p>
<p class="text-xs text-muted-foreground">
Perfekt — als nächstes baust du deinen ersten Comic-Character oder legst direkt eine
Story an.
</p>
</div>
<button
type="button"
onclick={dismissSuccess}
class="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Schließen
</button>
</div>
{:else}
<div class="flex items-start gap-3 text-sm">
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="font-medium text-foreground">Lade ein Gesichtsbild hoch</p>
<p class="text-xs text-muted-foreground">
Wir brauchen dich auf Bild, damit Comic-Panels und Charakter-Varianten von dir
gerendert werden können. Das Bild bleibt lokal und wird nur für deine eigenen
Generierungen genutzt.
</p>
</div>
</div>
<div class="relative">
<MeImageUploadZone
variant="compact"
label={uploadPhase === 'uploading' ? 'Wird hochgeladen…' : 'Gesichtsbild hochladen'}
hint="Kopf + Schulter, möglichst neutrale Beleuchtung"
disabled={uploadPhase === 'uploading'}
onFiles={handleFaceUpload}
/>
{#if uploadPhase === 'uploading'}
<span
class="pointer-events-none absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
role="status"
aria-live="polite"
>
<SpinnerGap size={12} class="spinner" weight="bold" />
Lade…
</span>
{/if}
</div>
{#if faceUploadError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
{faceUploadError}
</div>
{/if}
{/if}
</div>
{/if}
<div class="comic-body">
{#if activeTab === 'stories'}
<StoriesView />
{:else}
<CharactersView />
{/if}
</div>
</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;
}
.comic-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-shrink: 0;
}
.comic-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
margin-bottom: -1px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.comic-tab:hover {
color: hsl(var(--color-foreground));
}
.comic-tab.active {
color: hsl(var(--color-foreground));
border-bottom-color: hsl(var(--color-primary));
}
.comic-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
font-size: 0.6875rem;
font-weight: 600;
}
.comic-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.face-banner {
border-color: hsl(var(--color-border));
background: hsl(var(--color-background) / 0.5);
transition:
background-color 0.25s,
border-color 0.25s;
}
.face-banner-success {
border-style: solid;
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.06);
}
/* Spinner reaches into Phosphor's child SVG via :global(). */
.face-banner :global(.spinner) {
animation: comic-spin 0.9s linear infinite;
}
@keyframes comic-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@container (min-width: 640px) {
.comic-root {
padding: 0.75rem 1rem 1rem;
gap: 1rem;
}
}
</style>

View file

@ -1,208 +0,0 @@
/**
* Character-variant generation. Renders N stylised portraits of the
* user from face/body meImages with the chosen ComicStyle prefix,
* persists each into `picture.images` with a `comicCharacterId`
* back-ref, and appends each to the character's `variantMediaIds`.
*
* The endpoint and the HTTP shape are identical to panel-generation
* (`api/generate-panel.ts`); only the prompt-template differs (panel
* = "what happens in this panel", character = "portrait of the same
* person, identity anchor"). One call with `n=4` returns all four
* variants in a single batch that's the gpt-image-2 multi-image
* response shape (`{images: [{imageUrl, mediaId}, ...]}`).
*
* Plan: docs/plans/comic-module.md §11 (Mc2).
*/
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import { comicCharactersStore } from '../stores/characters.svelte';
import { STYLE_PREFIXES } from '../styles';
import { DEFAULT_PANEL_MODEL, type PanelModel } from './generate-panel';
import type { ComicCharacter, ComicStyle } from '../types';
export type CharacterSize = '1024x1024' | '1024x1536';
export interface RunCharacterGenerateParams {
character: ComicCharacter;
/** How many variants to render in one batch 1-4 (gpt-image-2's
* hard server cap). Default 4: the picker shows enough options
* for a real choice without burning credits on speculative noise. */
count?: number;
quality?: 'low' | 'medium' | 'high';
size?: CharacterSize;
model?: PanelModel;
}
export interface RunCharacterGenerateResult {
variantMediaIds: string[];
imageUrls: string[];
prompt: string;
model: string;
}
function dimsForSize(size: CharacterSize): { width: number; height: number } {
if (size === '1024x1536') return { width: 1024, height: 1536 };
return { width: 1024, height: 1024 };
}
/**
* Compose the gpt-image-2 prompt for a character variant. The
* style-prefix sets the visual register; the identity-anchor
* instruction biases the model toward keeping face features
* recognisable across the four variants of one batch.
*
* Caption / dialogue strings are deliberately left out characters
* are bare portraits, not panels with text.
*/
export function composeCharacterPrompt(
style: ComicStyle,
addPrompt: string | null | undefined
): string {
const parts: string[] = [
STYLE_PREFIXES[style],
'portrait of the user',
'looking natural, head and shoulders visible',
'neutral background, clear identity anchor — same face, same eyes, recognisable across panels',
];
const trimmed = addPrompt?.trim();
if (trimmed) {
parts.push(trimmed);
}
return parts.join('. ');
}
/**
* Generate N variants and append them to the character. Caller
* passes the snapshot character (post-create), this function
* mutates Dexie via `imagesStore.insert` + `comicCharactersStore.appendVariant`.
*/
export async function runCharacterGenerate(
params: RunCharacterGenerateParams
): Promise<RunCharacterGenerateResult> {
const { character } = params;
const count = Math.max(1, Math.min(4, params.count ?? 4));
const quality = params.quality ?? 'medium';
const size: CharacterSize = params.size ?? '1024x1024';
const model: PanelModel = params.model ?? DEFAULT_PANEL_MODEL;
if (!character.sourceFaceMediaId) {
throw new Error('Character braucht ein Source-Face-Bild.');
}
const referenceMediaIds: string[] = [character.sourceFaceMediaId];
if (character.sourceBodyMediaId) {
referenceMediaIds.push(character.sourceBodyMediaId);
}
const composed = composeCharacterPrompt(character.style, character.addPrompt);
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: composed,
referenceMediaIds,
model,
quality,
size,
n: count,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
detail?: string;
required?: number;
};
if (res.status === 402) {
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
}
if (res.status === 404) {
throw new Error(
'Source-Bilder im Server-Ownership-Check durchgefallen — Face-/Body-Refs fehlen im aktiven Space.'
);
}
const label = body.error ?? `Character-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;
};
// Normalise: the endpoint returns either `images: [...]` (n>=1
// path) or a legacy `imageUrl + mediaId` flat shape. Both go
// through the same persist loop below.
const items =
data.images && data.images.length > 0
? data.images
: data.imageUrl
? [{ imageUrl: data.imageUrl, mediaId: data.mediaId }]
: [];
if (items.length === 0) {
throw new Error('Keine Variant-Bilder zurückgegeben');
}
const dims = dimsForSize(size);
const variantMediaIds: string[] = [];
const imageUrls: string[] = [];
// Persist each variant in order — auto-pin auf erste Variant
// passiert in `appendVariant` falls noch keine gepinnt ist, der
// User kann später re-pinnen.
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item.imageUrl || !item.mediaId) continue;
const localImageId = crypto.randomUUID();
const nowIso = new Date().toISOString();
const variantIndex = (character.variantMediaIds?.length ?? 0) + i;
await imagesStore.insert({
id: localImageId,
prompt: data.prompt,
negativePrompt: null,
model: data.model,
publicUrl: item.imageUrl,
storagePath: item.mediaId,
filename: `comic-character-${character.id}-${variantIndex + 1}.png`,
format: 'png',
width: dims.width,
height: dims.height,
visibility: 'private',
isFavorite: false,
downloadCount: 0,
generationMode: 'reference',
referenceImageIds: referenceMediaIds,
comicCharacterId: character.id,
createdAt: nowIso,
});
await comicCharactersStore.appendVariant(character.id, localImageId);
variantMediaIds.push(localImageId);
imageUrls.push(item.imageUrl);
}
if (variantMediaIds.length === 0) {
throw new Error('Server lieferte Bilder ohne mediaId — kein Variant gespeichert');
}
return {
variantMediaIds,
imageUrls,
prompt: data.prompt,
model: data.model,
};
}

View file

@ -1,239 +0,0 @@
/**
* 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';
/**
* Models that can drive panel rendering. Same closed set as
* Wardrobe's Try-On picker so character consistency between a
* user's outfit try-ons and their comic panels stays comparable
* (different models different faces).
*
* - `openai/gpt-image-2` existing default, mid-tier cost.
* Server-side transparent fallback to gpt-image-1 for
* unverified OpenAI orgs; see apps/api picture/routes.ts.
* - `google/gemini-3-pro-image-preview` Nano Banana Pro.
* Strong character consistency across panels, higher cost.
* - `google/gemini-3.1-flash-image-preview` Nano Banana 2.
* Newest + fast + cheap, good default for drafts.
*
* Credit tarifs are set by creditsFor() in picture/routes.ts.
*/
export type PanelModel =
| 'openai/gpt-image-2'
| 'google/gemini-3-pro-image-preview'
| 'google/gemini-3.1-flash-image-preview';
export const DEFAULT_PANEL_MODEL: PanelModel = 'openai/gpt-image-2';
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;
/** Rendering backend defaults to `DEFAULT_PANEL_MODEL`. Mirrored
* from Wardrobe so users can pick per-call without a story-level
* schema change. See `PanelModelPicker.svelte`. */
model?: PanelModel;
}
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;
model: PanelModel;
}): 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: opts.model,
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';
const effectiveModel: PanelModel = params.model ?? DEFAULT_PANEL_MODEL;
// 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,
model: effectiveModel,
});
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,
});
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

@ -1,73 +0,0 @@
/**
* Storyboard client. Calls `/api/v1/comic/storyboard` with the
* decrypted source text (journal entry, note, library review,
* writing draft, calendar event description) and the chosen style,
* receives an ordered `Panel[]` suggestion that the user reviews +
* edits before firing the batch-gen flow (M3).
*
* Cross-module decrypt stays client-side the browser loads the
* source module's row, passes it through its own decryptor, and
* hands us plaintext. No Key-Grants / server-side decrypts involved
* (matches the plan §6 decision: M4 is interactive client-side).
*
* Plan: docs/plans/comic-module.md M4.
*/
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
import type { ComicStyle } from '../types';
export type StoryboardSourceModule = 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
export interface StoryboardPanel {
prompt: string;
caption?: string;
dialogue?: string;
}
export interface SuggestPanelsParams {
style: ComicStyle;
sourceText: string;
panelCount: number;
/** Story-level briefing the author typed when creating the story.
* Gets prepended server-side so Claude knows the tonal register. */
storyContext?: string | null;
/** Logged for observability only — not sent to the LLM. */
sourceModule?: StoryboardSourceModule;
}
export interface SuggestPanelsResult {
panels: StoryboardPanel[];
model: string;
durationMs: number;
}
export async function suggestPanels(params: SuggestPanelsParams): Promise<SuggestPanelsResult> {
const token = await authStore.getValidToken();
const res = await fetch(`${getManaApiUrl()}/api/v1/comic/storyboard`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
style: params.style,
sourceText: params.sourceText,
panelCount: params.panelCount,
storyContext: params.storyContext,
sourceModule: params.sourceModule,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string };
const label = body.error ?? `Storyboard fehlgeschlagen (${res.status})`;
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
}
const data = (await res.json()) as SuggestPanelsResult;
if (!Array.isArray(data.panels) || data.panels.length === 0) {
throw new Error('Keine Panels vom Modell zurück — versuche es mit anderem Text oder Stil.');
}
return data;
}

View file

@ -1,9 +0,0 @@
/**
* Comic module Dexie table accessors.
*/
import { db } from '$lib/data/database';
import type { LocalComicStory, LocalComicCharacter } from './types';
export const comicStoriesTable = db.table<LocalComicStory>('comicStories');
export const comicCharactersTable = db.table<LocalComicCharacter>('comicCharacters');

View file

@ -1,247 +0,0 @@
/**
* Comic encryption roundtrip test.
*
* `comicStories` ships with `panelMeta: Record<panelImageId, {caption,
* dialogue, promptUsed, sourceInput}>` as an encrypted JSON blob via the
* registry entry `entry<LocalComicStory>(['title', 'description',
* 'storyContext', 'tags', 'panelMeta'])`. This test locks in the
* roundtrip contract: every encrypted field recovers its exact value
* after an encryptdecrypt cycle, the structural fields (id, style,
* characterMediaIds, panelImageIds, booleans, timestamps) stay
* plaintext, and the nested panelMeta object (including its
* sourceInput.module enum and sourceInput.entryId FK) survives
* untouched.
*
* Modeled after notes-encryption.test.ts but uses encryptRecord /
* decryptRecord directly no Dexie round-trip needed to prove the
* registry contract, and skipping fake-indexeddb keeps the test fast.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
encryptRecord,
decryptRecord,
generateMasterKey,
MemoryKeyProvider,
setKeyProvider,
isEncrypted,
} from '$lib/data/crypto';
import { setCurrentUserId } from '$lib/data/current-user';
import type { ComicPanelMeta, LocalComicCharacter, LocalComicStory } from './types';
const TABLE = 'comicStories';
let provider: MemoryKeyProvider;
beforeEach(async () => {
const key = await generateMasterKey();
provider = new MemoryKeyProvider();
provider.setKey(key);
setKeyProvider(provider);
setCurrentUserId('test-user');
});
afterEach(() => {
provider.setKey(null);
setCurrentUserId(null);
});
function makeStory(overrides: Partial<LocalComicStory> = {}): LocalComicStory {
return {
id: 'story-1',
title: 'Bug-Hunt-Frust',
description: 'Ein 4-Panel-Comic zum Sync-Bug vom Dienstag',
style: 'comic',
characterMediaIds: ['me-face-123', 'wardrobe-tee-456'],
storyContext: 'Ich ärgere mich über einen Off-by-one in der LWW-Logik.',
panelImageIds: ['img-a', 'img-b'],
panelMeta: {
'img-a': {
caption: 'Montag, 9 Uhr.',
dialogue: 'Der Test ist grün.',
promptUsed: 'developer sitting at desk, confident expression',
sourceInput: { module: 'journal', entryId: 'journal-42' },
},
'img-b': {
caption: 'Eine Stunde später...',
dialogue: 'Der Test ist rot. WARUM.',
promptUsed: 'same developer, panicked expression, dark lighting',
},
},
tags: ['frust', 'devlog', '2026'],
isFavorite: true,
isArchived: false,
visibility: 'private',
...overrides,
};
}
describe('comicStories encryption registry', () => {
it('encrypts title, description, storyContext, tags, panelMeta; leaves structural fields plaintext', async () => {
const row = makeStory();
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
// Encrypted fields are ciphertext
expect(isEncrypted(row.title)).toBe(true);
expect(isEncrypted(row.description)).toBe(true);
expect(isEncrypted(row.storyContext)).toBe(true);
// tags is a string[] — aes.ts JSON-stringifies before wrap, the
// resulting value is still detected as encrypted via isEncrypted.
expect(isEncrypted(row.tags)).toBe(true);
// panelMeta is a nested object — same array-path pattern.
expect(isEncrypted(row.panelMeta)).toBe(true);
// Nothing user-typed slipped through
expect(String(row.title)).not.toContain('Bug-Hunt');
expect(String(row.description)).not.toContain('4-Panel');
expect(String(row.storyContext)).not.toContain('Off-by-one');
expect(JSON.stringify(row.panelMeta)).not.toContain('grün');
expect(JSON.stringify(row.panelMeta)).not.toContain('WARUM');
expect(JSON.stringify(row.tags)).not.toContain('devlog');
// Structural fields untouched
expect(row.id).toBe('story-1');
expect(row.style).toBe('comic');
expect(row.characterMediaIds).toEqual(['me-face-123', 'wardrobe-tee-456']);
expect(row.panelImageIds).toEqual(['img-a', 'img-b']);
expect(row.isFavorite).toBe(true);
expect(row.isArchived).toBe(false);
expect(row.visibility).toBe('private');
});
it('roundtrips the full panelMeta nested object', async () => {
const row = makeStory();
const originalMeta: Record<string, ComicPanelMeta> = JSON.parse(JSON.stringify(row.panelMeta));
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
expect(row.title).toBe('Bug-Hunt-Frust');
expect(row.description).toBe('Ein 4-Panel-Comic zum Sync-Bug vom Dienstag');
expect(row.storyContext).toBe('Ich ärgere mich über einen Off-by-one in der LWW-Logik.');
expect(row.tags).toEqual(['frust', 'devlog', '2026']);
// Nested shape survives intact — caption / dialogue / promptUsed /
// sourceInput (module + entryId) all present and equal.
expect(row.panelMeta).toEqual(originalMeta);
});
it('handles an empty panelMeta record (freshly created story with no panels yet)', async () => {
const row = makeStory({
panelImageIds: [],
panelMeta: {},
});
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
// Even the empty object ships encrypted — registry doesn't skip
// empty non-null values.
expect(isEncrypted(row.panelMeta)).toBe(true);
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
expect(row.panelMeta).toEqual({});
expect(row.panelImageIds).toEqual([]);
});
it('handles a panelMeta entry without sourceInput (manual panel, not AI-Storyboard)', async () => {
const row = makeStory({
panelMeta: {
'img-a': {
caption: 'Manuell geschrieben',
promptUsed: 'character looking at sunset',
// no dialogue, no sourceInput
},
},
});
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
expect(row.panelMeta['img-a']).toEqual({
caption: 'Manuell geschrieben',
promptUsed: 'character looking at sunset',
});
});
it('leaves null-valued description unchanged (no crash, no wrap)', async () => {
const row = makeStory({ description: null });
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
expect(row.description).toBe(null);
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
expect(row.description).toBe(null);
});
});
// ─── Comic-Characters ─────────────────────────────────────────────
const CHAR_TABLE = 'comicCharacters';
function makeCharacter(overrides: Partial<LocalComicCharacter> = {}): LocalComicCharacter {
return {
id: 'char-1',
name: 'Manga-Me',
description: 'Mein Manga-Stil mit freundlichem Ausdruck',
style: 'manga',
addPrompt: 'Casual Outfit, freundliches Lächeln',
sourceFaceMediaId: 'me-face-99',
sourceBodyMediaId: 'me-body-77',
variantMediaIds: ['variant-a', 'variant-b', 'variant-c'],
pinnedVariantId: 'variant-b',
tags: ['casual', 'manga', 'standard'],
isFavorite: true,
isArchived: false,
...overrides,
};
}
describe('comicCharacters encryption registry', () => {
it('encrypts name + description + addPrompt + tags; leaves structural fields plaintext', async () => {
const row = makeCharacter();
await encryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
expect(isEncrypted(row.name)).toBe(true);
expect(isEncrypted(row.description)).toBe(true);
expect(isEncrypted(row.addPrompt)).toBe(true);
expect(isEncrypted(row.tags)).toBe(true);
// User-typed prose nicht im Klartext durchgerutscht
expect(String(row.name)).not.toContain('Manga-Me');
expect(String(row.description)).not.toContain('freundlichem');
expect(String(row.addPrompt)).not.toContain('Lächeln');
expect(JSON.stringify(row.tags)).not.toContain('manga');
// Strukturelle Felder unangetastet — Style-Filter, Source-FKs,
// Variant-Liste und Pin müssen im Index lesbar bleiben.
expect(row.id).toBe('char-1');
expect(row.style).toBe('manga');
expect(row.sourceFaceMediaId).toBe('me-face-99');
expect(row.sourceBodyMediaId).toBe('me-body-77');
expect(row.variantMediaIds).toEqual(['variant-a', 'variant-b', 'variant-c']);
expect(row.pinnedVariantId).toBe('variant-b');
expect(row.isFavorite).toBe(true);
expect(row.isArchived).toBe(false);
});
it('roundtrips name / description / addPrompt / tags', async () => {
const row = makeCharacter();
await encryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
await decryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
expect(row.name).toBe('Manga-Me');
expect(row.description).toBe('Mein Manga-Stil mit freundlichem Ausdruck');
expect(row.addPrompt).toBe('Casual Outfit, freundliches Lächeln');
expect(row.tags).toEqual(['casual', 'manga', 'standard']);
});
it('handles a build-in-progress character with no variants yet', async () => {
const row = makeCharacter({
variantMediaIds: [],
pinnedVariantId: null,
addPrompt: null,
description: null,
});
await encryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
// addPrompt and description are null — no-wrap path
expect(row.addPrompt).toBe(null);
expect(row.description).toBe(null);
await decryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
expect(row.variantMediaIds).toEqual([]);
expect(row.pinnedVariantId).toBe(null);
});
});

View file

@ -1,404 +0,0 @@
<!--
BatchPanelEditor — compose 2..N panels in one go. All entries share
the story's style prefix + character refs; the user writes the
per-panel prompt/caption/dialog in stacked cards. On submit we fire
parallel `runPanelGenerate()` calls via `Promise.allSettled` so one
failure doesn't block the others, and render per-panel progress +
retry chips below the form.
The batch executor is a thin layer on top of the M2 single-panel
flow: each row goes through the identical HTTP path, appendPanel,
and picture.images write, so there's no divergence between single
and batch outputs.
Plan: docs/plans/comic-module.md M3.
-->
<script lang="ts">
import {
CheckCircle,
Plus,
Sparkle,
SpinnerGap,
Trash,
WarningCircle,
X,
} from '@mana/shared-icons';
import {
runPanelGenerate,
DEFAULT_PANEL_MODEL,
type PanelModel,
type PanelSize,
} from '../api/generate-panel';
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
import type { ComicStory } from '../types';
import PanelModelPicker from './PanelModelPicker.svelte';
interface Props {
story: ComicStory;
onClose: () => void;
onGenerated?: (panelIds: string[]) => void;
}
let { story, onClose, onGenerated }: Props = $props();
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 };
// Max entries per batch — plan cap. N=4 balances "write a short comic
// in one sitting" against "one failure takes out too many credits".
const MAX_BATCH = 4;
interface Row {
id: string;
prompt: string;
caption: string;
dialogue: string;
}
function emptyRow(): Row {
return { id: crypto.randomUUID(), prompt: '', caption: '', dialogue: '' };
}
let rows = $state<Row[]>([emptyRow(), emptyRow()]);
let quality = $state<Quality>('medium');
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
// svelte-ignore state_referenced_locally
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
// Per-row execution state — mirrors `rows` by id so we can render
// chips during/after submit without touching the input fields.
type RowStatus = 'idle' | 'pending' | 'ok' | 'error';
let rowStatus = $state<Record<string, { status: RowStatus; error?: string }>>({});
let submitting = $state(false);
const panelCount = $derived(story.panelImageIds.length);
const roomLeft = $derived(Math.max(0, MAX_PANELS_PER_STORY - panelCount));
const effectiveRows = $derived(rows.slice(0, Math.min(MAX_BATCH, roomLeft)));
const filledRows = $derived(effectiveRows.filter((r) => r.prompt.trim().length > 0));
const canAdd = $derived(rows.length < MAX_BATCH && rows.length < roomLeft);
const canSubmit = $derived(filledRows.length > 0 && !submitting && roomLeft > 0);
const warn = $derived(
panelCount + filledRows.length >= PANEL_COUNT_WARN_THRESHOLD &&
panelCount + filledRows.length <= MAX_PANELS_PER_STORY
);
const atCap = $derived(roomLeft === 0);
const totalCost = $derived(CREDIT_COST[quality] * filledRows.length);
function addRow() {
if (!canAdd) return;
rows.push(emptyRow());
}
function removeRow(id: string) {
if (rows.length <= 1) return;
rows = rows.filter((r) => r.id !== id);
delete rowStatus[id];
}
async function submitRow(row: Row): Promise<string | null> {
rowStatus[row.id] = { status: 'pending' };
try {
const result = await runPanelGenerate({
story,
panelPrompt: row.prompt,
caption: row.caption.trim() || undefined,
dialogue: row.dialogue.trim() || undefined,
quality,
size,
model,
});
rowStatus[row.id] = { status: 'ok' };
return result.imageId;
} catch (err) {
rowStatus[row.id] = {
status: 'error',
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
};
return null;
}
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
// Re-init status so a retry-submit doesn't leak old chip state.
rowStatus = {};
// Promise.allSettled preserves each row's outcome independently —
// a 402 Credits-Error on row 2 won't cancel rows 3+4. The story's
// `panelImageIds` grows in whatever order the calls resolve; the
// user can manually reorder panels in M5+ if needed.
const submissions = filledRows.map((r) => submitRow(r));
const results = await Promise.allSettled(submissions);
submitting = false;
const successfulIds = results
.map((r) => (r.status === 'fulfilled' ? r.value : null))
.filter((id): id is string => id !== null);
onGenerated?.(successfulIds);
// Clear successful rows so the user can type the next batch
// without them reappearing; keep failed rows filled for retry.
const failedIds = Object.entries(rowStatus)
.filter(([, s]) => s.status === 'error')
.map(([id]) => id);
if (failedIds.length === 0) {
rows = [emptyRow(), emptyRow()];
rowStatus = {};
} else {
rows = rows.filter((r) => failedIds.includes(r.id));
}
}
async function retryRow(row: Row) {
if (submitting) return;
submitting = true;
await submitRow(row);
submitting = false;
if (rowStatus[row.id]?.status === 'ok') {
// Strip successful row out.
rows = rows.filter((r) => r.id !== row.id);
delete rowStatus[row.id];
}
}
</script>
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
<header class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">Batch-Panels</h3>
<p class="text-xs text-muted-foreground">
{filledRows.length}
{filledRows.length === 1 ? 'Panel' : 'Panels'} · {story.characterMediaIds.length} Referenz{story
.characterMediaIds.length === 1
? ''
: 'en'} · {roomLeft}
{roomLeft === 1 ? 'Slot' : 'Slots'} frei
</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="Batch-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"
>
Die Story ist am {MAX_PANELS_PER_STORY}-Panel-Limit. Entferne ältere Panels oder lege eine
neue Story an.
</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 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-2">
{#each effectiveRows as row, index (row.id)}
{@const status = rowStatus[row.id]}
<div class="rounded-lg border border-border bg-background p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] text-foreground"
>
{panelCount + index + 1}
</span>
<span>Panel {index + 1}</span>
{#if status?.status === 'pending'}
<span
class="inline-flex items-center gap-1 text-primary"
role="status"
aria-live="polite"
>
<SpinnerGap size={12} class="spinner" weight="bold" />
Wird generiert…
</span>
{:else if status?.status === 'ok'}
<span class="inline-flex items-center gap-1 text-primary">
<CheckCircle size={12} weight="fill" />
Fertig
</span>
{:else if status?.status === 'error'}
<span class="inline-flex items-center gap-1 text-error">
<WarningCircle size={12} weight="fill" />
Fehlgeschlagen
</span>
{/if}
</div>
<div class="flex items-center gap-1">
{#if status?.status === 'error'}
<button
type="button"
onclick={() => retryRow(row)}
disabled={submitting}
class="text-[11px] font-medium text-primary hover:underline disabled:opacity-50"
>
Neu versuchen
</button>
{/if}
{#if rows.length > 1}
<button
type="button"
onclick={() => removeRow(row.id)}
disabled={submitting}
class="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-error disabled:opacity-50"
aria-label="Zeile entfernen"
>
<Trash size={12} />
</button>
{/if}
</div>
</div>
<textarea
bind:value={row.prompt}
rows={2}
placeholder="Was passiert in diesem Panel?"
maxlength={600}
class="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
></textarea>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<input
type="text"
bind:value={row.caption}
placeholder="Caption (optional)"
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
/>
<input
type="text"
bind:value={row.dialogue}
placeholder="Dialog (optional)"
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={submitting || atCap}
/>
</div>
{#if status?.status === 'error' && status.error}
<p class="mt-2 text-[11px] text-error" role="alert">{status.error}</p>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={addRow}
disabled={!canAdd || submitting}
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus size={12} />
Weiteres Panel ({rows.length}/{Math.min(MAX_BATCH, roomLeft)})
</button>
</div>
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={submitting} />
<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>
<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" />
{filledRows.length}
{filledRows.length === 1 ? 'Panel' : 'Panels'} werden generiert…
{:else}
<Sparkle size={14} />
{filledRows.length}
{filledRows.length === 1 ? 'Panel' : 'Panels'} generieren ({totalCost}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

@ -1,326 +0,0 @@
<!--
CharacterBuilder — Source picken, Stil picken, Add-Prompt, dann
4 Varianten in einem Batch generieren. Im Detail-View des
Characters wird derselbe Builder als „Mehr Varianten generieren"
wieder benutzt (mit pre-selected Source + Style aus dem Character).
Two modes:
- "create" — Builder erstellt erst die Character-Row (Name +
Stil + Source + AddPrompt), dann den ersten Variant-Batch.
- "extend" — Character existiert schon; Builder feuert nur
weitere Variants und schreibt sie in den existierenden
Character.
Variant-Generierung läuft synchron als ein Server-Call mit
n=4 (gpt-image-2-Server-Cap). User wartet ~30-60s auf alle 4
Bilder gleichzeitig.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { comicCharactersStore } from '../stores/characters.svelte';
import { runCharacterGenerate } from '../api/generate-character';
import { DEFAULT_PANEL_MODEL, type PanelModel } from '../api/generate-panel';
import type { ComicCharacter, ComicStyle } from '../types';
import StylePicker from './StylePicker.svelte';
import PanelModelPicker from './PanelModelPicker.svelte';
interface Props {
/** When set, builder runs in "extend" mode for an existing
* character — name+style+source are locked, only Add-Prompt
* is editable per generation. */
existing?: ComicCharacter;
/** Optional pre-fills for create-mode — used by the wardrobe-
* hook (Mc5) to seed an addPrompt like "wearing the
* Bühnenoutfit" when the user clicks "Als Comic-Character"
* on a Wardrobe-Outfit. Ignored in extend-mode. */
initialName?: string;
initialAddPrompt?: string;
initialStyle?: ComicStyle;
/** Called after the first successful variant batch with the
* resulting character id, so the parent route can navigate. */
onCreated?: (characterId: string) => void;
onClose?: () => void;
}
let { existing, initialName, initialAddPrompt, initialStyle, onClose, onCreated }: Props =
$props();
const isExtend = $derived(Boolean(existing));
// Builder state. In extend-mode all of these come from `existing`
// at mount time and aren't editable; in create-mode the user fills
// them in (with optional pre-fills from URL-params via the route
// page wrapper). Init-time read is intentional — the
// character is always remounted via {#key} when the route id
// changes, so capturing the snapshot here is correct.
// svelte-ignore state_referenced_locally
let name = $state(existing?.name ?? initialName ?? '');
// svelte-ignore state_referenced_locally
let style = $state<ComicStyle>(existing?.style ?? initialStyle ?? 'comic');
// svelte-ignore state_referenced_locally
let addPrompt = $state(existing?.addPrompt ?? initialAddPrompt ?? '');
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 };
let quality = $state<Quality>('medium');
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
const face$ = useImageByPrimary('face-ref');
const body$ = useImageByPrimary('body-ref');
const face = $derived(face$.value);
const body = $derived(body$.value);
const hasFace = $derived(Boolean(existing?.sourceFaceMediaId || face?.mediaId));
const sourceFaceMediaId = $derived(existing?.sourceFaceMediaId ?? face?.mediaId ?? null);
const sourceBodyMediaId = $derived(existing?.sourceBodyMediaId ?? body?.mediaId ?? null);
let useBodyRef = $state(true); // toggle in create-mode
let busy = $state(false);
let errorMsg = $state<string | null>(null);
const VARIANT_COUNT = 4;
const totalCost = $derived(CREDIT_COST[quality] * VARIANT_COUNT);
const canSubmit = $derived(
!busy && hasFace && (isExtend || name.trim().length > 0) // create-mode requires a name
);
async function handleGenerate(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit || !sourceFaceMediaId) return;
busy = true;
errorMsg = null;
try {
let character: ComicCharacter;
if (existing) {
character = existing;
// Optionally update addPrompt on the existing character
// so future "Mehr Varianten"-Calls remember the latest.
if (addPrompt.trim() !== (existing.addPrompt ?? '')) {
await comicCharactersStore.updateCharacter(existing.id, {
addPrompt: addPrompt.trim() || null,
});
}
} else {
character = await comicCharactersStore.createCharacter({
name: name.trim(),
style,
sourceFaceMediaId,
sourceBodyMediaId: useBodyRef ? sourceBodyMediaId : null,
addPrompt: addPrompt.trim() || null,
});
}
await runCharacterGenerate({
character,
count: VARIANT_COUNT,
quality,
model,
});
busy = false;
onCreated?.(character.id);
if (!isExtend) {
await goto(`/comic/character/${character.id}`);
}
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen';
busy = false;
}
}
</script>
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
<header class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">
{isExtend ? 'Mehr Varianten generieren' : 'Neuer Character'}
</h3>
<p class="text-xs text-muted-foreground">
{isExtend
? `Erweitert "${existing?.name}" um ${VARIANT_COUNT} weitere Varianten — gleicher Stil, gleiche Source.`
: `Erstellt einen Character und rendert direkt ${VARIANT_COUNT} Varianten zur Auswahl.`}
</p>
</div>
{#if onClose}
<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="Schließen"
>
<X size={14} />
</button>
{/if}
</header>
<form onsubmit={handleGenerate} class="space-y-4">
{#if !isExtend}
<!-- Name -->
<div class="space-y-1.5">
<label
for="character-name"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Name
</label>
<input
id="character-name"
type="text"
bind:value={name}
placeholder="Manga-Me, Cartoon-Casual, Action-Pose-Me…"
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 focus:ring-1 focus:ring-primary disabled:opacity-50"
disabled={busy}
required
/>
</div>
<!-- Style picker -->
<div class="space-y-2">
<div class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Stil
</div>
<StylePicker value={style} onChange={(next) => (style = next)} disabled={busy} />
</div>
{/if}
<!-- Add-Prompt -->
<div class="space-y-1.5">
<label
for="character-add-prompt"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Zusätzlicher Prompt
<span class="font-normal normal-case text-muted-foreground">(optional)</span>
</label>
<input
id="character-add-prompt"
type="text"
bind:value={addPrompt}
placeholder="z.B. freundlicher Ausdruck, casual outfit, action pose"
maxlength={200}
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 focus:ring-1 focus:ring-primary disabled:opacity-50"
disabled={busy}
/>
<p class="text-[11px] text-muted-foreground">
Englisch rendert stabiler. Wird auf alle {VARIANT_COUNT} Varianten in dieser Runde angewendet.
</p>
</div>
{#if !hasFace}
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
Kein Gesichtsbild im aktiven Space. Lade eines in
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a>
hoch — ohne Face-Ref kann kein Character generiert werden.
</div>
{:else if !isExtend}
<!-- Source preview + body toggle -->
<div class="space-y-2">
<div class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Quelle
</div>
<div class="flex flex-wrap items-start gap-2">
{#if face?.publicUrl}
<div class="flex flex-col items-center gap-1">
<div class="h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40">
<img
src={face.thumbnailUrl ?? face.publicUrl}
alt="Face-Ref"
class="h-full w-full object-cover"
/>
</div>
<span class="text-[10px] font-medium text-muted-foreground">Face</span>
</div>
{/if}
{#if body?.publicUrl}
<div class="flex flex-col items-center gap-1">
<button
type="button"
onclick={() => (useBodyRef = !useBodyRef)}
disabled={busy}
class="group relative h-20 w-20 overflow-hidden rounded-md border-2 transition-all
{useBodyRef
? 'border-primary shadow-sm shadow-primary/20'
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100'}"
aria-pressed={useBodyRef}
title={useBodyRef ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
>
<img
src={body.thumbnailUrl ?? body.publicUrl}
alt="Body-Ref"
class="h-full w-full object-cover"
/>
</button>
<span class="text-[10px] font-medium text-muted-foreground">Body</span>
</div>
{/if}
</div>
</div>
{/if}
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={busy} />
<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={busy}
aria-pressed={quality === q}
>
{q} ({CREDIT_COST[q]}c)
</button>
{/each}
</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 busy}
<SpinnerGap size={14} class="spinner" weight="bold" />
{VARIANT_COUNT} Varianten werden gerendert…
{:else}
<Sparkle size={14} />
{VARIANT_COUNT} Varianten generieren ({totalCost}c)
{/if}
</button>
</div>
</form>
</div>
<style>
:global(.spinner) {
animation: char-spin 0.9s linear infinite;
}
@keyframes char-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,78 +0,0 @@
<!--
Grid tile for a comic-character. Cover = pinned variant (or first
variant if none pinned yet — happens during build). Stories made
with this character snapshot the pinned mediaId at create time
(re-pinning later doesn't rewrite their refs).
-->
<script lang="ts">
import { Heart, Sparkle } from '@mana/shared-icons';
import { STYLE_LABELS } from '../constants';
import { usePanelImage } from '../queries';
import { characterCoverVariantId, type ComicCharacter } from '../types';
interface Props {
character: ComicCharacter;
}
let { character }: Props = $props();
const coverId = $derived(characterCoverVariantId(character));
// svelte-ignore state_referenced_locally
const cover$ = usePanelImage(coverId);
const cover = $derived(cover$.value);
const variantCount = $derived(character.variantMediaIds.length);
const isPinned = $derived(Boolean(character.pinnedVariantId));
</script>
<a
href="/comic/character/{character.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={character.name}
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 keine Variante</span>
</div>
{/if}
<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[character.style].de}
</span>
{#if character.isFavorite}
<span class="absolute right-2 top-2 text-rose-500" aria-label="Favorit">
<Heart size={14} weight="fill" />
</span>
{/if}
{#if !isPinned && variantCount > 0}
<span
class="absolute right-2 bottom-2 rounded-full bg-amber-500/90 px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm backdrop-blur"
title="Kein Variant gepinned — wird beim Story-Create blockiert"
>
Pin offen
</span>
{/if}
</div>
<div class="space-y-0.5 px-3 py-2">
<h3 class="truncate text-sm font-medium text-foreground">{character.name}</h3>
<p class="text-xs text-muted-foreground">
{variantCount}
{variantCount === 1 ? 'Variante' : 'Varianten'}
</p>
</div>
</a>

View file

@ -1,155 +0,0 @@
<!--
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: primary body-ref (for full-body framing).
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 { _ } from 'svelte-i18n';
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 face = $derived(face$.value);
const body = $derived(body$.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);
function toggleBody() {
if (!body?.mediaId) return;
if (bodyInValue) {
onChange(value.filter((id) => id !== body.mediaId));
} else {
const next = [...value];
const insertAt = face?.mediaId && next[0] === face.mediaId ? 1 : 0;
next.splice(insertAt, 0, body.mediaId);
onChange(next);
}
}
</script>
<div class="space-y-3">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{$_('comic.picker.section_title')}
</h3>
</div>
<div class="flex flex-wrap items-start gap-2">
<!-- Face ref tile — mandatory, not deselectable. -->
<div class="flex flex-col items-center gap-1">
{#if face?.publicUrl}
<div
class="relative h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40"
title={$_('comic.picker.face_required_title')}
>
<img
src={face.thumbnailUrl ?? face.publicUrl}
alt={$_('comic.picker.face_alt')}
class="h-full w-full object-cover"
/>
<span
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wider text-white"
>
{$_('comic.picker.face_required_badge')}
</span>
</div>
{: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>{$_('comic.picker.face_missing')}</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground"
>{$_('comic.picker.face_label')}</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="group relative h-20 w-20 overflow-hidden rounded-md border-2 transition-all active:translate-y-px
{bodyInValue
? 'border-primary shadow-sm shadow-primary/20'
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100 hover:shadow-sm'}"
aria-pressed={bodyInValue}
title={bodyInValue ? $_('comic.picker.toggle_remove') : $_('comic.picker.toggle_add')}
>
<img
src={body.thumbnailUrl ?? body.publicUrl}
alt={$_('comic.picker.body_alt')}
class="h-full w-full object-cover"
/>
{#if !bodyInValue}
<div
class="absolute inset-0 flex items-center justify-center bg-background/50 text-foreground"
>
<Plus size={20} weight="bold" />
</div>
{:else}
<div
class="absolute inset-0 flex items-center justify-center bg-error/0 text-white opacity-0 transition-all group-hover:bg-error/60 group-hover:opacity-100"
>
<X size={20} weight="bold" />
</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={$_('comic.picker.body_no_in_space')}
>
<UserCircle size={18} />
<span>{$_('comic.picker.body_missing')}</span>
</div>
{/if}
<span class="text-[10px] font-medium text-muted-foreground"
>{$_('comic.picker.body_label')}</span
>
</div>
</div>
{#if !hasFace}
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html $_('comic.picker.no_face_alert_html')}
</div>
{:else if !hasBody}
<p class="text-xs text-muted-foreground">
<TShirt size={12} class="inline" />
{$_('comic.picker.body_tip')}
</p>
{/if}
</div>

View file

@ -1,233 +0,0 @@
<!--
CharacterRefPicker — Mc3-Replacement für CharacterPicker beim
Story-Create.
Two modes:
1. **character-mode** (default wenn Characters existieren):
Grid existierender Comic-Characters (filterbar nach Stil).
Pick → die pinnedVariantMediaId ist die einzige
Story-Character-Ref. "+ Neuer Character" navigiert mit
Return-URL zur Builder-Route.
2. **quick-mode** (Toggle, oder default wenn keine Characters):
Fällt zurück auf das alte Pattern: face-ref + body-ref +
optional Wardrobe-Garments. Für "mal eben schnell aus dem
Tagebuch ohne Setup".
Output ist die gleiche `mediaIds: string[]`-Form wie der alte
CharacterPicker — der Story-Store bekommt am Ende die gleiche
Struktur und runPanelGenerate kennt seinen Pfad nicht mal. Die
Story bekommt zusätzlich `characterId` (für Display + Click-
Through) wenn character-mode genutzt wurde.
-->
<script lang="ts">
import { Plus, UserCircle, Sparkle, Wrench } from '@mana/shared-icons';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { useAllCharacters } from '../queries';
import { STYLE_LABELS } from '../constants';
import { usePanelImage } from '../queries';
import CharacterPicker from './CharacterPicker.svelte';
import type { ComicCharacter } from '../types';
interface Props {
/** Selected character (in character-mode) — null in quick-mode. */
selectedCharacterId: string | null;
/** mediaIds the renderer will use as references. In character-
* mode this is `[pinnedVariantMediaId]`. In quick-mode it's
* the old face/body/garment list. */
referenceMediaIds: string[];
onChange: (next: { characterId: string | null; referenceMediaIds: string[] }) => void;
disabled?: boolean;
}
let { selectedCharacterId, referenceMediaIds, onChange, disabled = false }: Props = $props();
const characters$ = useAllCharacters();
const characters = $derived(characters$.value ?? []);
// Filter out archived/in-progress (no pinned variant) characters —
// can't render a story without a pinnedVariantMediaId.
const usableCharacters = $derived(characters.filter((c) => !c.isArchived && c.pinnedVariantId));
const face$ = useImageByPrimary('face-ref');
const hasFace = $derived(Boolean(face$.value?.mediaId));
type Mode = 'character' | 'quick';
// Default: character mode if there's at least one usable character,
// else quick mode (so first-time users aren't gated on Character-Setup).
// Init-time read of `usableCharacters` is intentional — we want to
// pick a sensible default once and let the user toggle afterwards.
// svelte-ignore state_referenced_locally
let mode = $state<Mode>(usableCharacters.length > 0 ? 'character' : 'quick');
// If user came in with no selection but there ARE characters, auto-flip
// to character-mode after first liveQuery hit.
let initialModeSet = false;
$effect(() => {
if (!initialModeSet && characters$.value !== null) {
initialModeSet = true;
if (usableCharacters.length === 0 && mode === 'character') {
mode = 'quick';
}
}
});
function pickCharacter(c: ComicCharacter) {
if (!c.pinnedVariantId) return;
onChange({
characterId: c.id,
referenceMediaIds: [c.pinnedVariantId],
});
}
function handleQuickModeChange(next: string[]) {
onChange({ characterId: null, referenceMediaIds: next });
}
</script>
<div class="space-y-3">
<!-- Mode toggle (only when both modes are available) -->
{#if usableCharacters.length > 0}
<div class="flex items-center gap-1 rounded-md border border-border bg-background p-0.5">
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium transition-colors
{mode === 'character'
? 'bg-primary/10 text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (mode = 'character')}
{disabled}
aria-pressed={mode === 'character'}
>
<Sparkle size={12} />
Character
</button>
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium transition-colors
{mode === 'quick'
? 'bg-primary/10 text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (mode = 'quick')}
{disabled}
aria-pressed={mode === 'quick'}
>
<Wrench size={12} />
Quick (Roh-Modus)
</button>
</div>
{/if}
{#if mode === 'character'}
<div class="space-y-2">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Comic-Character wählen
</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
Iterier vorher einen Character mit deinem Stil — alle Panels nutzen dann denselben
gepinnten Look.
</p>
</div>
{#if usableCharacters.length === 0}
{#if !hasFace}
<div
class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error"
role="alert"
>
<div class="flex items-start gap-2">
<UserCircle size={14} class="mt-0.5 flex-shrink-0" />
<div>
Kein Face-Ref im aktiven Space. Lade eines in
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a
>
hoch — ohne Face-Ref kann kein Character gebaut werden.
</div>
</div>
</div>
{:else}
<div
class="rounded-xl border border-dashed border-border bg-background/50 p-4 text-center text-xs text-muted-foreground"
>
<p class="mb-2 font-medium text-foreground">Noch keine Characters mit Pin.</p>
<p>
Bau einen Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren, beste
pinnen.
</p>
<a
href="/comic/character/new"
class="mt-3 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} />
Character bauen
</a>
</div>
{/if}
{:else}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{#each usableCharacters as character (character.id)}
{@const isSelected = selectedCharacterId === character.id}
{@const cover$ = usePanelImage(character.pinnedVariantId ?? null)}
{@const cover = cover$.value}
<button
type="button"
{disabled}
onclick={() => pickCharacter(character)}
class="group flex flex-col overflow-hidden rounded-lg border-2 transition-all active:translate-y-px
{isSelected
? 'border-primary shadow-md shadow-primary/20'
: 'border-border hover:border-primary/40 hover:shadow-sm'}"
aria-pressed={isSelected}
>
<div class="relative aspect-square overflow-hidden bg-muted">
{#if cover?.publicUrl}
<img
src={cover.publicUrl}
alt={character.name}
class="h-full w-full object-cover"
/>
{:else}
<div
class="flex h-full w-full items-center justify-center text-xs text-muted-foreground"
>
<Sparkle size={20} />
</div>
{/if}
<span
class="absolute bottom-1 left-1 rounded-full bg-background/90 px-1.5 py-0.5 text-[9px] font-medium text-foreground shadow-sm backdrop-blur"
>
{STYLE_LABELS[character.style].de}
</span>
</div>
<div class="px-2 py-1.5 text-left">
<p class="truncate text-xs font-medium text-foreground">{character.name}</p>
</div>
</button>
{/each}
<a
href="/comic/character/new"
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-background text-muted-foreground transition-all hover:border-primary/50 hover:bg-primary/5 hover:text-foreground hover:shadow-sm"
>
<Plus size={20} />
<span class="text-[11px] font-medium">Neuer Character</span>
</a>
</div>
{/if}
</div>
{:else}
<!-- Quick-Modus: das alte Face-/Body-/Garment-Picker-Pattern -->
<div class="space-y-2">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Quick-Modus (Roh-Refs)
</h3>
<p class="mt-0.5 text-xs text-muted-foreground">
Direkt face-ref + optional body-ref + Garments aus dem Schrank — ohne Character-Iteration.
Konsistenz zwischen Panels schwächer.
</p>
</div>
<CharacterPicker value={referenceMediaIds} onChange={handleQuickModeChange} {disabled} />
</div>
{/if}
</div>

View file

@ -1,78 +0,0 @@
<!--
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

@ -1,281 +0,0 @@
<!--
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,
DEFAULT_PANEL_MODEL,
type PanelModel,
type PanelSize,
} from '../api/generate-panel';
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
import type { ComicStory } from '../types';
import PanelModelPicker from './PanelModelPicker.svelte';
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');
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
// 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,
model,
});
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>
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={submitting} />
<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

@ -1,118 +0,0 @@
<!--
Compact segmented picker for the panel-rendering model. Three
options, identical to Wardrobe's TryOnModelPicker so muscle-memory
carries across the two image-edit flows.
Binds to a parent-owned PanelModel so callers can persist the
choice locally (per editor-mount). Story-level persistence of the
preferred model is a future concern — storing it on the row would
need a migration and isn't worth it for a three-option picker.
-->
<script lang="ts">
import type { PanelModel } from '../api/generate-panel';
interface Props {
value: PanelModel;
onChange: (next: PanelModel) => void;
disabled?: boolean;
}
let { value, onChange, disabled = false }: Props = $props();
const OPTIONS: Array<{ id: PanelModel; label: string; hint: string }> = [
{
id: 'openai/gpt-image-2',
label: 'OpenAI',
hint: 'GPT-image · Standard',
},
{
id: 'google/gemini-3-pro-image-preview',
label: 'Nano Banana Pro',
hint: 'Google · hohe Konsistenz',
},
{
id: 'google/gemini-3.1-flash-image-preview',
label: 'Nano Banana 2',
hint: 'Google · neuestes · günstig',
},
];
</script>
<fieldset class="picker" {disabled}>
<legend class="legend">Modell</legend>
<div class="options">
{#each OPTIONS as opt (opt.id)}
<button
type="button"
class="option"
class:active={value === opt.id}
aria-pressed={value === opt.id}
{disabled}
onclick={() => onChange(opt.id)}
>
<span class="label">{opt.label}</span>
<span class="hint">{opt.hint}</span>
</button>
{/each}
</div>
</fieldset>
<style>
.picker {
border: none;
padding: 0;
margin: 0;
}
.legend {
padding: 0;
margin: 0 0 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
.options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 0.375rem;
}
.option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background) / 0.5);
border-radius: 0.5rem;
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color 0.15s,
background-color 0.15s,
color 0.15s;
}
.option:hover:not([disabled]) {
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.04);
}
.option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
.option:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.hint {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -1,40 +0,0 @@
<!--
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

@ -1,236 +0,0 @@
<!--
ReferenceInputPicker — tabs over the three content-richest modules
(Journal / Notes / Library) and picks one entry as the seed for the
AI-Storyboard flow. On select, resolves the decrypted plaintext
(title + content / review) and emits it to the parent so
`suggestPanels` can post it to the server.
Writing-Drafts and Calendar-Events are intentionally left out of the
first cut — writing drafts need version-chain resolution and
calendar events rarely carry enough prose to drive a panel sequence.
Adding them is a follow-up (tabs + hook + resolver wiring).
-->
<script lang="ts">
import { useAllJournalEntries } from '$lib/modules/journal/queries';
import { useAllNotes } from '$lib/modules/notes/queries';
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
import { MagnifyingGlass, Book, NotePencil, BookOpen } from '@mana/shared-icons';
import type { StoryboardSourceModule } from '../api/storyboard';
export interface ReferenceSelection {
module: StoryboardSourceModule;
entryId: string;
/** Human-readable label — shown in the "seeded from…"-chip on the
* story detail once panels are generated. */
label: string;
/** Decrypted plaintext that gets posted to /comic/storyboard. */
sourceText: string;
}
interface Props {
onSelect: (sel: ReferenceSelection) => void;
}
let { onSelect }: Props = $props();
type Tab = 'journal' | 'notes' | 'library';
let activeTab = $state<Tab>('journal');
let search = $state('');
const journal$ = useAllJournalEntries();
const notes$ = useAllNotes();
const library$ = useAllLibraryEntries();
const journal = $derived(journal$.value ?? []);
const notes = $derived(notes$.value ?? []);
const library = $derived(library$.value ?? []);
const q = $derived(search.trim().toLowerCase());
const journalFiltered = $derived(
q.length === 0
? journal.slice(0, 30)
: journal
.filter((e) => {
const hay = `${e.title ?? ''} ${e.content}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 30)
);
const notesFiltered = $derived(
q.length === 0
? notes.slice(0, 30)
: notes.filter((n) => `${n.title} ${n.content}`.toLowerCase().includes(q)).slice(0, 30)
);
const libraryFiltered = $derived(
q.length === 0
? library.slice(0, 30)
: library
.filter((e) => {
const review = e.review ?? '';
return `${e.title} ${review}`.toLowerCase().includes(q);
})
.slice(0, 30)
);
function shortPreview(text: string, maxLen = 100): string {
const clean = text.replace(/\s+/g, ' ').trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean;
}
const TABS: { key: Tab; label: string; count: number }[] = $derived([
{ key: 'journal', label: 'Tagebuch', count: journal.length },
{ key: 'notes', label: 'Notizen', count: notes.length },
{ key: 'library', label: 'Bibliothek', count: library.length },
]);
</script>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
<header class="space-y-1">
<h3 class="text-sm font-semibold text-foreground">Quelle wählen</h3>
<p class="text-xs text-muted-foreground">
Aus welchem Text soll die KI eine Panel-Folge bauen? Alles bleibt lokal — erst der
verschlüsselte Klartext wird an das Modell gesendet, nur für diesen einen Call.
</p>
</header>
<nav class="flex gap-1 border-b border-border" aria-label="Quelle">
{#each TABS as tab (tab.key)}
<button
type="button"
class="-mb-px border-b-2 px-2.5 py-1.5 text-xs font-medium transition-colors
{activeTab === tab.key
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'}"
aria-pressed={activeTab === tab.key}
onclick={() => (activeTab = tab.key)}
>
{tab.label}
<span class="ml-1 text-[10px] text-muted-foreground">{tab.count}</span>
</button>
{/each}
</nav>
<div class="relative">
<MagnifyingGlass
size={14}
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
bind:value={search}
placeholder="Suchen…"
class="block w-full rounded-md border border-border bg-background py-1.5 pl-7 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
</div>
<div class="max-h-80 space-y-1.5 overflow-y-auto">
{#if activeTab === 'journal'}
{#if journalFiltered.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">
{journal.length === 0
? 'Noch keine Tagebuch-Einträge in diesem Space.'
: 'Keine Einträge passen zur Suche.'}
</p>
{:else}
{#each journalFiltered as entry (entry.id)}
<button
type="button"
onclick={() =>
onSelect({
module: 'journal',
entryId: entry.id,
label: entry.title?.trim() || entry.entryDate || 'Tagebuch-Eintrag',
sourceText: entry.title ? `${entry.title}\n\n${entry.content}` : entry.content,
})}
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
>
<Book size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span class="truncate">{entry.title?.trim() || entry.entryDate || 'Eintrag'}</span>
</p>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{shortPreview(entry.content)}
</p>
</div>
</button>
{/each}
{/if}
{:else if activeTab === 'notes'}
{#if notesFiltered.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">
{notes.length === 0
? 'Noch keine Notizen in diesem Space.'
: 'Keine Notizen passen zur Suche.'}
</p>
{:else}
{#each notesFiltered as note (note.id)}
<button
type="button"
onclick={() =>
onSelect({
module: 'notes',
entryId: note.id,
label: note.title.trim() || 'Notiz',
sourceText: note.title ? `${note.title}\n\n${note.content}` : note.content,
})}
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
>
<NotePencil size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-foreground">
{note.title.trim() || 'Ohne Titel'}
</p>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{shortPreview(note.content)}
</p>
</div>
</button>
{/each}
{/if}
{:else if activeTab === 'library'}
{#if libraryFiltered.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">
{library.length === 0
? 'Noch keine Bibliotheks-Einträge in diesem Space.'
: 'Keine Einträge passen zur Suche.'}
</p>
{:else}
{#each libraryFiltered as entry (entry.id)}
{@const hasReview = entry.review && entry.review.trim().length > 0}
<button
type="button"
disabled={!hasReview}
onclick={() => {
if (!hasReview || !entry.review) return;
onSelect({
module: 'library',
entryId: entry.id,
label: entry.title,
sourceText: `${entry.title}\n\n${entry.review}`,
});
}}
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-background"
title={hasReview ? '' : 'Kein Review hinterlegt — kein Text zum Rendern'}
>
<BookOpen size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span class="truncate">{entry.title}</span>
<span
class="flex-shrink-0 rounded-sm bg-muted px-1 py-0 text-[9px] uppercase text-muted-foreground"
>
{entry.kind}
</span>
</p>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{hasReview ? shortPreview(entry.review ?? '') : 'Kein Review'}
</p>
</div>
</button>
{/each}
{/if}
{/if}
</div>
</div>

View file

@ -1,68 +0,0 @@
<!--
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

@ -1,149 +0,0 @@
<!--
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 CharacterRefPicker from './CharacterRefPicker.svelte';
let title = $state('');
let style = $state<ComicStyle>('comic');
let characterId = $state<string | null>(null);
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,
characterId,
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 focus:ring-1 focus:ring-primary disabled:opacity-50"
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 (Mc3): default is character-mode if any exist,
fallback Quick-Mode für Spontan-Stories ohne Setup. -->
<CharacterRefPicker
selectedCharacterId={characterId}
referenceMediaIds={characterMediaIds}
onChange={({ characterId: nextId, referenceMediaIds: nextRefs }) => {
characterId = nextId;
characterMediaIds = nextRefs;
}}
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 resize-none 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 focus:ring-1 focus:ring-primary disabled:opacity-50"
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

@ -1,485 +0,0 @@
<!--
StoryboardSuggester — the M4 multi-step flow:
1. Pick source (ReferenceInputPicker)
2. Choose panel count + submit to /comic/storyboard
3. Review / edit the suggested Panel[] list
4. Fire parallel generation — same batch executor as M3, plus
`panelMeta[imageId].sourceInput = {module, entryId}` on each
generated panel so the "Comics zu diesem Eintrag"-back-query
(useStoriesByInput) resolves later.
Unlike BatchPanelEditor this editor doesn't let the user author
panels from scratch — they start with a Claude suggestion and tune.
That's the value-add of M4: text in → panels out, author as editor
not author-from-scratch.
-->
<script lang="ts">
import {
ArrowLeft,
CheckCircle,
Plus,
Sparkle,
SpinnerGap,
Trash,
WarningCircle,
X,
} from '@mana/shared-icons';
import {
runPanelGenerate,
DEFAULT_PANEL_MODEL,
type PanelModel,
type PanelSize,
} from '../api/generate-panel';
import {
suggestPanels,
type StoryboardSourceModule,
type StoryboardPanel,
} from '../api/storyboard';
import {
DEFAULT_STORYBOARD_PANEL_COUNT,
MAX_PANELS_PER_STORY,
MAX_STORYBOARD_PANEL_COUNT,
MIN_STORYBOARD_PANEL_COUNT,
PANEL_COUNT_WARN_THRESHOLD,
} from '../constants';
import type { ComicStory } from '../types';
import ReferenceInputPicker, { type ReferenceSelection } from './ReferenceInputPicker.svelte';
import PanelModelPicker from './PanelModelPicker.svelte';
interface Props {
story: ComicStory;
onClose: () => void;
}
let { story, onClose }: Props = $props();
type Step = 'pick-source' | 'generating-plan' | 'review-plan' | 'rendering';
let step = $state<Step>('pick-source');
let selection = $state<ReferenceSelection | null>(null);
let requestedCount = $state(DEFAULT_STORYBOARD_PANEL_COUNT);
let planError = $state<string | null>(null);
interface PlanRow extends StoryboardPanel {
id: string;
}
let rows = $state<PlanRow[]>([]);
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 };
let quality = $state<Quality>('medium');
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
// svelte-ignore state_referenced_locally
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
type RowStatus = 'idle' | 'pending' | 'ok' | 'error';
let rowStatus = $state<Record<string, { status: RowStatus; error?: string }>>({});
let renderBusy = $state(false);
const panelCount = $derived(story.panelImageIds.length);
const roomLeft = $derived(Math.max(0, MAX_PANELS_PER_STORY - panelCount));
const filledRows = $derived(rows.filter((r) => r.prompt.trim().length > 0));
const effectiveCount = $derived(Math.min(filledRows.length, roomLeft));
const warn = $derived(
panelCount + effectiveCount >= PANEL_COUNT_WARN_THRESHOLD &&
panelCount + effectiveCount <= MAX_PANELS_PER_STORY
);
const totalCost = $derived(CREDIT_COST[quality] * effectiveCount);
async function handleSelect(sel: ReferenceSelection) {
selection = sel;
step = 'generating-plan';
planError = null;
try {
const result = await suggestPanels({
style: story.style,
sourceText: sel.sourceText,
panelCount: requestedCount,
storyContext: story.storyContext,
sourceModule: sel.module as StoryboardSourceModule,
});
rows = result.panels.map((p) => ({ ...p, id: crypto.randomUUID() }));
step = 'review-plan';
} catch (err) {
planError = err instanceof Error ? err.message : 'Unbekannter Fehler';
step = 'pick-source';
}
}
function removeRow(id: string) {
if (rows.length <= 1) return;
rows = rows.filter((r) => r.id !== id);
}
function addRow() {
rows.push({ id: crypto.randomUUID(), prompt: '', caption: undefined, dialogue: undefined });
}
async function submitRow(row: PlanRow): Promise<string | null> {
if (!selection) return null;
rowStatus[row.id] = { status: 'pending' };
try {
const result = await runPanelGenerate({
story,
panelPrompt: row.prompt,
caption: row.caption?.trim() || undefined,
dialogue: row.dialogue?.trim() || undefined,
quality,
size,
model,
sourceInput: {
module: selection.module,
entryId: selection.entryId,
},
});
rowStatus[row.id] = { status: 'ok' };
return result.imageId;
} catch (err) {
rowStatus[row.id] = {
status: 'error',
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
};
return null;
}
}
async function handleRender() {
if (renderBusy || filledRows.length === 0) return;
renderBusy = true;
step = 'rendering';
rowStatus = {};
const effectiveRows = filledRows.slice(0, roomLeft);
await Promise.allSettled(effectiveRows.map((r) => submitRow(r)));
renderBusy = false;
// Close the flow once everything succeeded. If any row failed the
// user stays in the review step with per-row retry chips.
const anyError = Object.values(rowStatus).some((s) => s.status === 'error');
if (!anyError) {
onClose();
} else {
step = 'review-plan';
}
}
async function retryRow(row: PlanRow) {
if (renderBusy) return;
renderBusy = true;
await submitRow(row);
renderBusy = false;
}
function resetToSource() {
step = 'pick-source';
rows = [];
rowStatus = {};
selection = null;
planError = null;
}
</script>
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
<header class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<Sparkle size={14} class="text-primary" />
Mit KI aus Text generieren
</h3>
<p class="text-xs text-muted-foreground">
{#if step === 'pick-source'}
Schritt 1 · Quelle auswählen
{:else if step === 'generating-plan'}
Schritt 2 · Panels werden vorgeschlagen…
{:else if step === 'review-plan'}
Schritt 3 · Vorschläge prüfen und generieren
{:else}
Schritt 4 · Panels werden gerendert…
{/if}
</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="KI-Flow schließen"
>
<X size={14} />
</button>
</header>
{#if step === 'pick-source'}
<div class="space-y-3">
<div class="flex items-center gap-2">
<label
for="panel-count"
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Panel-Anzahl:
</label>
<input
id="panel-count"
type="number"
min={MIN_STORYBOARD_PANEL_COUNT}
max={MAX_STORYBOARD_PANEL_COUNT}
bind:value={requestedCount}
class="w-16 rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
/>
<span class="text-xs text-muted-foreground">
({MIN_STORYBOARD_PANEL_COUNT}{MAX_STORYBOARD_PANEL_COUNT})
</span>
</div>
{#if planError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{planError}
</div>
{/if}
<ReferenceInputPicker onSelect={handleSelect} />
</div>
{:else if step === 'generating-plan'}
<div class="flex items-center justify-center gap-3 py-8" role="status" aria-live="polite">
<SpinnerGap size={20} class="spinner text-primary" weight="bold" />
<p class="text-sm text-muted-foreground">
Das Modell denkt über deine {requestedCount} Panels nach…
</p>
</div>
{:else if step === 'review-plan' || step === 'rendering'}
<div class="space-y-3">
{#if selection}
<div
class="flex items-start justify-between gap-2 rounded-md bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
>
<div class="min-w-0">
<p class="font-medium text-foreground">
Quelle: {selection.label}
</p>
<p class="text-[11px] capitalize text-muted-foreground">{selection.module}</p>
</div>
{#if !renderBusy}
<button
type="button"
onclick={resetToSource}
class="inline-flex items-center gap-1 text-[11px] font-medium text-primary hover:underline"
>
<ArrowLeft size={10} />
Andere Quelle
</button>
{/if}
</div>
{/if}
{#if warn && !renderBusy}
<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 spürbar schwerer.
</p>
{/if}
{#if roomLeft < rows.length}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
Nur {roomLeft} Slot{roomLeft === 1 ? '' : 's'} frei in dieser Story — die letzten {rows.length -
roomLeft} Vorschläge werden nicht gerendert.
</div>
{/if}
<div class="space-y-2">
{#each rows as row, index (row.id)}
{@const status = rowStatus[row.id]}
{@const overRoom = index >= roomLeft}
<div
class="rounded-lg border border-border bg-background p-3"
class:opacity-50={overRoom}
>
<div class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] text-foreground"
>
{panelCount + index + 1}
</span>
<span>Panel {index + 1}</span>
{#if status?.status === 'pending'}
<span
class="inline-flex items-center gap-1 text-primary"
role="status"
aria-live="polite"
>
<SpinnerGap size={12} class="spinner" weight="bold" />
Wird generiert…
</span>
{:else if status?.status === 'ok'}
<span class="inline-flex items-center gap-1 text-primary">
<CheckCircle size={12} weight="fill" />
Fertig
</span>
{:else if status?.status === 'error'}
<span class="inline-flex items-center gap-1 text-error">
<WarningCircle size={12} weight="fill" />
Fehlgeschlagen
</span>
{/if}
</div>
<div class="flex items-center gap-1">
{#if status?.status === 'error'}
<button
type="button"
onclick={() => retryRow(row)}
disabled={renderBusy}
class="text-[11px] font-medium text-primary hover:underline disabled:opacity-50"
>
Neu versuchen
</button>
{/if}
{#if rows.length > 1 && !renderBusy}
<button
type="button"
onclick={() => removeRow(row.id)}
class="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-error"
aria-label="Vorschlag entfernen"
>
<Trash size={12} />
</button>
{/if}
</div>
</div>
<textarea
bind:value={row.prompt}
rows={2}
placeholder="Prompt — was passiert im Panel?"
maxlength={600}
class="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
disabled={renderBusy}
></textarea>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<input
type="text"
bind:value={row.caption}
placeholder="Caption (optional)"
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
disabled={renderBusy}
/>
<input
type="text"
bind:value={row.dialogue}
placeholder="Dialog (optional)"
maxlength={120}
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
disabled={renderBusy}
/>
</div>
{#if status?.status === 'error' && status.error}
<p class="mt-2 text-[11px] text-error" role="alert">{status.error}</p>
{/if}
</div>
{/each}
</div>
{#if !renderBusy}
<button
type="button"
onclick={addRow}
disabled={rows.length >= MAX_PANELS_PER_STORY}
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus size={12} />
Weiteres Panel manuell
</button>
{/if}
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={renderBusy} />
<div class="flex flex-wrap items-center gap-3 border-t border-border pt-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={renderBusy}
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={renderBusy}
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={renderBusy}
aria-pressed={size === '1024x1536'}
>
Hoch
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={handleRender}
disabled={renderBusy || effectiveCount === 0}
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 renderBusy}
<SpinnerGap size={14} class="spinner" weight="bold" />
{effectiveCount}
{effectiveCount === 1 ? 'Panel' : 'Panels'} werden generiert…
{:else}
<Sparkle size={14} />
{effectiveCount}
{effectiveCount === 1 ? 'Panel' : 'Panels'} generieren ({totalCost}c)
{/if}
</button>
</div>
</div>
{/if}
</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

@ -1,107 +0,0 @@
<!--
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.
Markup pattern matches `PanelModelPicker` / wardrobe's
`TryOnModelPicker` so the create-flow has a coherent "click these
cards" affordance — both border AND background shift on hover so
the tiles read clearly as buttons.
-->
<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="picker" role="radiogroup" aria-label="Comic-Stil">
{#each STYLE_ORDER as style (style)}
<button
type="button"
class="option"
class:active={value === style}
role="radio"
aria-checked={value === style}
{disabled}
onclick={() => onChange(style)}
>
<span class="label">{STYLE_LABELS[style].de}</span>
<span class="hint">{HINTS[style]}</span>
</button>
{/each}
</div>
<style>
.picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem;
}
.option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background) / 0.5);
border-radius: 0.5rem;
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color 0.15s,
background-color 0.15s,
box-shadow 0.15s,
transform 0.05s;
}
.option:hover:not([disabled]):not(.active) {
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.05);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
.option:active:not([disabled]) {
transform: translateY(1px);
}
.option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
box-shadow: 0 1px 3px hsl(var(--color-primary) / 0.15);
}
.option:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.option:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.label {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.35;
}
</style>

View file

@ -1,98 +0,0 @@
<!--
VariantTile — one variant of a Comic-Character with pin / remove
controls. Used in the character-detail's variant grid.
Two states matter: pinned (= the canonical look, gets a primary
ring and a star) vs. unpinned (regular border, hover shows action
icons). Removing a pinned variant cascades the pin to the first
remaining variant (handled in `comicCharactersStore.removeVariant`).
-->
<script lang="ts">
import { Star, Trash } from '@mana/shared-icons';
import { usePanelImage } from '../queries';
interface Props {
variantId: string;
variantIndex: number;
isPinned: boolean;
onPin: () => void;
onRemove?: () => void;
}
let { variantId, variantIndex, isPinned, onPin, onRemove }: Props = $props();
// svelte-ignore state_referenced_locally
const image$ = usePanelImage(variantId);
const image = $derived(image$.value);
</script>
<div
class="group relative aspect-square overflow-hidden rounded-lg border-2 transition-all
{isPinned ? 'border-primary shadow-md shadow-primary/20' : 'border-border hover:border-primary/40'}"
>
{#if image?.publicUrl}
<img
src={image.publicUrl}
alt="Variante {variantIndex + 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">
Variante nicht gefunden
</div>
{/if}
<!-- Variant index in corner -->
<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"
>
#{variantIndex + 1}
</span>
<!-- Pin star — always visible if pinned, otherwise on hover -->
{#if isPinned}
<span
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-background"
aria-label="Gepinned"
title="Diese Variante ist gepinned"
>
<Star size={14} weight="fill" />
</span>
{/if}
<!-- Bottom action bar — appears on hover -->
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 via-black/40 to-transparent px-2 py-1.5 opacity-0 transition-opacity group-hover:opacity-100"
>
{#if !isPinned}
<button
type="button"
onclick={onPin}
class="flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-primary/90"
title="Als kanonischen Look pinnen"
>
<Star size={10} weight="fill" />
Pinnen
</button>
{:else}
<span class="text-[11px] font-medium text-white drop-shadow">Aktiv</span>
{/if}
{#if onRemove}
<button
type="button"
onclick={onRemove}
class="flex h-7 w-7 items-center justify-center rounded-md bg-error/90 text-white shadow-sm transition-colors hover:bg-error"
aria-label="Variante entfernen"
title="Variante aus Character entfernen (Bild bleibt in Galerie)"
>
<Trash size={12} />
</button>
{/if}
</div>
</div>

View file

@ -1,38 +0,0 @@
/**
* Comic module labels, caps, defaults.
*/
import type { ComicStyle } from './types';
export const STYLE_LABELS: Record<ComicStyle, { de: string; en: string }> = {
comic: { de: 'US-Comic', en: 'US-Comic' },
manga: { de: 'Manga', en: 'Manga' },
cartoon: { de: 'Cartoon', en: 'Cartoon' },
'graphic-novel': { de: 'Graphic Novel', en: 'Graphic Novel' },
webtoon: { de: 'Webtoon', en: 'Webtoon' },
};
export const STYLE_ORDER: readonly ComicStyle[] = [
'comic',
'manga',
'cartoon',
'graphic-novel',
'webtoon',
] as const;
/**
* Hard client-side cap on panels per story. gpt-image-2 consistency
* degrades beyond ~810 panels even with identical refs; 12 is the
* "long comic" ceiling before restyling. UI warns softly 8. Plan
* offene-frage #1.
*/
export const MAX_PANELS_PER_STORY = 12;
export const PANEL_COUNT_WARN_THRESHOLD = 8;
/**
* Default panel count the AI-Storyboard flow (M4) asks Claude to
* generate when no explicit number is chosen. Slider range 28 in UI.
*/
export const DEFAULT_STORYBOARD_PANEL_COUNT = 4;
export const MIN_STORYBOARD_PANEL_COUNT = 2;
export const MAX_STORYBOARD_PANEL_COUNT = 8;

View file

@ -1,25 +0,0 @@
/**
* Comic module public surface.
*
* Plan: docs/plans/comic-module.md. M1 ships the datenschicht only
* (types, collections, queries, stores, module registration). UI +
* generate-flow follows in M2.
*/
export * from './types';
export { comicStoriesTable, comicCharactersTable } from './collections';
export { comicStoriesStore } from './stores/stories.svelte';
export { comicCharactersStore } from './stores/characters.svelte';
export {
useAllStories,
useStoriesByStyle,
useStory,
useStoryPanels,
useStoriesByInput,
usePanelImage,
useAllCharacters,
useCharactersByStyle,
useCharacter,
} from './queries';
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
export { STYLE_PREFIXES, composePanelPrompt } from './styles';

View file

@ -1,6 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const comicModuleConfig: ModuleConfig = {
appId: 'comic',
tables: [{ name: 'comicStories' }, { name: 'comicCharacters' }],
};

View file

@ -1,185 +0,0 @@
/**
* Comic module read-side queries.
*
* Stories are space-scoped: switching the active space swaps the
* visible pool automatically via `scopedForModule`. Panel history
* lives in `picture.images` filtered by `comicStoryId` kept on the
* picture side rather than here (decision #1 in the plan: one table
* in this module, panels are picture rows).
*/
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalImage, Image } from '$lib/modules/picture/types';
import { toImage } from '$lib/modules/picture/queries';
import {
toStory,
toCharacter,
type ComicStory,
type ComicStyle,
type ComicCharacter,
type LocalComicStory,
type LocalComicCharacter,
} from './types';
/** All non-archived, non-deleted stories in the active space, newest first. */
export function useAllStories() {
return useScopedLiveQuery<ComicStory[]>(async () => {
const locals = await scopedForModule<LocalComicStory, string>(
'comic',
'comicStories'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('comicStories', visible);
return decrypted.map(toStory);
}, [] as ComicStory[]);
}
/** Stories filtered by style — used by the style-tabs view in M5 list tool. */
export function useStoriesByStyle(style: ComicStyle) {
return useScopedLiveQuery<ComicStory[]>(async () => {
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
.and((row) => row.style === style)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('comicStories', visible);
return decrypted.map(toStory);
}, [] 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 useScopedLiveQuery<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 useScopedLiveQuery<ComicStory | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('comicStories', [local]);
return toStory(decrypted);
}, null);
}
/**
* Every panel rendered for a story, newest first. Pulls from
* `picture.images` filtered by `comicStoryId`. Typically the Detail-
* View uses `story.panelImageIds` directly for ordered rendering; this
* query is for gallery-style "all renders across regenerations" views
* where users want to see panels that were dropped from the story's
* ordered list but not deleted.
*/
export function useStoryPanels(storyId: string | null) {
return useScopedLiveQuery<Image[]>(async () => {
if (!storyId) return [];
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
.and((row) => row.comicStoryId === storyId)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('images', visible);
return decrypted.map(toImage);
}, [] as Image[]);
}
// ─── Characters ──────────────────────────────────────────────────
/** All non-archived, non-deleted comic-characters in the active space,
* newest first. Characters travel with their source meImages, so they're
* space-scoped. */
export function useAllCharacters() {
return useScopedLiveQuery<ComicCharacter[]>(async () => {
const locals = await scopedForModule<LocalComicCharacter, string>(
'comic',
'comicCharacters'
).toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('comicCharacters', visible);
return decrypted.map(toCharacter);
}, [] as ComicCharacter[]);
}
/** Characters filtered by style — used by style-tabs in the picker. */
export function useCharactersByStyle(style: ComicStyle) {
return useScopedLiveQuery<ComicCharacter[]>(async () => {
const locals = await scopedForModule<LocalComicCharacter, string>('comic', 'comicCharacters')
.and((row) => row.style === style)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('comicCharacters', visible);
return decrypted.map(toCharacter);
}, [] as ComicCharacter[]);
}
/** A single character by id, live-updating. Null while loading / missing. */
export function useCharacter(id: string | null) {
return useScopedLiveQuery<ComicCharacter | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalComicCharacter, string>('comic', 'comicCharacters')
.and((row) => row.id === id)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('comicCharacters', [local]);
return toCharacter(decrypted);
}, null);
}
/**
* Stories that were seeded by a given module entry (M4 AI-Storyboard
* back-reference). Matches when *any* panel in the story has a
* `panelMeta[id].sourceInput` pointing at the given {module, entryId}.
* Used for the "Comics zu diesem Journal-Eintrag" cross-reference
* widget that renders on module detail pages.
*/
export function useStoriesByInput(
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar' | null,
entryId: string | null
) {
return useScopedLiveQuery<ComicStory[]>(async () => {
if (!module || !entryId) return [];
const locals = await scopedForModule<LocalComicStory, string>(
'comic',
'comicStories'
).toArray();
const visible = locals.filter((row) => !row.deletedAt && !row.isArchived);
const decrypted = await decryptRecords('comicStories', visible);
const stories = decrypted.map(toStory);
return stories.filter((s) => {
const metas = Object.values(s.panelMeta);
return metas.some(
(meta) => meta.sourceInput?.module === module && meta.sourceInput.entryId === entryId
);
});
}, [] as ComicStory[]);
}

View file

@ -1,164 +0,0 @@
/**
* Comic-Characters store mutation-only service.
*
* A character holds an unbounded `variantMediaIds: string[]` of
* generated picture.images-rows plus a `pinnedVariantId` that
* picks one as the canonical look. Variant generation itself
* lives in `api/generate-character.ts` (Mc2.1) this store only
* mutates the row.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { comicCharactersTable } from '../collections';
import { toCharacter } from '../types';
import type { ComicCharacter, ComicStyle, LocalComicCharacter } from '../types';
export interface CreateCharacterInput {
name: string;
style: ComicStyle;
sourceFaceMediaId: string;
sourceBodyMediaId?: string | null;
description?: string | null;
addPrompt?: string | null;
tags?: string[];
}
export const comicCharactersStore = {
/**
* Create a fresh character row WITHOUT any variants yet the
* builder calls this first to obtain an id, then runs N variant
* generations and pushes each through `appendVariant`. The user
* pins one once they're happy.
*/
async createCharacter(input: CreateCharacterInput): Promise<ComicCharacter> {
const trimmedName = input.name.trim();
if (!trimmedName) {
throw new Error('Character braucht einen Namen');
}
if (!input.sourceFaceMediaId) {
throw new Error('Character braucht ein Face-Bild als Quelle');
}
// Spread incoming arrays to break any Svelte 5 $state proxies
// the form might pass through. Same defense as comicStoriesStore.
const newLocal: LocalComicCharacter = {
id: crypto.randomUUID(),
name: trimmedName,
description: input.description ?? null,
style: input.style,
addPrompt: input.addPrompt ?? null,
sourceFaceMediaId: input.sourceFaceMediaId,
sourceBodyMediaId: input.sourceBodyMediaId ?? null,
variantMediaIds: [],
pinnedVariantId: null,
tags: input.tags ? [...input.tags] : [],
isFavorite: false,
};
const snapshot = toCharacter({ ...newLocal });
await encryptRecord('comicCharacters', newLocal);
await comicCharactersTable.add(newLocal);
emitDomainEvent('ComicCharacterCreated', 'comic', 'comicCharacters', newLocal.id, {
characterId: newLocal.id,
style: input.style,
});
return snapshot;
},
/**
* Append a freshly generated variant to the character's variant
* list. Called by the builder after each gpt-image-2 / Nano Banana
* call lands a picture.images row. The first variant auto-pins
* (build-in-progress fallback) so the character has a cover even
* before the user explicitly chooses.
*/
async appendVariant(characterId: string, variantMediaId: string): Promise<void> {
const existing = await comicCharactersTable.get(characterId);
if (!existing) throw new Error(`Character ${characterId} not found`);
const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId];
const patch: Partial<LocalComicCharacter> = {
variantMediaIds: nextIds,
};
// Auto-pin the first variant so the cover isn't blank during
// build. User can re-pin afterwards.
if (!existing.pinnedVariantId) {
patch.pinnedVariantId = variantMediaId;
}
await comicCharactersTable.update(characterId, patch);
emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, {
characterId,
variantMediaId,
variantIndex: nextIds.length - 1,
});
},
/** Pin a different variant as the canonical look. Stories generated
* AFTER the re-pin get the new variant; existing stories are
* unchanged because they snapshot the mediaId at story-create. */
async pinVariant(characterId: string, variantMediaId: string): Promise<void> {
const existing = await comicCharactersTable.get(characterId);
if (!existing) throw new Error(`Character ${characterId} not found`);
if (!(existing.variantMediaIds ?? []).includes(variantMediaId)) {
throw new Error(`Variant ${variantMediaId} not in this character`);
}
await comicCharactersTable.update(characterId, {
pinnedVariantId: variantMediaId,
});
emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, {
characterId,
variantMediaId,
});
},
/** Remove a variant from the character's pool. Doesn't touch the
* underlying picture.images-row (user can keep the render in their
* Picture gallery). If the removed variant was pinned, falls back
* to the first remaining variant; if none remain, pin = null. */
async removeVariant(characterId: string, variantMediaId: string): Promise<void> {
const existing = await comicCharactersTable.get(characterId);
if (!existing) return;
const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId);
const patch: Partial<LocalComicCharacter> = {
variantMediaIds: nextIds,
};
if (existing.pinnedVariantId === variantMediaId) {
patch.pinnedVariantId = nextIds[0] ?? null;
}
await comicCharactersTable.update(characterId, patch);
},
async updateCharacter(
id: string,
patch: Partial<Pick<LocalComicCharacter, 'name' | 'description' | 'addPrompt' | 'tags'>>
): Promise<void> {
const wrapped: Partial<LocalComicCharacter> = { ...patch };
if (Array.isArray(wrapped.tags)) {
wrapped.tags = [...wrapped.tags];
}
await encryptRecord('comicCharacters', wrapped);
await comicCharactersTable.update(id, wrapped);
},
async toggleFavorite(id: string): Promise<void> {
const existing = await comicCharactersTable.get(id);
if (!existing) return;
await comicCharactersTable.update(id, {
isFavorite: !existing.isFavorite,
});
},
async archiveCharacter(id: string, archived: boolean): Promise<void> {
await comicCharactersTable.update(id, {
isArchived: archived,
});
},
async deleteCharacter(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await comicCharactersTable.update(id, {
deletedAt: nowIso,
});
emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, {
characterId: id,
});
},
};

View file

@ -1,173 +0,0 @@
/**
* Comic stories store mutation-only service.
*
* A story holds an ordered `panelImageIds: string[]` plus a
* `panelMeta` record keyed by panel id. Panel mutations (append,
* reorder, remove, updateMeta) are the M2+ shape; M1 covers the
* shell: create/update/archive/delete/setVisibility.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import {
defaultVisibilityFor,
generateUnlistedToken,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { comicStoriesTable } from '../collections';
import { toStory } from '../types';
import type { ComicPanelMeta, ComicStory, ComicStyle, LocalComicStory } from '../types';
export interface CreateStoryInput {
title: string;
style: ComicStyle;
characterMediaIds: string[];
/** When the story is bound to a comicCharacter (Character-Mode), the
* FK lands here for display + cross-ref. Quick-Mode stories pass
* `null` and only fill `characterMediaIds` with raw face/body/garments. */
characterId?: string | null;
description?: string | null;
storyContext?: string | null;
tags?: string[];
isFavorite?: boolean;
}
export const comicStoriesStore = {
async createStory(input: CreateStoryInput): Promise<ComicStory> {
if (input.characterMediaIds.length === 0) {
throw new Error('Story needs at least one character reference image');
}
// Spread incoming arrays to break Svelte 5 $state proxies — the
// caller (StoryForm) declares `characterMediaIds`/`tags` as
// `$state<string[]>([])` and passes them directly. IndexedDB's
// structured-clone refuses to clone proxies, so without this
// `comicStoriesTable.add(...)` throws DataCloneError.
const newLocal: LocalComicStory = {
id: crypto.randomUUID(),
title: input.title,
description: input.description ?? null,
style: input.style,
characterId: input.characterId ?? null,
characterMediaIds: [...input.characterMediaIds],
storyContext: input.storyContext ?? null,
panelImageIds: [],
panelMeta: {},
tags: input.tags ? [...input.tags] : [],
isFavorite: input.isFavorite ?? false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const snapshot = toStory({ ...newLocal });
await encryptRecord('comicStories', newLocal);
await comicStoriesTable.add(newLocal);
emitDomainEvent('ComicStoryCreated', 'comic', 'comicStories', newLocal.id, {
storyId: newLocal.id,
style: input.style,
});
return snapshot;
},
async updateStory(
id: string,
patch: Partial<
Pick<LocalComicStory, 'title' | 'description' | 'storyContext' | 'tags' | 'characterMediaIds'>
>
): Promise<void> {
// Same proxy-breaking copy as createStory: any array on the patch
// might be a $state proxy if the caller is a Svelte 5 component.
const wrapped: Partial<LocalComicStory> = { ...patch };
if (Array.isArray(wrapped.characterMediaIds)) {
wrapped.characterMediaIds = [...wrapped.characterMediaIds];
}
if (Array.isArray(wrapped.tags)) {
wrapped.tags = [...wrapped.tags];
}
await encryptRecord('comicStories', wrapped);
await comicStoriesTable.update(id, wrapped);
},
async toggleFavorite(id: string): Promise<void> {
const existing = await comicStoriesTable.get(id);
if (!existing) return;
await comicStoriesTable.update(id, {
isFavorite: !existing.isFavorite,
});
},
async archiveStory(id: string, archived: boolean): Promise<void> {
await comicStoriesTable.update(id, {
isArchived: archived,
});
},
async deleteStory(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await comicStoriesTable.update(id, {
deletedAt: nowIso,
});
emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, {
storyId: id,
});
},
/**
* Flip a story's visibility. Comics are a natural share-surface
* (4-panel jokes, work-anecdotes) marking a story `public` makes
* it eligible for `/embed/comic/:id` in M5.
*/
async setVisibility(id: string, next: VisibilityLevel): Promise<void> {
const existing = await comicStoriesTable.get(id);
if (!existing) throw new Error(`Comic story ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const now = new Date().toISOString();
const patch: Partial<LocalComicStory> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(),
};
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined;
}
await comicStoriesTable.update(id, patch);
emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, {
recordId: id,
collection: 'comicStories',
before,
after: next,
});
},
/**
* Append a freshly generated panel to the end of the story. Called
* by `runPanelGenerate` (M2) right after `picture.images` lands the
* new row. `meta` carries the prompt used + optional caption /
* dialogue / sourceInput.
*
* Re-encrypts the whole panelMeta Record because it's one JSON
* blob in the registry we can't partially update individual keys
* without decrypting first.
*/
async appendPanel(storyId: string, panelImageId: string, meta: ComicPanelMeta): Promise<void> {
const existing = await comicStoriesTable.get(storyId);
if (!existing) throw new Error(`Comic story ${storyId} not found`);
const nextIds = [...(existing.panelImageIds ?? []), panelImageId];
const nextMeta = { ...(existing.panelMeta ?? {}), [panelImageId]: meta };
const patch = {
panelImageIds: nextIds,
panelMeta: nextMeta,
} as Partial<LocalComicStory>;
await encryptRecord('comicStories', patch);
await comicStoriesTable.update(storyId, patch);
emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, {
storyId,
panelImageId,
panelIndex: nextIds.length - 1,
});
},
};

View file

@ -1,53 +0,0 @@
/**
* Prompt-prefix templates per visual style. The prefix is prepended to
* every panel prompt in `runPanelGenerate` (M2); gpt-image-2 sees the
* composite (stylePrefix + panelPrompt + captionHint + dialogueHint),
* never the enum itself. Keep prefixes short and directive they're
* spent on every call.
*
* Adding a style = extending `ComicStyle` in types.ts + `STYLE_LABELS`
* in constants.ts + a prefix here. The three stay in lockstep because
* Record<ComicStyle, > forces exhaustive coverage.
*/
import type { ComicStyle } from './types';
export const STYLE_PREFIXES: Record<ComicStyle, string> = {
comic:
'US comic book illustration, bold clean linework, vivid cell-shaded coloring, dramatic lighting, high contrast, comic-panel framing',
manga:
'Japanese manga illustration, black and white line art with screen tones, dynamic perspective, expressive character design, dramatic motion lines',
cartoon:
'soft pastel cartoon illustration, rounded friendly shapes, warm saturated colors, Saturday-morning animation style, simple clean backgrounds',
'graphic-novel':
'graphic novel illustration, painterly watercolor style, muted atmospheric palette, cinematic composition, moody naturalistic lighting',
webtoon:
'modern webtoon illustration, clean vertical-scroll framing, bright saturated colors, soft cel-shading, expressive character close-ups',
};
/**
* Compose the final gpt-image-2 prompt for a single panel. Caption and
* dialogue (both optional) are rendered directly into the image by
* gpt-image-2 no SVG overlay. Decision #4 in docs/plans/comic-module.md.
*
* The text-rendering language is whatever the user typed (gpt-image-2
* handles multiple languages, English is most stable but German works
* for short strings). UI surfaces an English-preferred hint.
*/
export function composePanelPrompt(input: {
style: ComicStyle;
panelPrompt: string;
caption?: string;
dialogue?: string;
}): string {
const parts: string[] = [STYLE_PREFIXES[input.style], input.panelPrompt.trim()];
const caption = input.caption?.trim();
const dialogue = input.dialogue?.trim();
if (caption) {
parts.push(`narration caption at the top reading: "${caption}"`);
}
if (dialogue) {
parts.push(`character speaking in a speech bubble saying: "${dialogue}"`);
}
return parts.join('. ');
}

View file

@ -1,642 +0,0 @@
/**
* Comic module tools AI-accessible operations over comic stories.
*
* Auto (read-only):
* - list_comic_stories
*
* Propose (human approval per the agent's policy generate burns
* credits so it's never auto):
* - create_comic_story
* - generate_comic_panel
*
* Character references (face-ref + optional body-ref) resolve
* automatically from the active space's primary meImages the AI
* caller doesn't have to know about mediaIds. That's a deliberate
* simplification versus the MCP layer (packages/mana-tool-registry/
* src/modules/comic.ts) which accepts an explicit `characterMediaIds`
* array; the webapp-runner pattern is "compose for the user, then
* propose", and forcing the planner to list mediaIds before creating
* a story was friction with no upside.
*
* Panel rendering delegates to the existing `runPanelGenerate` from
* api/generate-panel.ts, which is the same code path the DetailView's
* PanelEditor uses so the encryption + picture.images insertion +
* story appendPanel happen exactly once regardless of whether the
* user or the agent triggered it.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { meImagesTable } from '$lib/modules/profile/collections';
import { comicStoriesStore } from './stores/stories.svelte';
import { comicCharactersStore } from './stores/characters.svelte';
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
import { runCharacterGenerate } from './api/generate-character';
import { comicCharactersTable } from './collections';
import { toStory, toCharacter } from './types';
import type { ComicStyle, LocalComicStory, LocalComicCharacter } from './types';
const VALID_MODELS: readonly PanelModel[] = [
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
] as const;
function isValidModel(v: unknown): v is PanelModel {
return typeof v === 'string' && (VALID_MODELS as readonly string[]).includes(v);
}
import type { LocalMeImage } from '$lib/modules/profile/types';
import { getActiveSpace } from '$lib/data/scope';
const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'];
function isValidStyle(v: unknown): v is ComicStyle {
return typeof v === 'string' && (VALID_STYLES as string[]).includes(v);
}
/**
* Resolve the active space's primary face-ref (+ optional body-ref) to
* a mediaId array suitable for `characterMediaIds`. Non-reactive we
* scan meImagesTable directly instead of going through the svelte
* `useImageByPrimary` hook because tools run outside the Svelte
* reactivity graph.
*/
async function resolveCharacterMediaIds(): Promise<{
mediaIds: string[];
faceRef: string | null;
bodyRef: string | null;
}> {
const space = getActiveSpace();
if (!space) return { mediaIds: [], faceRef: null, bodyRef: null };
const all = await meImagesTable.toArray();
const inSpace = all.filter((m) => !m.deletedAt && m.spaceId === space.id);
const face = inSpace.find((m) => m.primaryFor === 'face-ref') ?? null;
const body = inSpace.find((m) => m.primaryFor === 'body-ref') ?? null;
const mediaIds: string[] = [];
if (face?.mediaId) mediaIds.push(face.mediaId);
if (body?.mediaId) mediaIds.push(body.mediaId);
return { mediaIds, faceRef: face?.mediaId ?? null, bodyRef: body?.mediaId ?? null };
}
export const comicTools: ModuleTool[] = [
{
name: 'list_comic_stories',
module: 'comic',
description:
'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.',
parameters: [
{
name: 'style',
type: 'string',
description: 'Nur einen Stil zeigen',
required: false,
enum: [...VALID_STYLES],
},
{
name: 'favoriteOnly',
type: 'boolean',
description: 'Nur Favoriten',
required: false,
},
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
],
async execute(params) {
const styleFilter = params.style as ComicStyle | undefined;
const favoriteOnly = params.favoriteOnly === true;
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
try {
const locals = await scopedForModule<LocalComicStory, string>(
'comic',
'comicStories'
).toArray();
const visible = locals.filter((s) => !s.deletedAt && !s.isArchived);
const decrypted = await decryptRecords('comicStories', visible);
const rows = decrypted
.map(toStory)
.filter((s) => (styleFilter ? s.style === styleFilter : true))
.filter((s) => (favoriteOnly ? s.isFavorite === true : true))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit)
.map((s) => ({
id: s.id,
title: s.title,
style: s.style,
panelCount: s.panelImageIds.length,
isFavorite: s.isFavorite === true,
description: s.description ?? null,
storyContext: s.storyContext ?? null,
}));
return {
success: true,
data: { stories: rows, total: rows.length },
message: `${rows.length} Stor${rows.length === 1 ? 'y' : 'ies'} gelistet`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist gesperrt — Comic-Stories können nicht entschlüsselt werden',
};
}
throw err;
}
},
},
{
name: 'create_comic_story',
module: 'comic',
description:
'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.',
parameters: [
{ name: 'title', type: 'string', description: 'Titel der Story', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: [...VALID_STYLES],
},
{
name: 'description',
type: 'string',
description: 'Kurze Story-Beschreibung',
required: false,
},
{
name: 'storyContext',
type: 'string',
description: 'Freitext-Briefing — Ton, Ziel, Hintergrund',
required: false,
},
{
name: 'tags',
type: 'string',
description: 'Tags durch Komma getrennt',
required: false,
},
],
async execute(params) {
const title = String(params.title ?? '').trim();
if (!title) return { success: false, message: 'title erforderlich' };
const style = params.style;
if (!isValidStyle(style)) {
return {
success: false,
message: `style muss einer von ${VALID_STYLES.join(', ')} sein`,
};
}
const refs = await resolveCharacterMediaIds();
if (refs.mediaIds.length === 0) {
return {
success: false,
message:
'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.',
};
}
const description =
typeof params.description === 'string' && params.description.trim()
? params.description.trim()
: null;
const storyContext =
typeof params.storyContext === 'string' && params.storyContext.trim()
? params.storyContext.trim()
: null;
const tags =
typeof params.tags === 'string' && params.tags.trim()
? params.tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
: [];
try {
const story = await comicStoriesStore.createStory({
title,
style,
characterMediaIds: refs.mediaIds,
description,
storyContext,
tags,
});
return {
success: true,
data: {
id: story.id,
title: story.title,
style: story.style,
characterRefCount: refs.mediaIds.length,
hasBodyRef: refs.bodyRef !== null,
},
message: `Story "${story.title}" angelegt (Stil: ${story.style})`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt — Story nicht gespeichert' };
}
throw err;
}
},
},
{
name: 'generate_comic_panel',
module: 'comic',
description:
'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.',
parameters: [
{ name: 'storyId', type: 'string', description: 'ID der Story', required: true },
{
name: 'panelPrompt',
type: 'string',
description:
'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.',
required: true,
},
{
name: 'caption',
type: 'string',
description: 'Erzaehl-Zeile ueber/unter dem Bild',
required: false,
},
{
name: 'dialogue',
type: 'string',
description: 'Sprechblasen-Text',
required: false,
},
{
name: 'quality',
type: 'string',
description: 'Render-Qualitaet — hoeher = mehr Credits',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description:
'Rendering-Backend (Default openai/gpt-image-2). Alternativen: google/gemini-3-pro-image-preview (Nano Banana Pro), google/gemini-3.1-flash-image-preview (Nano Banana 2).',
required: false,
enum: [...VALID_MODELS],
},
],
async execute(params) {
const storyId = String(params.storyId ?? '').trim();
if (!storyId) return { success: false, message: 'storyId erforderlich' };
const panelPrompt = String(params.panelPrompt ?? '').trim();
if (!panelPrompt) return { success: false, message: 'panelPrompt erforderlich' };
const caption =
typeof params.caption === 'string' && params.caption.trim()
? params.caption.trim()
: undefined;
const dialogue =
typeof params.dialogue === 'string' && params.dialogue.trim()
? params.dialogue.trim()
: undefined;
const quality =
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
const model = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
try {
// Load the story for runPanelGenerate — same code path as the
// PanelEditor in the web UI.
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
.and((s) => s.id === storyId)
.toArray();
const [local] = locals;
if (!local || local.deletedAt) {
return { success: false, message: `Story ${storyId} nicht gefunden` };
}
const [decrypted] = await decryptRecords('comicStories', [local]);
if (!decrypted) {
return { success: false, message: 'Entschlüsselung der Story fehlgeschlagen' };
}
const story = toStory(decrypted);
const result = await runPanelGenerate({
story,
panelPrompt,
caption,
dialogue,
quality: quality as 'low' | 'medium' | 'high',
model,
});
return {
success: true,
data: {
imageId: result.imageId,
imageUrl: result.imageUrl,
panelIndex: result.panelIndex,
model: result.model,
},
message: `Panel ${result.panelIndex + 1} für Story "${story.title}" generiert`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt — Panel nicht angehängt' };
}
return {
success: false,
message: err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen',
};
}
},
},
];
// Imported for side-effect types — keeps unused-import warnings quiet
// when the LocalMeImage reference in resolveCharacterMediaIds is
// compile-time only.
export type { LocalMeImage };
// ─── Character tools (Mc4) ────────────────────────────────────────
export const comicCharacterTools: ModuleTool[] = [
{
name: 'list_comic_characters',
module: 'comic',
description:
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, isFavorite). Optional nach Stil oder Favoriten filterbar.',
parameters: [
{
name: 'style',
type: 'string',
description: 'Nur einen Stil zeigen',
required: false,
enum: [...VALID_STYLES],
},
{
name: 'favoriteOnly',
type: 'boolean',
description: 'Nur Favoriten',
required: false,
},
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
],
async execute(params) {
const styleFilter = params.style as ComicStyle | undefined;
const favoriteOnly = params.favoriteOnly === true;
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
try {
const locals = await scopedForModule<LocalComicCharacter, string>(
'comic',
'comicCharacters'
).toArray();
const visible = locals.filter((c) => !c.deletedAt && !c.isArchived);
const decrypted = await decryptRecords('comicCharacters', visible);
const rows = decrypted
.map(toCharacter)
.filter((c) => (styleFilter ? c.style === styleFilter : true))
.filter((c) => (favoriteOnly ? c.isFavorite === true : true))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit)
.map((c) => ({
id: c.id,
name: c.name,
style: c.style,
variantCount: c.variantMediaIds.length,
pinnedVariantId: c.pinnedVariantId ?? null,
isFavorite: c.isFavorite === true,
}));
return {
success: true,
data: { characters: rows, total: rows.length },
message: `${rows.length} Character${rows.length === 1 ? '' : 's'} gelistet`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist gesperrt — Comic-Characters können nicht entschlüsselt werden',
};
}
throw err;
}
},
},
{
name: 'create_comic_character',
module: 'comic',
description:
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern. Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage.',
parameters: [
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: [...VALID_STYLES],
},
{
name: 'addPrompt',
type: 'string',
description: 'Zusaetzlicher Prompt',
required: false,
},
{
name: 'description',
type: 'string',
description: 'Kurze Charakter-Beschreibung',
required: false,
},
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
],
async execute(params) {
const name = String(params.name ?? '').trim();
if (!name) return { success: false, message: 'name erforderlich' };
const style = params.style;
if (!isValidStyle(style)) {
return {
success: false,
message: `style muss einer von ${VALID_STYLES.join(', ')} sein`,
};
}
const refs = await resolveCharacterMediaIds();
if (!refs.faceRef) {
return {
success: false,
message:
'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.',
};
}
const description =
typeof params.description === 'string' && params.description.trim()
? params.description.trim()
: null;
const addPrompt =
typeof params.addPrompt === 'string' && params.addPrompt.trim()
? params.addPrompt.trim()
: null;
const tags =
typeof params.tags === 'string' && params.tags.trim()
? params.tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
: [];
try {
const character = await comicCharactersStore.createCharacter({
name,
style,
sourceFaceMediaId: refs.faceRef,
sourceBodyMediaId: refs.bodyRef,
addPrompt,
description,
tags,
});
return {
success: true,
data: {
id: character.id,
name: character.name,
style: character.style,
hasBodyRef: refs.bodyRef !== null,
note: 'Character-Row angelegt — jetzt generate_character_variant aufrufen um Varianten zu rendern.',
},
message: `Character "${character.name}" angelegt (Stil: ${character.style})`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt — Character nicht gespeichert' };
}
throw err;
}
},
},
{
name: 'generate_character_variant',
module: 'comic',
description:
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character via gpt-image-2. Konsumiert Credits × count. Auto-pinnt die erste Variante wenn noch keine gepinnt ist.',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'count',
type: 'number',
description: 'Anzahl Varianten (1-4, default 4)',
required: false,
},
{
name: 'quality',
type: 'string',
description: 'Render-Qualitaet',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description: 'Rendering-Backend',
required: false,
enum: [...VALID_MODELS],
},
],
async execute(params) {
const characterId = String(params.characterId ?? '').trim();
if (!characterId) return { success: false, message: 'characterId erforderlich' };
const count = Math.max(1, Math.min(4, Number(params.count) || 4));
const quality =
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
const model: PanelModel = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
try {
const local = await comicCharactersTable.get(characterId);
if (!local || local.deletedAt) {
return { success: false, message: `Character ${characterId} nicht gefunden` };
}
const [decrypted] = await decryptRecords('comicCharacters', [local]);
if (!decrypted) {
return { success: false, message: 'Entschlüsselung des Characters fehlgeschlagen' };
}
const character = toCharacter(decrypted);
const result = await runCharacterGenerate({
character,
count,
quality: quality as 'low' | 'medium' | 'high',
model,
});
return {
success: true,
data: {
characterId: character.id,
newVariantMediaIds: result.variantMediaIds,
imageUrls: result.imageUrls,
model: result.model,
},
message: `${result.variantMediaIds.length} Variant${result.variantMediaIds.length === 1 ? '' : 's'} für "${character.name}" generiert`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt' };
}
return {
success: false,
message: err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen',
};
}
},
},
{
name: 'pin_character_variant',
module: 'comic',
description:
'Setzt einen anderen Variant als kanonischen Look. Stories danach erstellt nutzen den neuen Pin — bestehende Stories bleiben unveraendert (Snapshot-Pattern).',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'variantMediaId',
type: 'string',
description: 'ID des neuen Pin-Variants',
required: true,
},
],
async execute(params) {
const characterId = String(params.characterId ?? '').trim();
const variantMediaId = String(params.variantMediaId ?? '').trim();
if (!characterId || !variantMediaId) {
return { success: false, message: 'characterId und variantMediaId erforderlich' };
}
try {
await comicCharactersStore.pinVariant(characterId, variantMediaId);
return {
success: true,
data: { characterId, pinnedVariantId: variantMediaId },
message: `Variant gepinned`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : 'Pin fehlgeschlagen',
};
}
},
},
];
// Append character tools to the main export so init.ts picks them
// up via the existing registerTools(comicTools) call.
comicTools.push(...comicCharacterTools);

View file

@ -1,250 +0,0 @@
/**
* Comic module types one table:
*
* - `comicStories`: a comic story with title, style, fixed character
* reference list, and an ordered `panelImageIds: string[]` pointing
* at `picture.images` rows generated via the reference-edit flow.
*
* Panels themselves live in `picture.images` with `comicStoryId` +
* `comicPanelIndex` plaintext back-refs see apps/mana/apps/web/src/
* lib/modules/picture/types.ts. No second table in this module.
*
* Plan: docs/plans/comic-module.md.
*/
import type { BaseRecord } from '@mana/local-store';
import { deriveUpdatedAt } from '$lib/data/sync';
import type { VisibilityLevel } from '@mana/shared-privacy';
// ─── Style ────────────────────────────────────────────────────────
/**
* Closed enum of five visual presets. Each preset is mapped to a
* prompt-prefix template in `styles.ts`; the backend never sees the
* enum, only the final composed prompt. Chosen at story-create time
* and fixed restyling = new story (or regenerate panels one by one).
*/
export type ComicStyle =
| 'comic' // US-Comic: Linework + Cell-Shading, kräftige Farben
| 'manga' // Schwarz/weiß, Screen-Tones, dynamische Perspektiven
| 'cartoon' // Weich, pastellig, Saturday-Morning-Cartoon
| 'graphic-novel' // Realistischer, Aquarell/Painterly, stimmungsvoll
| 'webtoon'; // Vertikal-lesbar, moderne Farbpalette, Soft-Shading
// ─── Panel-Meta ───────────────────────────────────────────────────
/**
* Per-panel sidecar data that sits on the story (keyed by the panel's
* `picture.images.id`). The image itself carries only the rendered
* pixels + structural fields; everything that describes *why* the
* panel exists user caption, dialogue text, the exact prompt used,
* and optional Cross-Module source ref lives here.
*
* Whole object is encrypted as one JSON blob via the encryption
* registry (same pattern as food.foods / recipes.ingredients).
*/
export interface ComicPanelMeta {
caption?: string;
dialogue?: string;
/** The final prompt passed to gpt-image-2, stored so a user can
* regenerate or tweak without retyping. */
promptUsed?: string;
/** Which module-entry, if any, seeded this panel in the AI-Storyboard
* flow (M4). Lets `useStoriesByInput` answer "which comics did I
* make from this journal entry?". Plaintext FKs inside the
* encrypted blob. */
sourceInput?: {
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
entryId: string;
};
}
// ─── Story ────────────────────────────────────────────────────────
export interface LocalComicStory extends BaseRecord {
id: string;
title: string;
description?: string | null;
style: ComicStyle;
/**
* FK to the comicCharacter that drives this story (Character-Mode,
* Mc3+). Plaintext used for "Charakter: <Name>"-Anzeige im
* DetailView und für `useStoriesByCharacter`-Cross-Refs.
*
* `null` when the story was created in **Quick-Mode** (rohes
* face/body/garments-Setup ohne Character-Iteration). Beide Modi
* funktionieren parallel die Story hängt am `characterMediaIds`-
* Array für die eigentliche Render-Logik (Snapshot-Pattern).
*/
characterId?: string | null;
/**
* Reference-image IDs passed unchanged to every panel-generate call.
* Character-Mode: enthält genau die `pinnedVariantMediaId` des
* referenzierten comicCharacters zum Story-Create-Zeitpunkt
* (Snapshot Re-Pinning ändert das nicht rückwirkend).
* Quick-Mode: enthält face-ref + optional body-ref + Wardrobe-
* Garment-Photos.
* Capped at 8 by the backend (MAX_REFERENCE_IMAGES in the /picture/
* generate-with-reference endpoint).
*/
characterMediaIds: string[];
/**
* Free-text briefing the author writes once, surfaced in the
* AI-Storyboard flow (M4) as context Claude sees before suggesting
* panel descriptions. Typical: 13 sentences ("Ich ärgere mich über
* einen Bug in unserer Sync-Logik mach daraus einen 4-Panel-
* Frust-Comic.").
*/
storyContext?: string | null;
/**
* Ordered list of `picture.images.id` the reading order of the
* comic. Reorder = rewrite this array. Length implicitly bounded
* by `MAX_PANELS_PER_STORY` at the UI layer; the type doesn't
* enforce it.
*/
panelImageIds: string[];
/** Keyed by panel image id. Encrypted as a whole JSON blob. */
panelMeta: Record<string, ComicPanelMeta>;
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
}
export interface ComicStory {
id: string;
title: string;
description?: string;
style: ComicStyle;
/** FK to the comic-character driving this story; undefined in Quick-Mode. */
characterId?: string;
characterMediaIds: string[];
storyContext?: string;
panelImageIds: string[];
panelMeta: Record<string, ComicPanelMeta>;
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
}
export function toStory(local: LocalComicStory): ComicStory {
return {
id: local.id,
title: local.title,
description: local.description ?? undefined,
style: local.style,
characterId: local.characterId ?? undefined,
characterMediaIds: local.characterMediaIds ?? [],
storyContext: local.storyContext ?? undefined,
panelImageIds: local.panelImageIds ?? [],
panelMeta: local.panelMeta ?? {},
tags: local.tags ?? [],
isFavorite: local.isFavorite,
isArchived: local.isArchived,
visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? '',
updatedAt: deriveUpdatedAt(local),
};
}
/** Thumbnail / cover panel for a story. `null` for stories without any
* generated panel yet (they render a placeholder in StoryCard). */
export function storyCoverPanelId(story: Pick<ComicStory, 'panelImageIds'>): string | null {
return story.panelImageIds[0] ?? null;
}
// ─── Character ────────────────────────────────────────────────────
/**
* A reusable comic-style stand-in for the user. Generated once, refined
* across N variant renders (gpt-image-2 / Nano Banana edits over the
* raw face/body meImages with a style-prefix), and pinned to one
* variant that becomes the character's canonical look. Stories then
* reference the pinned variant's mediaId rather than the raw face-ref
* that's how a "Manga-Me" stays consistent across many stories.
*
* One character many variants (all kept in `variantMediaIds[]`).
* The pinned variant is the cover + the ref every story-create
* snapshots into the new story. Re-pinning later doesn't touch
* existing stories (those snapshotted at story-create time).
*
* Variants are written into `picture.images` with a `comicCharacterId`
* back-ref so the gallery can show "all renders of Manga-Me" if the
* user ever wants that view.
*/
export interface LocalComicCharacter extends BaseRecord {
id: string;
name: string;
description?: string | null;
style: ComicStyle;
/** Optional add-on prompt the user typed during character-build,
* e.g. "freundlicher Ausdruck", "casual outfit", "action pose".
* Re-used as default when the user clicks "Mehr Varianten" later. */
addPrompt?: string | null;
/** Source meImages that fed every variant generation. Pinned in the
* character so re-generating later keeps the same identity anchor. */
sourceFaceMediaId: string;
sourceBodyMediaId?: string | null;
/** All generated variant images (mana-media ids on `picture.images`).
* Newest-first by convention; a future "regenerate" appends to the
* end. Unbounded but rendered as a paginated grid in the detail view. */
variantMediaIds: string[];
/** Which variant IS the character used as the cover and as the
* ref every story-create snapshots. `null` if the user hasn't
* picked one yet (build-in-progress). */
pinnedVariantId?: string | null;
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
}
export interface ComicCharacter {
id: string;
name: string;
description?: string;
style: ComicStyle;
addPrompt?: string;
sourceFaceMediaId: string;
sourceBodyMediaId?: string;
variantMediaIds: string[];
pinnedVariantId?: string;
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
createdAt: string;
updatedAt: string;
}
export function toCharacter(local: LocalComicCharacter): ComicCharacter {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
style: local.style,
addPrompt: local.addPrompt ?? undefined,
sourceFaceMediaId: local.sourceFaceMediaId,
sourceBodyMediaId: local.sourceBodyMediaId ?? undefined,
variantMediaIds: local.variantMediaIds ?? [],
pinnedVariantId: local.pinnedVariantId ?? undefined,
tags: local.tags ?? [],
isFavorite: local.isFavorite,
isArchived: local.isArchived,
createdAt: local.createdAt ?? '',
updatedAt: deriveUpdatedAt(local),
};
}
/** Cover variant for a character pinned variant if set, otherwise
* the first variant in `variantMediaIds` (so a build-in-progress
* character still shows something). `null` if no variants generated. */
export function characterCoverVariantId(
character: Pick<ComicCharacter, 'pinnedVariantId' | 'variantMediaIds'>
): string | null {
return character.pinnedVariantId ?? character.variantMediaIds[0] ?? null;
}

View file

@ -1,60 +0,0 @@
<!--
Comic-Characters list view — grid of all characters in the active
space, with a "+ Neuer Character" CTA. The face-ref upload banner
lives one level up in the module-root ListView (above the tabs),
so we don't repeat it here per tab.
-->
<script lang="ts">
import { Plus } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useAllCharacters } from '../queries';
import CharacterCard from '../components/CharacterCard.svelte';
const characters$ = useAllCharacters();
const characters = $derived(characters$.value ?? []);
const activeSpace = $derived(getActiveSpace());
</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 Comic-Characters</h2>
<p class="text-xs text-muted-foreground">
{characters.length}
{characters.length === 1 ? 'Character' : 'Characters'} in
<strong class="text-foreground">{activeSpace?.name ?? 'diesem Space'}</strong>
</p>
</div>
<a
href="/comic/character/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} />
Neuer Character
</a>
</header>
{#if characters.length > 0}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each characters as character (character.id)}
<CharacterCard {character} />
{/each}
</div>
{:else if !characters$.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 Characters.</p>
<p class="mt-1 text-sm text-muted-foreground">
Bau deinen ersten Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren,
beste pinnen, fertig.
</p>
<a
href="/comic/character/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} />
Ersten Character bauen
</a>
</div>
{/if}
</div>

View file

@ -1,233 +0,0 @@
<!--
Comic-Character detail — Meta-Card (name + style + favorite +
archive/delete) + Variant-Grid mit Pin/Remove + "Mehr Varianten
generieren"-Button (öffnet inline den Builder im extend-mode).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
import { comicCharactersStore } from '../stores/characters.svelte';
import { useCharacter } from '../queries';
import VariantTile from '../components/VariantTile.svelte';
import CharacterBuilder from '../components/CharacterBuilder.svelte';
import { _ } from 'svelte-i18n';
interface Props {
id: string;
}
let { id }: Props = $props();
// svelte-ignore state_referenced_locally
const character$ = useCharacter(id);
const character = $derived(character$.value);
let showBuilder = $state(false);
async function handleToggleFavorite() {
if (!character) return;
await comicCharactersStore.toggleFavorite(character.id);
}
async function handleArchive() {
if (!character) return;
await comicCharactersStore.archiveCharacter(character.id, !character.isArchived);
}
async function handleDelete() {
if (!character) return;
if (
!confirm(
$_('comic.character_detail.confirm_delete_character', {
values: { name: character.name },
})
)
)
return;
await comicCharactersStore.deleteCharacter(character.id);
await goto('/comic/character');
}
async function handlePin(variantId: string) {
if (!character) return;
await comicCharactersStore.pinVariant(character.id, variantId);
}
async function handleRemove(variantId: string) {
if (!character) return;
if (!confirm($_('comic.character_detail.confirm_remove_variant'))) return;
await comicCharactersStore.removeVariant(character.id, variantId);
}
</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/character"
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label={$_('comic.character_detail.back_aria')}
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">{$_('comic.character_detail.breadcrumb')}</span>
</nav>
{#if !character}
{#if character$.loading}
<p class="text-sm text-muted-foreground">{$_('comic.character_detail.loading')}</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">
{$_('comic.character_detail.not_found')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
{$_('comic.character_detail.not_found_hint')}
</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">{character.name}</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">
{$_('comic.styles.' + character.style)}
</span>
<span>
{character.variantMediaIds.length === 1
? $_('comic.character_detail.variant_one', {
values: { n: character.variantMediaIds.length },
})
: $_('comic.character_detail.variant_other', {
values: { n: character.variantMediaIds.length },
})}
</span>
{#if !character.pinnedVariantId && character.variantMediaIds.length > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 font-medium text-amber-700"
>{$_('comic.character_detail.pin_open')}</span
>
{/if}
</div>
</div>
<button
type="button"
onclick={handleToggleFavorite}
aria-label={character.isFavorite
? $_('comic.character_detail.favorite_remove')
: $_('comic.character_detail.favorite_set')}
title={character.isFavorite
? $_('comic.character_detail.favorite_remove')
: $_('comic.character_detail.favorite_set')}
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {character.isFavorite
? 'text-rose-500 hover:bg-rose-500/10'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<Heart size={16} weight={character.isFavorite ? 'fill' : 'regular'} />
</button>
</header>
{#if character.description}
<p class="whitespace-pre-wrap text-sm text-foreground">{character.description}</p>
{/if}
{#if character.addPrompt}
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<strong class="text-foreground">{$_('comic.character_detail.prompt_add_label')}</strong>
{character.addPrompt}
</div>
{/if}
</div>
<!-- Variants -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{$_('comic.character_detail.section_variants')}
</h2>
{#if !showBuilder && !character.isArchived}
<button
type="button"
onclick={() => (showBuilder = 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} />
{$_('comic.character_detail.action_more_variants')}
</button>
{/if}
</div>
{#if character.variantMediaIds.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">
{$_('comic.character_detail.empty_variants_title')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html $_('comic.character_detail.empty_variants_hint_html')}
</p>
</div>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each character.variantMediaIds as variantId, index (variantId)}
<VariantTile
{variantId}
variantIndex={index}
isPinned={character.pinnedVariantId === variantId}
onPin={() => handlePin(variantId)}
onRemove={character.variantMediaIds.length > 1
? () => handleRemove(variantId)
: undefined}
/>
{/each}
</div>
{/if}
{#if showBuilder && !character.isArchived}
<CharacterBuilder
existing={character}
onClose={() => (showBuilder = false)}
onCreated={() => {
// Keep the builder open so the user can iterate without
// having to re-open. New variants append + appear in
// the grid above via the liveQuery.
}}
/>
{/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} />
{character.isArchived
? $_('comic.character_detail.unarchive')
: $_('comic.character_detail.archive')}
</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} />
{$_('comic.character_detail.delete')}
</button>
</div>
{#if character.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" />
{$_('comic.character_detail.archived_hint')}
</p>
{/if}
{/if}
</div>

View file

@ -1,261 +0,0 @@
<!--
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 PanelStrip from '../components/PanelStrip.svelte';
import PanelEditor from '../components/PanelEditor.svelte';
import BatchPanelEditor from '../components/BatchPanelEditor.svelte';
import StoryboardSuggester from '../components/StoryboardSuggester.svelte';
import { encryptRecord } from '$lib/data/crypto';
import { _ } from 'svelte-i18n';
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);
type EditorMode = 'off' | 'single' | 'batch' | 'ai';
let editorMode = $state<EditorMode>('off');
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($_('comic.detail.confirm_delete_story', { values: { title: story.title } })))
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($_('comic.detail.confirm_remove_panel'))) 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>;
await encryptRecord('comicStories', patch);
await comicStoriesTable.update(story.id, patch);
}
</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={$_('comic.detail.back_aria')}
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">{$_('comic.detail.breadcrumb')}</span>
</nav>
{#if !story}
{#if story$.loading}
<p class="text-sm text-muted-foreground">{$_('comic.detail.loading')}</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">{$_('comic.detail.not_found')}</p>
<p class="mt-1 text-sm text-muted-foreground">{$_('comic.detail.not_found_hint')}</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">
{$_('comic.styles.' + story.style)}
</span>
<span>
{story.panelImageIds.length === 1
? $_('comic.detail.panel_one', { values: { n: story.panelImageIds.length } })
: $_('comic.detail.panel_other', { values: { n: story.panelImageIds.length } })}
</span>
{#if story.characterMediaIds.length > 0}
<span class="text-border">·</span>
<span>
{story.characterMediaIds.length === 1
? $_('comic.detail.reference_one', {
values: { n: story.characterMediaIds.length },
})
: $_('comic.detail.reference_other', {
values: { n: story.characterMediaIds.length },
})}
</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
? $_('comic.detail.favorite_remove')
: $_('comic.detail.favorite_set')}
title={story.isFavorite
? $_('comic.detail.favorite_remove')
: $_('comic.detail.favorite_set')}
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">{$_('comic.detail.context_label')}</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">
{$_('comic.detail.section_panels')}
</h2>
{#if editorMode === 'off' && !story.isArchived}
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => (editorMode = 'single')}
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} />
{$_('comic.detail.add_panel')}
</button>
<button
type="button"
onclick={() => (editorMode = 'batch')}
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
title={$_('comic.detail.add_batch_title')}
>
<Plus size={12} />
{$_('comic.detail.add_batch')}
</button>
<button
type="button"
onclick={() => (editorMode = 'ai')}
class="inline-flex items-center gap-1.5 rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
title={$_('comic.detail.add_ai_title')}
>
<Sparkle size={12} weight="fill" />
{$_('comic.detail.add_ai')}
</button>
</div>
{/if}
</div>
<PanelStrip
panelImageIds={story.panelImageIds}
panelMeta={story.panelMeta}
onRemove={handleRemovePanel}
/>
{#if editorMode === 'single' && !story.isArchived}
<PanelEditor
{story}
onClose={() => (editorMode = 'off')}
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.
}}
/>
{:else if editorMode === 'batch' && !story.isArchived}
<BatchPanelEditor {story} onClose={() => (editorMode = 'off')} />
{:else if editorMode === 'ai' && !story.isArchived}
<StoryboardSuggester {story} onClose={() => (editorMode = 'off')} />
{/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 ? $_('comic.detail.unarchive') : $_('comic.detail.archive')}
</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} />
{$_('comic.detail.delete')}
</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" />
{$_('comic.detail.archived_hint')}
</p>
{/if}
{/if}
</div>

View file

@ -1,59 +0,0 @@
<!--
Comic stories list view — grid of stories in the active space.
The face-ref upload banner lives one level up in the module-root
ListView (above the tabs), so we don't repeat it here per tab.
-->
<script lang="ts">
import { Plus } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useAllStories } from '../queries';
import StoryCard from '../components/StoryCard.svelte';
const stories$ = useAllStories();
const stories = $derived(stories$.value ?? []);
const activeSpace = $derived(getActiveSpace());
</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 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

@ -48,9 +48,6 @@ export function toImage(local: LocalImage): Image {
sourceImageId: local.sourceImageId ?? undefined,
referenceImageIds: local.referenceImageIds ?? undefined,
generationMode: local.generationMode ?? undefined,
comicStoryId: local.comicStoryId ?? undefined,
comicPanelIndex: local.comicPanelIndex ?? undefined,
comicCharacterId: local.comicCharacterId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};

View file

@ -39,32 +39,6 @@ export interface LocalImage extends BaseRecord {
/** mana-media ids of the me-images that fed a reference-edit. */
referenceImageIds?: string[] | null;
generationMode?: ImageGenerationMode | null;
/**
* Back-reference to `comicStories.id` when this image was produced as
* a comic panel (docs/plans/comic-module.md). The canonical reading
* order lives on the story in `panelImageIds`; this field lets the
* Picture-gallery show a "Panel von Comic X" chip without having to
* load every story to check which one owns the image. Plaintext FK.
*/
comicStoryId?: string | null;
/**
* Zero-based reading position inside the owning story at write time.
* Denormalised copy of `panelImageIds.indexOf(imageId)` used for
* the gallery's "Panel 3" label. Goes stale if the story is
* reordered (M3+); the Detail-View re-reads from `panelImageIds` so
* the canonical order is never wrong even if this drifts.
*/
comicPanelIndex?: number | null;
/**
* Back-reference to `comicCharacters.id` when this image was produced
* as a character-variant render (docs/plans/comic-module.md §11).
* Lets the Picture gallery show "Variant of <Character>" without
* loading every character row, and keeps the variant identifiable
* in cross-module embeds. Plaintext FK. Mutually exclusive with
* `comicStoryId` a single image is either a panel OR a variant,
* never both.
*/
comicCharacterId?: string | null;
}
export interface LocalBoard extends BaseRecord {
@ -129,9 +103,6 @@ export interface Image {
sourceImageId?: string;
referenceImageIds?: string[];
generationMode?: ImageGenerationMode;
comicStoryId?: string;
comicPanelIndex?: number;
comicCharacterId?: string;
createdAt: string;
updatedAt: string;
}

View file

@ -29,7 +29,6 @@ import type { LocalTaskTag } from '$lib/modules/todo/types';
import type { LocalGoal } from '$lib/companion/goals/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalRecipe } from '$lib/modules/recipes/types';
import type { LocalComicStory } from '$lib/modules/comic/types';
import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types';
import type { LocalQuiz } from '$lib/modules/quiz/types';
import type { LocalSocialEvent } from '$lib/modules/events/types';
@ -70,9 +69,6 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'recipes.recipes':
items = await resolveRecipes(props);
break;
case 'comic.stories':
items = await resolveComicStories(props);
break;
case 'habits.habits':
items = await resolveHabits(props);
break;
@ -482,70 +478,6 @@ async function resolveRecipes(props: ModuleEmbedProps): Promise<EmbedItem[]> {
});
}
/**
* Comic-stories: public-comic-portfolio use case. Returns stories
* flipped to 'public' with their cover panel as the card image
* (panelImageIds[0] picture.images.publicUrl). Hard-gated on
* canEmbedOnWebsite.
*
* Whitelist (plan §2): title + "N Panels" subtitle + cover-panel URL.
* Character references, panel captions/dialogues, storyContext, and
* the full panelMeta stay out of the snapshot the cover image is
* already an AI-rendered artifact, the other fields would leak the
* author's briefing and source-entry linkage.
*/
async function resolveComicStories(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let stories = await db.table<LocalComicStory>('comicStories').toArray();
stories = stories.filter(
(s) => !s.deletedAt && !s.isArchived && canEmbedOnWebsite(s.visibility ?? 'private')
);
if (props.filter?.isFavorite === true) {
stories = stories.filter((s) => s.isFavorite === true);
}
if (props.filter?.kind) {
// `kind` reuses the generic filter slot as a style filter so the
// website editor can restrict to e.g. only manga-style comics.
stories = stories.filter((s) => s.style === props.filter?.kind);
}
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
stories = stories.filter((s) => (s.tags ?? []).some((t) => wanted.has(t)));
}
const decrypted = (await decryptRecords('comicStories', stories)) as LocalComicStory[];
// Favourites first, then newest.
decrypted.sort((a, b) => {
const favA = a.isFavorite ? 0 : 1;
const favB = b.isFavorite ? 0 : 1;
if (favA !== favB) return favA - favB;
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
});
const coverImageIds = decrypted
.map((s) => s.panelImageIds?.[0])
.filter((id): id is string => Boolean(id));
const coverImages = await db
.table<LocalImage>('images')
.where('id')
.anyOf(coverImageIds)
.toArray();
const coverById = new Map<string, LocalImage>();
for (const img of coverImages) coverById.set(img.id, img);
return decrypted.map((s) => {
const coverId = s.panelImageIds?.[0];
const cover = coverId ? coverById.get(coverId) : undefined;
const panelCount = s.panelImageIds?.length ?? 0;
return {
title: s.title,
subtitle: `${panelCount} ${panelCount === 1 ? 'Panel' : 'Panels'}`,
imageUrl: cover?.publicUrl ?? undefined,
};
});
}
/**
* Habits: build-in-public use case. Returns active habits flipped to
* 'public' with their current streak as subtitle.

View file

@ -1,12 +0,0 @@
<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

@ -1,20 +0,0 @@
<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

@ -1,14 +0,0 @@
<script lang="ts">
import { RoutePage } from '$lib/components/shell';
import CharactersView from '$lib/modules/comic/views/CharactersView.svelte';
</script>
<svelte:head>
<title>Comic-Characters · Mana</title>
</svelte:head>
<RoutePage appId="comic" backHref="/comic">
<div class="mx-auto max-w-4xl space-y-4 p-4 sm:p-6">
<CharactersView />
</div>
</RoutePage>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { RoutePage } from '$lib/components/shell';
import DetailCharacterView from '$lib/modules/comic/views/DetailCharacterView.svelte';
const id = $derived(page.params.id ?? '');
</script>
<svelte:head>
<title>Comic-Character · Mana</title>
</svelte:head>
<RoutePage appId="comic" backHref="/comic/character">
{#key id}
<DetailCharacterView {id} />
{/key}
</RoutePage>

View file

@ -1,39 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { RoutePage } from '$lib/components/shell';
import CharacterBuilder from '$lib/modules/comic/components/CharacterBuilder.svelte';
import type { ComicStyle } from '$lib/modules/comic/types';
const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'];
function isValidStyle(s: string | null): s is ComicStyle {
return s !== null && (VALID_STYLES as string[]).includes(s);
}
// Optional URL-param prefill — used by the Mc5 wardrobe-hook
// ("Als Comic-Character"-Button auf einem Outfit/Garment): we land
// here with `?prompt=wearing+the+Bühnenoutfit&style=manga`, the
// builder picks them up as initial state. Plain user creates
// (no params) are unaffected.
const initialName = $derived(page.url.searchParams.get('title') ?? undefined);
const initialAddPrompt = $derived(page.url.searchParams.get('prompt') ?? undefined);
const styleParam = $derived(page.url.searchParams.get('style'));
const initialStyle = $derived(isValidStyle(styleParam) ? styleParam : undefined);
</script>
<svelte:head>
<title>Neuer Comic-Character · Mana</title>
</svelte:head>
<RoutePage appId="comic" backHref="/comic/character">
<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-Character</h1>
<p class="text-sm text-muted-foreground">
Wähle Stil + optionalen Add-Prompt — wir rendern direkt 4 Varianten zur Auswahl. Aus dem
Detail kannst du jederzeit weitere generieren.
</p>
</header>
<CharacterBuilder {initialName} {initialAddPrompt} {initialStyle} />
</div>
</RoutePage>

View file

@ -1,20 +0,0 @@
<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>

File diff suppressed because it is too large Load diff

View file

@ -1,170 +0,0 @@
import { AI_PROPOSABLE_TOOL_NAMES } from '../../policy/proposable-tools';
import type { AgentTemplate } from './types';
import type { AiPolicy } from '../../policy/types';
/**
* Comic-Autor turns the user's text artifacts (journal entries,
* notes, library reviews) into short illustrated comics via the
* comic module's panel-generation pipeline.
*
* Why propose on every write:
* Each `comic.generatePanel` call consumes credits (325 each, 10
* for the medium default). A mis-reading of the source text or an
* over-generous panel count would burn spend fast. Propose-on-write
* forces the user to approve the Panel[] suggestion list before any
* picture.images row lands.
*
* Reads (journal/notes/library + comic.listStories) stay auto the
* agent may freely peek at existing content to pick which entry to
* illustrate or which story to append to, without nagging the user.
*
* Tools this template uses:
* - journal.list* (read) browse source entries
* - notes.list* (read) browse source notes
* - library.list* (read) browse reviews
* - comic.listStories (read) find an existing story to extend
* - comic.createStory (propose) start a new comic
* - comic.generatePanel (propose) render a single panel (credits!)
* - comic.reorderPanels (propose) rearrange existing panels
*
* The comic.* tools live in mana-tool-registry (MCP) and are NOT
* part of AI_TOOL_CATALOG they're reachable from persona-runner
* and external MCP clients (Claude Desktop). The foreground webapp
* runner will pick them up when the comic module gains its
* AI_TOOL_CATALOG entries in a later step; until then this template
* is primarily useful on the persona-runner side.
*/
// Per-tool propose policy. Start from every proposable tool in the
// AI catalog (same seed as the Recherche-Agent) so cross-module
// writes this template doesn't anticipate (`create_note` for a
// sidecar summary note, etc.) still land as proposals.
const COMIC_AUTHOR_POLICY: AiPolicy = {
tools: {
...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])),
// Web-app catalog names (snake_case). The spread above already
// covers propose-defaults; read tools (list_*) get pinned to
// auto explicitly for clarity.
list_comic_stories: 'auto',
create_comic_story: 'propose',
generate_comic_panel: 'propose',
// Character tools (Mc4) — same auto/propose split as story tools.
list_comic_characters: 'auto',
create_comic_character: 'propose',
generate_character_variant: 'propose',
pin_character_variant: 'propose',
// MCP-registry names (dot-case). The agent uses these when
// running inside persona-runner / Claude Desktop where the
// mana-tool-registry surface is what the MCP client sees.
// Listing them keeps the policy intent consistent across both
// surfaces (foreground runner + MCP).
'comic.listStories': 'auto',
'comic.createStory': 'propose',
'comic.generatePanel': 'propose',
'comic.reorderPanels': 'propose',
'comic.listCharacters': 'auto',
'comic.createCharacter': 'propose',
'comic.generateVariant': 'propose',
'comic.pinVariant': 'propose',
},
defaultsByModule: {
// Read-only companions the agent uses to find source material.
journal: 'auto',
notes: 'auto',
library: 'auto',
// Kontext + goals are referenced as standing context the
// planner already auto-injects; keep them auto so the agent can
// skim them for tonal cues (is the user in a serious phase, is
// there a goal the comic should celebrate).
kontext: 'auto',
goals: 'auto',
// Every comic write requires approval.
comic: 'propose',
},
defaultForAi: 'propose',
};
export const comicAuthorTemplate: AgentTemplate = {
id: 'comic-author',
version: '1',
icon: '🎨',
label: 'Comic-Autor',
tagline: 'Verwandelt Tagebuch-Einträge und Notizen in kurze Comics',
description: `Gib dem Agent einen Tagebuch-Eintrag, eine Notiz oder ein Review aus deiner Bibliothek — er schlägt daraus einen kurzen Comic vor:
1. Liest den gewählten Text
2. Schlägt 4 Panels vor (Prompt + Caption + Dialog pro Panel)
3. Du bestätigst die Liste, optional mit Edits
4. Jedes Panel wird via gpt-image-2 gerendert und an die Story angehängt
Jeder Generate-Schritt ist ein Vorschlag du bestimmst, wann Credits fließen. Für 4 Panels mit Default-Qualität: 4 × 10 = 40 Credits.`,
category: 'ai',
color: '#F97316',
agent: {
name: 'Comic-Autor',
avatar: '🎨',
role: 'Macht aus Text kurze Comics',
systemPrompt: `Du bist Comic-Autor. Wenn der User dir einen Moment, ein Erlebnis oder eine Idee gibt, verwandelst du das in einen kurzen Comic.
Vorgehen:
1. Lies den Ausgangstext zu Ende, bevor du mit Panels anfängst Details aus der Mitte oder dem Ende sind oft der Kern.
2. Wähle einen Stil, der zum Ton passt: 'comic' für Humor/Alltag, 'manga' für Drama, 'cartoon' für Kinder/Leichtigkeit, 'graphic-novel' für Reflexion/Melancholie, 'webtoon' für vertikale Long-Reads.
3. Wenn der User noch keinen passenden Comic-Character hat: nutze list_comic_characters um zu prüfen, dann create_comic_character (legt Row an) generate_character_variant (rendert 4 Varianten) User wählt eine pin_character_variant. Das ist EINMALIG der gepinnte Character bleibt für viele Stories der stabile Identity-Anchor.
4. Schlage 4 Panels vor (26 je nach Textlänge). Jedes Panel hat:
- prompt: was passiert bildlich (kurze englische Sätze, Komposition + Aktion + Stimmung)
- caption (optional): kurze Erzählzeile über/unter dem Bild
- dialogue (optional): was der Protagonist sagt, in Sprechblase
5. Protagonist ist IMMER der User selbst (sein gepinnter Character bzw. sein face-ref im Quick-Mode).
6. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story / aus dem Character).
Ton:
- Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt. Nicht belehrend.
- Niemals urteilen über das was der User erlebt hat.
- Deutsch als Sprache in Captions/Dialogen ist ok; englische Text-Prompts rendern aber stabiler.
Tools:
- journal.listEntries / notes.list / library.listEntries um Quelle zu finden
- list_comic_characters / list_comic_stories um Bestand zu prüfen (nicht jede Quelle braucht neuen Character oder neue Story)
- create_comic_character generate_character_variant pin_character_variant: Character-Aufbau-Pfad (einmalig pro Stil)
- create_comic_story um eine Story anzulegen (mit existierendem Character als Anchor oder im Quick-Mode mit face-ref)
- generate_comic_panel um einen Panel anzuhängen (teurer Call nur nach Bestätigung)`,
memory: `# Comic-Richtlinien
(Hier kannst du festhalten wie du Comics magst z.B. bevorzugter Stil,
Panel-Anzahl, wieviel Dialog vs. Caption, Tabu-Themen die nie vorkommen sollen.)
`,
policy: COMIC_AUTHOR_POLICY,
maxConcurrentMissions: 1,
},
scene: {
name: 'Comic-Werkstatt',
description: 'Texte lesen, Panels vorschlagen, Comic-Stories bauen',
openApps: [
{ appId: 'comic', widthPx: 540 },
{ appId: 'journal', widthPx: 440 },
{ appId: 'ai-missions', widthPx: 360 },
{ appId: 'ai-workbench', widthPx: 360 },
],
},
missions: [
{
title: 'Comic aus einem Tagebuch-Eintrag',
objective:
'Wähle einen Tagebuch-Eintrag, schlage Titel + Stil vor, und generiere eine Panel-Folge (Default 4). Jeder Generate-Schritt ist ein Vorschlag.',
conceptMarkdown: `# Comic-Auftrag
Ersetze diesen Block mit:
- **Eintrag:** _Link auf den Tagebuch-Eintrag (oder Zitat daraus)_
- **Stil:** _comic / manga / cartoon / graphic-novel / webtoon oder leer lassen, damit der Agent vorschlägt_
- **Panels:** _2-8, default 4_
- **Ton:** _frei "leicht und selbstironisch" / "ernst und reflektiert" / "melancholisch"_
Der Agent liest den Eintrag, legt eine neue Comic-Story an (als Vorschlag),
schlägt die Panel-Folge vor, und rendert die Panels einzeln nach deiner
Bestätigung.`,
cadence: { kind: 'manual' },
startPaused: true,
},
],
};

View file

@ -14,7 +14,6 @@ import { calmnessTemplate } from './calmness';
import { fitnessTemplate } from './fitness';
import { deepWorkTemplate } from './deep-work';
import { eventScoutTemplate } from './event-scout';
import { comicAuthorTemplate } from './comic-author';
export type {
// Generalised names (T1 of workbench-templates plan):
@ -41,7 +40,6 @@ export const ALL_TEMPLATES = [
fitnessTemplate,
deepWorkTemplate,
eventScoutTemplate,
comicAuthorTemplate,
] as const;
export {
@ -52,7 +50,6 @@ export {
fitnessTemplate,
deepWorkTemplate,
eventScoutTemplate,
comicAuthorTemplate,
};
/** Lookup helper returns the template matching the given id, or

View file

@ -1835,223 +1835,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }],
},
// ── Comic ───────────────────────────────────────────────
{
name: 'list_comic_stories',
module: 'comic',
description:
'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.',
defaultPolicy: 'auto',
parameters: [
{
name: 'style',
type: 'string',
description: 'Nur einen Stil zeigen',
required: false,
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
},
{
name: 'favoriteOnly',
type: 'boolean',
description: 'Nur Favoriten',
required: false,
},
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
],
},
{
name: 'create_comic_story',
module: 'comic',
description:
'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.',
defaultPolicy: 'propose',
parameters: [
{ name: 'title', type: 'string', description: 'Titel der Story', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
},
{
name: 'description',
type: 'string',
description: 'Kurze Story-Beschreibung',
required: false,
},
{
name: 'storyContext',
type: 'string',
description:
'Freitext-Briefing — Ton, Ziel, Hintergrund. Wird im AI-Storyboard-Flow als Briefing genutzt.',
required: false,
},
{
name: 'tags',
type: 'string',
description: 'Tags durch Komma getrennt',
required: false,
},
],
},
{
name: 'generate_comic_panel',
module: 'comic',
description:
'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.',
defaultPolicy: 'propose',
parameters: [
{ name: 'storyId', type: 'string', description: 'ID der Story', required: true },
{
name: 'panelPrompt',
type: 'string',
description:
'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.',
required: true,
},
{
name: 'caption',
type: 'string',
description: 'Erzaehl-Zeile ueber/unter dem Bild (optional)',
required: false,
},
{
name: 'dialogue',
type: 'string',
description: 'Sprechblasen-Text (optional)',
required: false,
},
{
name: 'quality',
type: 'string',
description: 'Render-Qualitaet — hoeher = mehr Credits',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description:
'Rendering-Backend. openai/gpt-image-2 ist Standard. google/gemini-3-pro-image-preview = Nano Banana Pro (hoehere Charakter-Konsistenz, teurer). google/gemini-3.1-flash-image-preview = Nano Banana 2 (neuestes, schnell, guenstig).',
required: false,
enum: [
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
],
},
],
},
{
name: 'list_comic_characters',
module: 'comic',
description:
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, isFavorite). Optional nach Stil oder Favoriten filterbar.',
defaultPolicy: 'auto',
parameters: [
{
name: 'style',
type: 'string',
description: 'Nur einen Stil zeigen',
required: false,
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
},
{
name: 'favoriteOnly',
type: 'boolean',
description: 'Nur Favoriten',
required: false,
},
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
],
},
{
name: 'create_comic_character',
module: 'comic',
description:
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern (Splittet Anlegen von Generierung — User reviewt erst). Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage. Gibt characterId zurueck — danach generate_character_variant aufrufen.',
defaultPolicy: 'propose',
parameters: [
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
},
{
name: 'addPrompt',
type: 'string',
description: 'Zusaetzlicher Prompt (z.B. "freundlicher Ausdruck", "casual outfit")',
required: false,
},
{
name: 'description',
type: 'string',
description: 'Kurze Charakter-Beschreibung',
required: false,
},
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
],
},
{
name: 'generate_character_variant',
module: 'comic',
description:
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character und appended sie an den Variant-Pool. Konsumiert Credits × count (medium=10c). Auto-pinnt die erste Variante wenn noch keine gepinnt ist. Stil + Source-Refs kommen aus dem Character — nur count + quality + model sind hier waehlbar.',
defaultPolicy: 'propose',
parameters: [
{
name: 'characterId',
type: 'string',
description: 'ID des Characters',
required: true,
},
{
name: 'count',
type: 'number',
description: 'Anzahl Varianten (1-4, default 4)',
required: false,
},
{
name: 'quality',
type: 'string',
description: 'Render-Qualitaet — hoeher = mehr Credits',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description: 'Rendering-Backend (default openai/gpt-image-2).',
required: false,
enum: [
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
],
},
],
},
{
name: 'pin_character_variant',
module: 'comic',
description:
'Setzt einen anderen Variant als kanonischen Look des Comic-Characters. Stories die DANACH erstellt werden nutzen den neuen Pin; bestehende Stories bleiben unveraendert (sie haben den alten Variant zum Story-Create-Zeitpunkt fix gespeichert).',
defaultPolicy: 'propose',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'variantMediaId',
type: 'string',
description: 'ID der Variante die zum neuen Pin werden soll (muss in variantMediaIds sein)',
required: true,
},
],
},
// ── Augur (signs / fortunes / hunches) ──────────────────────
{
name: 'capture_sign',

View file

@ -63,10 +63,6 @@ const timesSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="
// Calc icon (calculator with pink gradient)
const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
// Comic icon — speech bubble with a lightning-bolt panel marker on
// orange→red gradient. Warm creative-family tone for the Mana launcher.
const comicSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#comicGrad)"/><path d="M260 340c0-33 27-60 60-60h384c33 0 60 27 60 60v288c0 33-27 60-60 60H480l-108 90v-90h-52c-33 0-60-27-60-60V340z" fill="white"/><path d="M540 370l-90 156h72l-30 128 108-172h-78l28-112h-10z" fill="#ea580c"/><circle cx="360" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><circle cx="410" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><circle cx="460" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><defs><linearGradient id="comicGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f97316"/><stop offset="1" stop-color="#dc2626"/></linearGradient></defs></svg>`;
// Augur icon — open eye with a small star in the iris and three drifting
// dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic
// family next to Dreams (indigo) and Cards (violet) so the launcher reads
@ -99,7 +95,6 @@ export const APP_ICONS = {
todo: svgToDataUrl(todoSvg),
mail: svgToDataUrl(mailSvg),
inventory: svgToDataUrl(inventorySvg),
comic: svgToDataUrl(comicSvg),
augur: svgToDataUrl(augurSvg),
questions: svgToDataUrl(questionsSvg),
times: svgToDataUrl(timesSvg),

View file

@ -345,23 +345,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'comic',
name: 'Comic',
description: {
de: 'Aus Text wird ein Comic',
en: 'Turn text into comics',
},
longDescription: {
de: 'Erstelle mehrseitige Comics mit KI. Starte mit einem Tagebuch-Eintrag, einer Notiz oder einem Kalender-Event und generiere Panels in fünf Stilen — Comic, Manga, Cartoon, Graphic Novel oder Webtoon. Du selbst bist der Protagonist.',
en: 'Create multi-panel comics with AI. Start from a journal entry, note, or calendar event and generate panels in five styles — comic, manga, cartoon, graphic novel, or webtoon. You are the protagonist.',
},
icon: APP_ICONS.comic,
color: '#f97316',
comingSoon: false,
status: 'beta',
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
},
{
id: 'questions',
name: 'Questions',

View file

@ -24,7 +24,6 @@
<option value="places.places">Orte</option>
<option value="recipes.recipes">Rezepte</option>
<option value="wardrobe.outfits">Wardrobe (Outfits)</option>
<option value="comic.stories">Comics</option>
<option value="habits.habits">Habits</option>
<option value="quiz.quizzes">Quizze</option>
<option value="events.socialEvents">Events (RSVP)</option>

View file

@ -34,7 +34,6 @@ export const EmbedSourceSchema = z.enum([
'goals.goals',
'places.places',
'recipes.recipes',
'comic.stories',
'habits.habits',
'quiz.quizzes',
'events.socialEvents',