feat(comic): Mc2 — Character-Builder UI + Variant-Grid + Routes

Datenschicht aus Mc1 wird jetzt durch UI benutzbar. End-to-end-Flow:
Tab-Switch zu Characters → "+ Neuer Character" → Stil + Add-Prompt
+ Source-Confirm (face Pflicht, body Toggle) → 4 Varianten parallel
gerendert → User pinnt eine als Identity → Character ist fertig,
nutzbar als Story-Anchor (Mc3 wired das in den StoryForm-Flow).

UI-Komponenten:
- `api/generate-character.ts`: runCharacterGenerate({character, n=4,
  quality, model}) ruft /picture/generate-with-reference mit
  [face, body?]-Refs + Stil-Prefix + Add-Prompt + Identity-Anchor-
  Hint, schreibt N picture.images mit comicCharacterId-Back-Ref,
  appended an den Character via comicCharactersStore.appendVariant
  (auto-pin auf erste Variant). Ein Server-Call mit n=4 statt 4
  parallele — gpt-image-2 Multi-Image-Response in einem Batch.
- `components/CharacterCard.svelte`: Grid-Tile mit Cover (pinned
  Variant > erste Variant > Placeholder), Style-Badge, Favorit-
  Heart, Amber "Pin offen"-Badge wenn Varianten existieren aber
  keine gepinned ist.
- `components/VariantTile.svelte`: einzelne Variant im Grid mit
  Pin-Star wenn aktiv, Bottom-Action-Bar auf Hover (Pinnen / Entf.).
  Pinned hat primary-Border + Schatten, Unpinned dezent.
- `components/CharacterBuilder.svelte`: Zwei Modi via `existing`-
  Prop. Create-Modus: Name + StylePicker + AddPrompt + Source-
  Preview (face Pflicht, Body-Toggle). Extend-Modus: Style + Source
  fix vom existierenden Character, nur AddPrompt editierbar pro
  Generierung. Beide feuern die gleiche runCharacterGenerate-Pipeline.
- `views/CharactersView.svelte`: Grid + "+ Neuer Character"-CTA +
  Face-Ref-Empty-State + leeres Empty-Board. Gleicher Aufbau wie
  StoriesView für visuelle Konsistenz.
- `views/DetailCharacterView.svelte`: Meta-Card (Titel + Style-
  Badge + Variant-Count + Pin-offen-Hinweis), Variant-Grid mit
  Pin/Remove, "+ Mehr Varianten"-Button öffnet Builder im
  extend-Modus inline (Builder bleibt offen für Iterations-Flow).
  Plus Archive/Delete.
- `ListView.svelte` (Modul-Root) bekommt 2-Tab-UI:
  **Stories | Characters** mit Count-Badge auf dem Characters-Tab.
  Standardpattern wie Wardrobe's Garments|Outfits.

Routes:
- `/comic/character` (Liste, eigenständige Route — Back-Nav aus
  Detail/New zeigt darauf)
- `/comic/character/new` (CharacterBuilder im Create-Modus)
- `/comic/character/[id]` (DetailCharacterView mit {#key id}
  Re-Mount wie Story-Detail).

check passes 0/0 für comic-files.

Mc3 (Story-Create wechselt auf den neuen Picker, Soft-Migration
für bestehende Stories) folgt im nächsten Commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 16:42:31 +02:00
parent 2b359f9e1a
commit 882aa60976
10 changed files with 1139 additions and 6 deletions

View file

@ -1,15 +1,53 @@
<!--
Comic module root — thin wrapper around the story grid. Wardrobe-
style face-banner is kept in ListView.svelte (the list) rather
than here, because creating a story already has its own face-ref
check in CharacterPicker. Module root is as small as possible.
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).
-->
<script lang="ts">
import ListView from './views/ListView.svelte';
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 },
]);
</script>
<div class="comic-root">
<ListView />
<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>
<div class="comic-body">
{#if activeTab === 'stories'}
<StoriesView />
{:else}
<CharactersView />
{/if}
</div>
</div>
<style>
@ -21,6 +59,56 @@
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;
}
@container (min-width: 640px) {
.comic-root {
padding: 0.75rem 1rem 1rem;

View file

@ -0,0 +1,209 @@
/**
* 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,
updatedAt: 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

@ -0,0 +1,317 @@
<!--
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;
/** 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, 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. Init-time read of `existing` 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 ?? '');
// svelte-ignore state_referenced_locally
let style = $state<ComicStyle>(existing?.style ?? 'comic');
// svelte-ignore state_referenced_locally
let addPrompt = $state(existing?.addPrompt ?? '');
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

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

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

@ -0,0 +1,77 @@
<!--
Comic-Characters list view — grid of all characters in the active
space, with a "+ Neuer Character" CTA. Mirrors the StoryView layout
for visual consistency between the two tabs.
-->
<script lang="ts">
import { Plus, UserCircle } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { useAllCharacters } from '../queries';
import CharacterCard from '../components/CharacterCard.svelte';
const characters$ = useAllCharacters();
const characters = $derived(characters$.value ?? []);
const activeSpace = $derived(getActiveSpace());
const face$ = useImageByPrimary('face-ref');
const hasFace = $derived(Boolean(face$.value?.mediaId));
</script>
<div class="space-y-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-foreground">Deine 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 !hasFace && !face$.loading}
<div class="rounded-xl border border-dashed border-border bg-background/50 p-4">
<div class="flex items-start gap-3 text-sm">
<UserCircle size={18} class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="font-medium text-foreground">Lade erst dein Gesichtsbild hoch</p>
<p class="text-xs text-muted-foreground">
Charakter-Generierung braucht ein Face-Bild als Source. Hochladen in
<a href="/profile/me-images" class="text-primary hover:underline">Profil → Bilder</a>.
</p>
</div>
</div>
</div>
{/if}
{#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

@ -0,0 +1,214 @@
<!--
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 { STYLE_LABELS } from '../constants';
import VariantTile from '../components/VariantTile.svelte';
import CharacterBuilder from '../components/CharacterBuilder.svelte';
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(`Character "${character.name}" wirklich löschen?`)) 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(
'Variante aus dem Character entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden.'
)
)
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="Zurück zu Characters"
>
<ArrowLeft size={16} />
</a>
<span class="text-muted-foreground">Comic · Characters</span>
</nav>
{#if !character}
{#if character$.loading}
<p class="text-sm text-muted-foreground">Lädt…</p>
{:else}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<p class="text-sm font-medium text-foreground">Character nicht gefunden.</p>
<p class="mt-1 text-sm text-muted-foreground">Gelöscht oder in einem anderen Space.</p>
</div>
{/if}
{:else}
<!-- Meta -->
<div class="space-y-3 rounded-2xl border border-border bg-card p-5">
<header class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-foreground">{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">
{STYLE_LABELS[character.style].de}
</span>
<span>
{character.variantMediaIds.length}
{character.variantMediaIds.length === 1 ? 'Variante' : 'Varianten'}
</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"
>Pin offen</span
>
{/if}
</div>
</div>
<button
type="button"
onclick={handleToggleFavorite}
aria-label={character.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
title={character.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
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">Prompt-Add:</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">
Varianten
</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} />
Mehr Varianten
</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">Noch keine Varianten.</p>
<p class="mt-1 text-sm text-muted-foreground">
Klick oben rechts auf <strong class="text-foreground">+ Mehr Varianten</strong>, um die
ersten 4 zu generieren.
</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 ? 'Wieder aktiv' : 'Archivieren'}
</button>
<button
type="button"
onclick={handleDelete}
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
>
<Trash size={14} />
Löschen
</button>
</div>
{#if 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" /> Archivierter Character — keine Variant-Generierung möglich,
bis wieder aktiviert.
</p>
{/if}
{/if}
</div>

View file

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

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

@ -0,0 +1,21 @@
<script lang="ts">
import { RoutePage } from '$lib/components/shell';
import CharacterBuilder from '$lib/modules/comic/components/CharacterBuilder.svelte';
</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 />
</div>
</RoutePage>