mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
2b359f9e1a
commit
882aa60976
10 changed files with 1139 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue