mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(comic): Mc3 — Story-Create nutzt Character-Mode + Quick-Fallback
Story-Anlegen ist jetzt zweigleisig: Default ist Character-Mode (picke einen iterierten Comic-Character mit gepinntem Look), Fallback ist Quick-Mode (rohes face/body/garments wie bisher) als opt-in-Toggle für Spontan-Stories ohne Setup. Datenmodell-Erweiterung (soft, kein Breaking-Change): - LocalComicStory + ComicStory bekommen ein optionales `characterId?: string | null` Feld, plaintext, FK auf comicCharacters.id. Im Quick-Modus null, im Character-Modus die gewählte Character-id. - `characterMediaIds` bleibt das einzige Feld, das runPanelGenerate liest — im Character-Modus enthält es genau die `pinnedVariantMediaId` als single-element-Array (Snapshot zum Story-Create-Zeitpunkt). Re-Pinning eines Characters ändert bestehende Stories also NICHT, weil sie das mediaId fix gespeichert haben. Im Quick-Modus enthält's face + body? + garments[] wie vorher. Beide Modi gehen durch denselben /picture/generate-with-reference-Pfad. - Soft-Migration: bestehende Stories ohne `characterId` zeigen weiterhin keine Character-Linkage und rendern wie vorher (die `characterMediaIds` waren vorher ja schon die Quelle). Neue Komponente: - `CharacterRefPicker.svelte` ersetzt den alten `CharacterPicker` in StoryForm. Mode-Toggle (Character | Quick) erscheint nur wenn Characters existieren — sonst startet's direkt im Quick-Modus. Character-Mode zeigt Grid der usableCharacters (nicht-archived + pinnedVariantId gesetzt) mit Cover, Style-Badge, Active-Border. "+ Neuer Character"-Tile öffnet die Builder-Route. Quick-Modus rendert intern den alten CharacterPicker (face/body/garments) — reuse statt parallel zu pflegen. StoryForm: - 2 neue $state-Felder: `characterId` und (umbenannt-) der bestehende `characterMediaIds`. CharacterRefPicker emittiert beide via onChange-Callback. - createStory bekommt `characterId` mit, das landet auf der Story- Row. canSubmit greift weiterhin auf `characterMediaIds.length > 0` — beide Modi liefern mindestens 1 ref. CharacterBuilder Bugfix: prettier hatte den Add-Prompt-Placeholder mit nested double-quotes zerstört (z.B. "freundlicher Ausdruck" wurde zu invalidem HTML). Auf einfache Liste umgestellt. 8/8 Encryption-Tests weiter grün. check für comic-files clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
882aa60976
commit
0ff5030ad2
5 changed files with 270 additions and 8 deletions
|
|
@ -194,7 +194,7 @@
|
|||
id="character-add-prompt"
|
||||
type="text"
|
||||
bind:value={addPrompt}
|
||||
placeholder="z.B. "freundlicher Ausdruck", "casual outfit", "action pose""
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
<!--
|
||||
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>
|
||||
|
|
@ -15,10 +15,11 @@
|
|||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import type { ComicStyle } from '../types';
|
||||
import StylePicker from './StylePicker.svelte';
|
||||
import CharacterPicker from './CharacterPicker.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);
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
const story = await comicStoriesStore.createStory({
|
||||
title: title.trim(),
|
||||
style,
|
||||
characterId,
|
||||
characterMediaIds,
|
||||
storyContext: storyContext.trim() || null,
|
||||
});
|
||||
|
|
@ -81,10 +83,15 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Character refs -->
|
||||
<CharacterPicker
|
||||
value={characterMediaIds}
|
||||
onChange={(next) => (characterMediaIds = next)}
|
||||
<!-- 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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ 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[];
|
||||
|
|
@ -45,6 +49,7 @@ export const comicStoriesStore = {
|
|||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
style: input.style,
|
||||
characterId: input.characterId ?? null,
|
||||
characterMediaIds: [...input.characterMediaIds],
|
||||
storyContext: input.storyContext ?? null,
|
||||
panelImageIds: [],
|
||||
|
|
|
|||
|
|
@ -65,10 +65,24 @@ export interface LocalComicStory extends BaseRecord {
|
|||
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.
|
||||
* Minimum: the primary face-ref from meImages. Optional additions:
|
||||
* body-ref + up to ~3 wardrobe-garment photos for a costume-setup.
|
||||
* 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).
|
||||
*/
|
||||
|
|
@ -104,6 +118,8 @@ export interface ComicStory {
|
|||
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[];
|
||||
|
|
@ -122,6 +138,7 @@ export function toStory(local: LocalComicStory): ComicStory {
|
|||
title: local.title,
|
||||
description: local.description ?? undefined,
|
||||
style: local.style,
|
||||
characterId: local.characterId ?? undefined,
|
||||
characterMediaIds: local.characterMediaIds ?? [],
|
||||
storyContext: local.storyContext ?? undefined,
|
||||
panelImageIds: local.panelImageIds ?? [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue