mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(comic): M2 — UI + Single-Panel-Generierung
Die Datenschicht aus M1 wird jetzt durch UI + gpt-image-2-Flow
benutzbar. Nutzer legt eine Story an (Titel, Stil, Protagonist) und
generiert Panels einzeln über PanelEditor — jeder Panel-Call nutzt
die story-weite Referenz-Liste (face + optional body + optional
Kostüme) plus den stil-spezifischen Prompt-Prefix aus styles.ts.
- `api/generate-panel.ts` → `runPanelGenerate()` wrappt
`/picture/generate-with-reference` analog zu wardrobe/try-on,
schreibt picture.images mit `comicStoryId` + `comicPanelIndex`
Back-Refs und appendet via `comicStoriesStore.appendPanel`. Größe
defaultet auf 1024×1024 (Quadrat) bzw. 1024×1536 für Webtoon.
- Form-Komponenten: `StylePicker` (5 Presets als Radio-Tiles),
`CharacterPicker` (face-ref Pflicht, body-ref + bis 3
Wardrobe-Kostüme optional), `StoryForm` (Titel + Stil + Picker +
optionaler Kontext).
- Panel-Komponenten: `PanelCard` (Bild + Caption/Dialog-Sidecar),
`PanelStrip` (responsives Grid 2-4 Spalten), `PanelEditor`
(inline-Sheet mit Prompt + Caption + Dialog + Quality/Format +
Generate-Button; zeigt Credits vorher, warnt ab 8 Panels, cappt
bei 12).
- `StoryCard` rendert Cover aus `panelImageIds[0]` via neuer
`usePanelImage`-Query, mit Style-Badge und Favorit-Heart.
- `ListView`: Grid + "+ Neue Story"-CTA, Face-Ref-Hinweis wenn
fehlt, leeres Empty-State-Board.
- `DetailView`: Meta-Card mit VisibilityPicker + Favorit +
Archive/Delete, PanelStrip, "+ Panel"-CTA öffnet PanelEditor
inline. Panel-Remove entfernt aus panelImageIds + panelMeta, die
picture.images-Row bleibt (Final-Delete im Picture-Modul).
- Routes: `/comic` (ListView), `/comic/new` (StoryForm) und
`/comic/[id]` (DetailView mit {#key id} Re-Mount wie wardrobe).
- i18n: comic-Label in de.json + en.json für RoutePage-Header.
- queries: `usePanelImage(id)` Helper für Cover + Panel-Rendering
(comic-intern, nicht ins Picture-Modul eingemischt).
Sprechblasen/Captions werden gpt-image-2 per Prompt übergeben und
direkt ins Bild gerendert — kein SVG-Overlay. Englische Texte
rendern stabiler (UI-Hinweis).
Testet per `pnpm run check` + `validate:all` sauber, 5 Encryption-
Tests weiterhin grün.
Kein Batch-Mode (M3), kein AI-Storyboard (M4), keine MCP-Tools (M5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
19e0f33665
commit
3551652612
18 changed files with 1638 additions and 2 deletions
|
|
@ -34,5 +34,47 @@
|
|||
"who": "Who",
|
||||
"events": "Events",
|
||||
"automations": "Automationen",
|
||||
"playground": "Playground"
|
||||
"playground": "Playground",
|
||||
"kontext": "Web-Kontext",
|
||||
"news": "News",
|
||||
"news-research": "News-Recherche",
|
||||
"articles": "Artikel",
|
||||
"research-lab": "Recherche-Labor",
|
||||
"drink": "Trinken",
|
||||
"recipes": "Rezepte",
|
||||
"stretch": "Dehnen",
|
||||
"mail": "E-Mail",
|
||||
"meditate": "Meditation",
|
||||
"mood": "Stimmung",
|
||||
"sleep": "Schlaf",
|
||||
"myday": "Mein Tag",
|
||||
"activity": "Aktivität",
|
||||
"companion": "Begleiter",
|
||||
"ai-missions": "KI-Missionen",
|
||||
"ai-agents": "KI-Agenten",
|
||||
"ai-workbench": "KI-Werkbank",
|
||||
"rituals": "Rituale",
|
||||
"ai-policy": "KI-Richtlinien",
|
||||
"ai-insights": "KI-Einblicke",
|
||||
"ai-health": "KI-Gesundheit",
|
||||
"goals": "Ziele",
|
||||
"credits": "Credits & Abo",
|
||||
"spiral": "Mana Spirale",
|
||||
"settings": "Einstellungen",
|
||||
"themes": "Designs",
|
||||
"profile": "Profil",
|
||||
"admin": "Admin",
|
||||
"complexity": "Komplexität",
|
||||
"api-keys": "API-Schlüssel",
|
||||
"wishes": "Wünsche",
|
||||
"help": "Hilfe",
|
||||
"wetter": "Wetter",
|
||||
"feedback": "Feedback",
|
||||
"wardrobe": "Kleiderschrank",
|
||||
"library": "Bibliothek",
|
||||
"spaces": "Bereiche",
|
||||
"website": "Website",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Anleitungen",
|
||||
"comic": "Comic"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,5 +34,47 @@
|
|||
"who": "Who",
|
||||
"events": "Events",
|
||||
"automations": "Automations",
|
||||
"playground": "Playground"
|
||||
"playground": "Playground",
|
||||
"kontext": "Web Context",
|
||||
"news": "News",
|
||||
"news-research": "News Research",
|
||||
"articles": "Articles",
|
||||
"research-lab": "Research Lab",
|
||||
"drink": "Drinks",
|
||||
"recipes": "Recipes",
|
||||
"stretch": "Stretch",
|
||||
"mail": "Mail",
|
||||
"meditate": "Meditate",
|
||||
"mood": "Mood",
|
||||
"sleep": "Sleep",
|
||||
"myday": "My Day",
|
||||
"activity": "Activity",
|
||||
"companion": "Companion",
|
||||
"ai-missions": "AI Missions",
|
||||
"ai-agents": "AI Agents",
|
||||
"ai-workbench": "AI Workbench",
|
||||
"rituals": "Rituals",
|
||||
"ai-policy": "AI Policy",
|
||||
"ai-insights": "AI Insights",
|
||||
"ai-health": "AI Health",
|
||||
"goals": "Goals",
|
||||
"credits": "Credits & Subscription",
|
||||
"spiral": "Mana Spiral",
|
||||
"settings": "Settings",
|
||||
"themes": "Themes",
|
||||
"profile": "Profile",
|
||||
"admin": "Admin",
|
||||
"complexity": "Complexity",
|
||||
"api-keys": "API Keys",
|
||||
"wishes": "Wishes",
|
||||
"help": "Help",
|
||||
"wetter": "Weather",
|
||||
"feedback": "Feedback",
|
||||
"wardrobe": "Wardrobe",
|
||||
"library": "Library",
|
||||
"spaces": "Spaces",
|
||||
"website": "Website",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Guides",
|
||||
"comic": "Comic"
|
||||
}
|
||||
|
|
|
|||
30
apps/mana/apps/web/src/lib/modules/comic/ListView.svelte
Normal file
30
apps/mana/apps/web/src/lib/modules/comic/ListView.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ListView from './views/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<div class="comic-root">
|
||||
<ListView />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comic-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (min-width: 640px) {
|
||||
.comic-root {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
210
apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts
Normal file
210
apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Panel generation client. Composes a reference-based image-edit call
|
||||
* against `/api/v1/picture/generate-with-reference` using the story's
|
||||
* fixed `characterMediaIds` plus the story-wide style-prefix, then
|
||||
* persists the result into `picture.images` with `comicStoryId` +
|
||||
* `comicPanelIndex` back-refs and appends the panel to the story via
|
||||
* `comicStoriesStore.appendPanel`.
|
||||
*
|
||||
* Same HTTP shape as `wardrobe/api/try-on.ts` — Comics reuse the
|
||||
* endpoint verbatim. Only difference: character refs come from the
|
||||
* story row (not reactively from useImageByPrimary), and the result
|
||||
* goes through appendPanel into the story's ordered panel list.
|
||||
*
|
||||
* Plan: docs/plans/comic-module.md M2.
|
||||
*/
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import { composePanelPrompt } from '../styles';
|
||||
import type { ComicPanelMeta, ComicStory } from '../types';
|
||||
|
||||
/**
|
||||
* Panel size. 1024×1024 is the comic-default — square panels compose
|
||||
* into a strip or grid cleanly. 1024×1536 is available for verticaly-
|
||||
* oriented "Webtoon"-style long shots. The backend supports more but
|
||||
* M2 keeps the picker small.
|
||||
*/
|
||||
export type PanelSize = '1024x1024' | '1024x1536';
|
||||
|
||||
export interface RunPanelGenerateParams {
|
||||
story: ComicStory;
|
||||
panelPrompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
/** Tags the panel with the module-entry it was seeded from (M4 AI-
|
||||
* Storyboard). Ignored in M2 single-panel flow. */
|
||||
sourceInput?: ComicPanelMeta['sourceInput'];
|
||||
quality?: 'low' | 'medium' | 'high';
|
||||
size?: PanelSize;
|
||||
}
|
||||
|
||||
export interface RunPanelGenerateResult {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
panelIndex: number;
|
||||
}
|
||||
|
||||
function dimsForSize(size: PanelSize): { width: number; height: number } {
|
||||
if (size === '1024x1536') return { width: 1024, height: 1536 };
|
||||
return { width: 1024, height: 1024 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared low-level POST. Mirrors wardrobe's callGenerateWithReference
|
||||
* so the error matrix stays identical across the two consumers of
|
||||
* this endpoint.
|
||||
*/
|
||||
async function callGenerateWithReference(opts: {
|
||||
prompt: string;
|
||||
referenceMediaIds: string[];
|
||||
quality: 'low' | 'medium' | 'high';
|
||||
size: PanelSize;
|
||||
}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: opts.prompt,
|
||||
referenceMediaIds: opts.referenceMediaIds,
|
||||
model: 'openai/gpt-image-2',
|
||||
quality: opts.quality,
|
||||
size: opts.size,
|
||||
n: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
required?: number;
|
||||
missing?: string[];
|
||||
};
|
||||
if (res.status === 402) {
|
||||
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — prüfe, ob Face/Body in diesem Space existieren.'
|
||||
);
|
||||
}
|
||||
const label = body.error ?? `Panel-Generierung fehlgeschlagen (${res.status})`;
|
||||
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
images?: Array<{ imageUrl: string; mediaId?: string }>;
|
||||
imageUrl?: string;
|
||||
mediaId?: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
};
|
||||
const first =
|
||||
(data.images && data.images[0]) ??
|
||||
(data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
|
||||
if (!first?.imageUrl || !first.mediaId) {
|
||||
throw new Error('Keine Bilder zurückgegeben');
|
||||
}
|
||||
return {
|
||||
imageUrl: first.imageUrl,
|
||||
mediaId: first.mediaId,
|
||||
prompt: data.prompt,
|
||||
model: data.model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one panel for a story. The story provides the fixed
|
||||
* reference-image list (face + optional body + optional garments —
|
||||
* chosen once at story-create time); this call only adds the panel
|
||||
* prompt + caption + dialogue on top of the story's style prefix.
|
||||
*/
|
||||
export async function runPanelGenerate(
|
||||
params: RunPanelGenerateParams
|
||||
): Promise<RunPanelGenerateResult> {
|
||||
const { story, panelPrompt, caption, dialogue, sourceInput } = params;
|
||||
|
||||
if (story.characterMediaIds.length === 0) {
|
||||
throw new Error('Story hat keine Character-Referenz — bitte Face-Ref hinterlegen.');
|
||||
}
|
||||
if (!panelPrompt.trim()) {
|
||||
throw new Error('Panel-Prompt ist leer.');
|
||||
}
|
||||
|
||||
// Style-prefix + panelPrompt + caption/dialog hints, composed in
|
||||
// styles.ts. The backend never sees the style enum — it only sees
|
||||
// the final prompt string.
|
||||
const composedPrompt = composePanelPrompt({
|
||||
style: story.style,
|
||||
panelPrompt,
|
||||
caption,
|
||||
dialogue,
|
||||
});
|
||||
|
||||
const effectiveSize: PanelSize =
|
||||
params.size ?? (story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
const effectiveQuality = params.quality ?? 'medium';
|
||||
|
||||
// Cap at 8 references (server limit). If the story somehow has more
|
||||
// in its characterMediaIds (shouldn't — UI caps at ~5), truncate and
|
||||
// warn. Face-ref is [0] by convention.
|
||||
const referenceMediaIds = story.characterMediaIds.slice(0, 8);
|
||||
|
||||
const result = await callGenerateWithReference({
|
||||
prompt: composedPrompt,
|
||||
referenceMediaIds,
|
||||
quality: effectiveQuality,
|
||||
size: effectiveSize,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const localImageId = crypto.randomUUID();
|
||||
const dims = dimsForSize(effectiveSize);
|
||||
const panelIndex = story.panelImageIds.length; // zero-based
|
||||
|
||||
await imagesStore.insert({
|
||||
id: localImageId,
|
||||
prompt: result.prompt,
|
||||
negativePrompt: null,
|
||||
model: result.model,
|
||||
publicUrl: result.imageUrl,
|
||||
storagePath: result.mediaId,
|
||||
filename: `comic-panel-${story.id}-${panelIndex + 1}.png`,
|
||||
format: 'png',
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
referenceImageIds: referenceMediaIds,
|
||||
comicStoryId: story.id,
|
||||
comicPanelIndex: panelIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await comicStoriesStore.appendPanel(story.id, localImageId, {
|
||||
caption: caption?.trim() || undefined,
|
||||
dialogue: dialogue?.trim() || undefined,
|
||||
promptUsed: composedPrompt,
|
||||
sourceInput,
|
||||
});
|
||||
|
||||
return {
|
||||
imageId: localImageId,
|
||||
imageUrl: result.imageUrl,
|
||||
prompt: result.prompt,
|
||||
model: result.model,
|
||||
panelIndex,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
<!--
|
||||
CharacterPicker — selects the reference-image set that every panel
|
||||
in the story renders against. At minimum: primary face-ref from the
|
||||
active space's meImages. Optional add-ons:
|
||||
- primary body-ref (for full-body framing)
|
||||
- up to 3 wardrobe-garment photos (costume setup)
|
||||
|
||||
Mirrors wardrobe's try-on composition (face + body + garments) but
|
||||
here the list is chosen ONCE at story-create time and fixed on the
|
||||
story row — every panel uses the same refs for visual continuity.
|
||||
|
||||
Outputs: `value: string[]` (mediaIds, face-ref at [0]). Emits via
|
||||
`onChange` on every add/remove.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus, X, UserCircle, TShirt } from '@mana/shared-icons';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import { useAllGarments } from '$lib/modules/wardrobe/queries';
|
||||
import { garmentPhotoUrl } from '$lib/modules/wardrobe/api/media-url';
|
||||
import type { Garment } from '$lib/modules/wardrobe/types';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
const face$ = useImageByPrimary('face-ref');
|
||||
const body$ = useImageByPrimary('body-ref');
|
||||
const garments$ = useAllGarments();
|
||||
|
||||
const face = $derived(face$.value);
|
||||
const body = $derived(body$.value);
|
||||
const allGarments = $derived(garments$.value ?? []);
|
||||
|
||||
// Auto-seed face-ref at position [0] the first time it becomes
|
||||
// available and value is still empty. After that, mutations go
|
||||
// through the Add/Remove buttons.
|
||||
let seeded = false;
|
||||
$effect(() => {
|
||||
if (!seeded && face?.mediaId && value.length === 0) {
|
||||
seeded = true;
|
||||
onChange([face.mediaId]);
|
||||
}
|
||||
});
|
||||
|
||||
const hasFace = $derived(Boolean(face?.mediaId));
|
||||
const hasBody = $derived(Boolean(body?.mediaId));
|
||||
|
||||
const bodyInValue = $derived(body?.mediaId ? value.includes(body.mediaId) : false);
|
||||
|
||||
// Garment slots = everything beyond [face, body] if present.
|
||||
const garmentIdsInValue = $derived.by<string[]>(() => {
|
||||
const exclude = new Set<string>();
|
||||
if (face?.mediaId) exclude.add(face.mediaId);
|
||||
if (body?.mediaId) exclude.add(body.mediaId);
|
||||
return value.filter((id) => !exclude.has(id));
|
||||
});
|
||||
|
||||
const garmentPicks = $derived.by<Garment[]>(() => {
|
||||
const out: Garment[] = [];
|
||||
for (const id of garmentIdsInValue) {
|
||||
const g = allGarments.find((g) => g.mediaIds[0] === id);
|
||||
if (g) out.push(g);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Garments the user can still add (not already picked, has a photo,
|
||||
// not archived). Max 3 slots.
|
||||
const MAX_GARMENTS = 3;
|
||||
const canAddGarment = $derived(garmentIdsInValue.length < MAX_GARMENTS);
|
||||
const availableGarments = $derived(
|
||||
allGarments.filter(
|
||||
(g) => !g.isArchived && g.mediaIds[0] && !garmentIdsInValue.includes(g.mediaIds[0])
|
||||
)
|
||||
);
|
||||
|
||||
let showGarmentPicker = $state(false);
|
||||
|
||||
function toggleBody() {
|
||||
if (!body?.mediaId) return;
|
||||
if (bodyInValue) {
|
||||
onChange(value.filter((id) => id !== body.mediaId));
|
||||
} else {
|
||||
// Insert body at [1] (right after face), before garments.
|
||||
const next = [...value];
|
||||
const insertAt = face?.mediaId && next[0] === face.mediaId ? 1 : 0;
|
||||
next.splice(insertAt, 0, body.mediaId);
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
function addGarment(g: Garment) {
|
||||
const mediaId = g.mediaIds[0];
|
||||
if (!mediaId || value.includes(mediaId)) return;
|
||||
onChange([...value, mediaId]);
|
||||
showGarmentPicker = false;
|
||||
}
|
||||
|
||||
function removeGarment(mediaId: string) {
|
||||
onChange(value.filter((id) => id !== mediaId));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Protagonist
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||
Dein Gesicht ist Pflicht. Body-Ref und bis zu {MAX_GARMENTS} Kostüm-Fotos sind optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
<!-- Face ref tile — mandatory -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#if face?.publicUrl}
|
||||
<img
|
||||
src={face.thumbnailUrl ?? face.publicUrl}
|
||||
alt="Face-Ref"
|
||||
class="h-20 w-20 rounded-md border border-primary/30 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/50 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
<span>Face fehlt</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-[10px] font-medium text-muted-foreground">Face</span>
|
||||
</div>
|
||||
|
||||
<!-- Body ref tile — optional toggle -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#if body?.publicUrl}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={toggleBody}
|
||||
class="relative h-20 w-20 overflow-hidden rounded-md border transition-colors
|
||||
{bodyInValue ? 'border-primary/50' : 'border-border opacity-50 hover:opacity-100'}"
|
||||
aria-pressed={bodyInValue}
|
||||
title={bodyInValue ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
|
||||
>
|
||||
<img
|
||||
src={body.thumbnailUrl ?? body.publicUrl}
|
||||
alt="Body-Ref"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if !bodyInValue}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-background/40 text-xs text-foreground"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/30 text-[10px] text-muted-foreground"
|
||||
title="Kein Body-Ref im aktiven Space"
|
||||
>
|
||||
<UserCircle size={18} />
|
||||
<span>Body fehlt</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-[10px] font-medium text-muted-foreground">Body</span>
|
||||
</div>
|
||||
|
||||
<!-- Garment tiles (picked) -->
|
||||
{#each garmentPicks as g (g.id)}
|
||||
{@const mediaId = g.mediaIds[0]}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="relative h-20 w-20 overflow-hidden rounded-md border border-primary/30">
|
||||
{#if mediaId}
|
||||
<img
|
||||
src={garmentPhotoUrl(mediaId, 'thumb')}
|
||||
alt={g.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={() => mediaId && removeGarment(mediaId)}
|
||||
class="absolute right-0 top-0 m-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background/80 text-foreground shadow-sm hover:bg-background"
|
||||
aria-label={`${g.name} entfernen`}
|
||||
title="Entfernen"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<span class="max-w-20 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{g.name}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add-garment button -->
|
||||
{#if canAddGarment}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={() => (showGarmentPicker = !showGarmentPicker)}
|
||||
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-expanded={showGarmentPicker}
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span class="text-[10px] font-medium">Kostüm</span>
|
||||
</button>
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
{garmentIdsInValue.length}/{MAX_GARMENTS}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Garment picker (collapsible). Only shows when toggled open. -->
|
||||
{#if showGarmentPicker}
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold text-foreground">Kostüm aus dem Schrank wählen</h4>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showGarmentPicker = false)}
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{#if availableGarments.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Keine weiteren Kleidungsstücke verfügbar — lade welche in <a
|
||||
href="/wardrobe"
|
||||
class="text-primary hover:underline">/wardrobe</a
|
||||
> hoch.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid max-h-48 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6">
|
||||
{#each availableGarments as g (g.id)}
|
||||
{@const mediaId = g.mediaIds[0]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addGarment(g)}
|
||||
class="group flex flex-col items-center gap-1 overflow-hidden rounded-md border border-border bg-background text-left hover:border-primary/50"
|
||||
title={g.name}
|
||||
>
|
||||
<div class="aspect-square w-full bg-muted">
|
||||
{#if mediaId}
|
||||
<img
|
||||
src={garmentPhotoUrl(mediaId, 'thumb')}
|
||||
alt={g.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="w-full truncate px-1 pb-1 text-[10px] text-foreground">
|
||||
{g.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasFace}
|
||||
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
|
||||
Kein Gesichtsbild in diesem Space. Lade eins in
|
||||
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a>
|
||||
hoch — ohne Face-Ref kein Comic.
|
||||
</div>
|
||||
{:else if !hasBody}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<TShirt size={12} class="inline" /> Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen
|
||||
soll.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<!--
|
||||
PanelCard — single rendered panel with its caption/dialogue sidecar.
|
||||
In M2 captions/dialogue are baked into the image by gpt-image-2, so
|
||||
the text under the image is redundant meta for the author's own
|
||||
reference (quick scan without opening the full image). It's still
|
||||
useful for accessibility and for regenerating / reviewing prompts.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { X } from '@mana/shared-icons';
|
||||
import { usePanelImage } from '../queries';
|
||||
import type { ComicPanelMeta } from '../types';
|
||||
|
||||
interface Props {
|
||||
panelId: string;
|
||||
panelIndex: number;
|
||||
meta: ComicPanelMeta | undefined;
|
||||
/** Shows a small remove-from-story button. Wired by the DetailView. */
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { panelId, panelIndex, meta, onRemove }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const image$ = usePanelImage(panelId);
|
||||
const image = $derived(image$.value);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex h-full w-full flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div class="relative aspect-square bg-muted">
|
||||
{#if image?.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt="Panel {panelIndex + 1}"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if image$.loading}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Lädt…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Panel nicht gefunden
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="absolute left-2 top-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
#{panelIndex + 1}
|
||||
</span>
|
||||
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:text-error"
|
||||
aria-label="Panel aus Story entfernen"
|
||||
title="Panel aus Story entfernen (Bild bleibt in der Galerie)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if meta?.caption || meta?.dialogue}
|
||||
<div class="space-y-1 px-2.5 py-1.5 text-[11px] leading-snug">
|
||||
{#if meta.caption}
|
||||
<p class="text-muted-foreground"><em>{meta.caption}</em></p>
|
||||
{/if}
|
||||
{#if meta.dialogue}
|
||||
<p class="text-foreground">„{meta.dialogue}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<!--
|
||||
PanelEditor — inline "new panel" sheet. Three textareas (prompt,
|
||||
caption, dialogue) plus a quality toggle and the Generieren button.
|
||||
On submit, runPanelGenerate fires the API call and appends the new
|
||||
panel to the story; on error the sheet stays mounted so the user
|
||||
can adjust without retyping.
|
||||
|
||||
Prompt and the optional caption/dialogue get composed with the
|
||||
story-wide style prefix inside `composePanelPrompt` before the call
|
||||
— the user doesn't repeat style instructions per panel.
|
||||
|
||||
M2 scope: single panel per click. Batch-mode (n panels in one submit)
|
||||
is M3 via the plan.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
|
||||
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
|
||||
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
|
||||
import type { ComicStory } from '../types';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
onClose: () => void;
|
||||
onGenerated?: (panelId: string) => void;
|
||||
}
|
||||
|
||||
let { story, onClose, onGenerated }: Props = $props();
|
||||
|
||||
let panelPrompt = $state('');
|
||||
let caption = $state('');
|
||||
let dialogue = $state('');
|
||||
let quality = $state<'low' | 'medium' | 'high'>('medium');
|
||||
// Size defaults based on the story's style at mount time — users
|
||||
// can flip the toggle per panel afterwards, so capturing the
|
||||
// initial value is intentional here.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
const atCap = $derived(panelCount >= MAX_PANELS_PER_STORY);
|
||||
const warn = $derived(panelCount >= PANEL_COUNT_WARN_THRESHOLD && !atCap);
|
||||
|
||||
const canSubmit = $derived(panelPrompt.trim().length > 0 && !submitting && !atCap);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
submitting = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt,
|
||||
caption: caption.trim() || undefined,
|
||||
dialogue: dialogue.trim() || undefined,
|
||||
quality,
|
||||
size,
|
||||
});
|
||||
onGenerated?.(result.imageId);
|
||||
// Reset local state so the next panel-add starts fresh.
|
||||
panelPrompt = '';
|
||||
caption = '';
|
||||
dialogue = '';
|
||||
submitting = false;
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = {
|
||||
low: 3,
|
||||
medium: 10,
|
||||
high: 25,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">Neues Panel</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Panel {panelCount + 1} · nutzt {story.characterMediaIds.length} Referenz{story
|
||||
.characterMediaIds.length === 1
|
||||
? ''
|
||||
: 'en'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Panel-Editor schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if atCap}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
Hart-Limit von {MAX_PANELS_PER_STORY} Panels erreicht. Ältere Panels entfernen oder neue Story anlegen.
|
||||
</div>
|
||||
{:else if warn}
|
||||
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar
|
||||
schwerer.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="mt-3 space-y-3"
|
||||
class:pointer-events-none={atCap}
|
||||
class:opacity-60={atCap}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-prompt"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Panel-Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="panel-prompt"
|
||||
bind:value={panelPrompt}
|
||||
rows={3}
|
||||
placeholder="Was passiert in diesem Panel? z.B. 'Protagonist sitzt am Schreibtisch, starrt auf Monitor mit rotem X'"
|
||||
maxlength={600}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-caption"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Caption <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="panel-caption"
|
||||
type="text"
|
||||
bind:value={caption}
|
||||
placeholder="Montag, 9 Uhr."
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-dialogue"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Dialog <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="panel-dialogue"
|
||||
type="text"
|
||||
bind:value={dialogue}
|
||||
placeholder="Schon wieder rot."
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Caption und Dialog werden direkt in das Bild gerendert. Englische Texte rendern stabiler als
|
||||
deutsche, kurze Sätze funktionieren am besten.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1024')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1024'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1024'}
|
||||
>
|
||||
Quadrat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1536')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1536'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1536'}
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
Generiert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
Panel generieren ({CREDIT_COST[quality]}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: panel-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes panel-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<!--
|
||||
PanelStrip — horizontal scrollable list of panels in story order. On
|
||||
small screens the strip overflows horizontally (iOS-style momentum
|
||||
scroll); on wide screens it wraps into a 2–3 column grid. Avoids
|
||||
`grid-flow-col` so a long story doesn't force a monster horizontal
|
||||
scroll on desktop.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComicPanelMeta } from '../types';
|
||||
import PanelCard from './PanelCard.svelte';
|
||||
|
||||
interface Props {
|
||||
panelImageIds: string[];
|
||||
panelMeta: Record<string, ComicPanelMeta>;
|
||||
onRemove?: (panelId: string) => void;
|
||||
}
|
||||
|
||||
let { panelImageIds, panelMeta, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if panelImageIds.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-6 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch keine Panels.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Klick unten auf <strong class="text-foreground">+ Panel</strong>, um die erste Szene zu
|
||||
generieren.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each panelImageIds as panelId, index (panelId)}
|
||||
<PanelCard
|
||||
{panelId}
|
||||
panelIndex={index}
|
||||
meta={panelMeta[panelId]}
|
||||
onRemove={onRemove ? () => onRemove(panelId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<!--
|
||||
Grid tile for a comic story. Cover = first panel's publicUrl from
|
||||
picture.images. Stories without any panels yet render a placeholder
|
||||
with the style badge so the user still has something to click.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, Heart } from '@mana/shared-icons';
|
||||
import { STYLE_LABELS } from '../constants';
|
||||
import { usePanelImage } from '../queries';
|
||||
import type { ComicStory } from '../types';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
}
|
||||
|
||||
let { story }: Props = $props();
|
||||
|
||||
const coverPanelId = $derived(story.panelImageIds[0] ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const cover$ = usePanelImage(coverPanelId);
|
||||
const cover = $derived(cover$.value);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/comic/{story.id}"
|
||||
class="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden bg-muted">
|
||||
{#if cover?.publicUrl}
|
||||
<img
|
||||
src={cover.publicUrl}
|
||||
alt={story.title}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-muted to-muted/50 text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={24} />
|
||||
<span class="text-xs">Noch kein Panel</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Style badge -->
|
||||
<span
|
||||
class="absolute bottom-2 left-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
{STYLE_LABELS[story.style].de}
|
||||
</span>
|
||||
|
||||
{#if story.isFavorite}
|
||||
<span class="absolute right-2 top-2 text-rose-500" aria-label="Favorit">
|
||||
<Heart size={14} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5 px-3 py-2">
|
||||
<h3 class="truncate text-sm font-medium text-foreground">{story.title}</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{panelCount}
|
||||
{panelCount === 1 ? 'Panel' : 'Panels'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
<!--
|
||||
StoryForm — create a new comic story. Title + Style + Characters +
|
||||
optional Kontext. On submit, createStory() lands the row in Dexie
|
||||
and we navigate to /comic/[id] so the user can start adding panels
|
||||
immediately.
|
||||
|
||||
No edit mode yet — update-story is a future concern (users who want
|
||||
to change the style/characters can just create a new story). The
|
||||
form is tuned for the "fresh idea → first panel in <60s"-flow.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Sparkle } from '@mana/shared-icons';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import type { ComicStyle } from '../types';
|
||||
import StylePicker from './StylePicker.svelte';
|
||||
import CharacterPicker from './CharacterPicker.svelte';
|
||||
|
||||
let title = $state('');
|
||||
let style = $state<ComicStyle>('comic');
|
||||
let characterMediaIds = $state<string[]>([]);
|
||||
let storyContext = $state('');
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
const activeSpace = $derived(getActiveSpace());
|
||||
const canSubmit = $derived(
|
||||
title.trim().length > 0 && characterMediaIds.length > 0 && !submitting
|
||||
);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
submitting = true;
|
||||
submitError = null;
|
||||
try {
|
||||
const story = await comicStoriesStore.createStory({
|
||||
title: title.trim(),
|
||||
style,
|
||||
characterMediaIds,
|
||||
storyContext: storyContext.trim() || null,
|
||||
});
|
||||
await goto(`/comic/${story.id}`);
|
||||
} catch (err) {
|
||||
submitError = err instanceof Error ? err.message : 'Erstellung fehlgeschlagen';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-5">
|
||||
<!-- Title -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="comic-title"
|
||||
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
id="comic-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Bug-Hunt-Frust, Urlaubs-Abenteuer, …"
|
||||
maxlength={120}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Stil</div>
|
||||
<StylePicker value={style} onChange={(next) => (style = next)} disabled={submitting} />
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Der Stil gilt für alle Panels der Geschichte. Wechsel ist später nicht möglich — dafür neue
|
||||
Story anlegen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Character refs -->
|
||||
<CharacterPicker
|
||||
value={characterMediaIds}
|
||||
onChange={(next) => (characterMediaIds = next)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<!-- Story context (optional) -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="comic-context"
|
||||
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Kontext <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="comic-context"
|
||||
bind:value={storyContext}
|
||||
rows={3}
|
||||
maxlength={800}
|
||||
placeholder="Kurze Zusammenfassung, Ton, Ziel der Geschichte. Wird im AI-Storyboard-Flow (M4) als Briefing genutzt."
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if activeSpace && activeSpace.type !== 'personal'}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Diese Story gehört zu <strong class="text-foreground">{activeSpace.name}</strong> — nur Mitglieder
|
||||
dieses Space sehen sie.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if submitError}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Sparkle size={14} />
|
||||
{submitting ? 'Wird erstellt…' : 'Story anlegen'}
|
||||
</button>
|
||||
<a
|
||||
href="/comic"
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
Abbrechen
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
StylePicker — radio-tiles for the five ComicStyle presets. Chosen at
|
||||
story-create time, fixed afterward (restyling = new story). Each
|
||||
tile carries a short "what this looks like" hint so the user can
|
||||
pick without having to memorise the preset mapping in styles.ts.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { STYLE_ORDER, STYLE_LABELS } from '../constants';
|
||||
import type { ComicStyle } from '../types';
|
||||
|
||||
interface Props {
|
||||
value: ComicStyle;
|
||||
onChange: (next: ComicStyle) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
// Short descriptive hint per preset, shown under the label. Keep
|
||||
// each line ≤ 60 chars so 2-column layouts don't wrap.
|
||||
const HINTS: Record<ComicStyle, string> = {
|
||||
comic: 'Kräftige Linien, Cell-Shading, US-Comic-Look',
|
||||
manga: 'Schwarz/weiß, Screen-Tones, dynamische Perspektive',
|
||||
cartoon: 'Weich, pastellig, Saturday-Morning-Feeling',
|
||||
'graphic-novel': 'Aquarell / painterly, stimmungsvoll',
|
||||
webtoon: 'Vertikale Panels, moderne Farben, Soft-Shading',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each STYLE_ORDER as style (style)}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={() => onChange(style)}
|
||||
class="rounded-lg border px-3 py-2.5 text-left transition-colors
|
||||
{value === style
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-foreground hover:bg-muted'}"
|
||||
aria-pressed={value === style}
|
||||
>
|
||||
<div class="text-sm font-medium">{STYLE_LABELS[style].de}</div>
|
||||
<div class="mt-0.5 text-xs text-muted-foreground">{HINTS[style]}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -15,6 +15,7 @@ export {
|
|||
useStory,
|
||||
useStoryPanels,
|
||||
useStoriesByInput,
|
||||
usePanelImage,
|
||||
} from './queries';
|
||||
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
|
||||
export { STYLE_PREFIXES, composePanelPrompt } from './styles';
|
||||
|
|
|
|||
|
|
@ -44,6 +44,26 @@ export function useStoriesByStyle(style: ComicStyle) {
|
|||
}, [] as ComicStory[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single picture.images row by id — used for panel rendering
|
||||
* (cover on StoryCard, thumbnails on PanelStrip, full-size on
|
||||
* PanelCard). Lives here (not in picture/queries) because it's
|
||||
* comic-specific convenience; picture's own queries don't need a
|
||||
* single-image hook today.
|
||||
*/
|
||||
export function usePanelImage(imageId: string | null) {
|
||||
return useLiveQueryWithDefault<Image | null>(async () => {
|
||||
if (!imageId) return null;
|
||||
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
|
||||
.and((row) => row.id === imageId)
|
||||
.toArray();
|
||||
const [local] = locals;
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('images', [local]);
|
||||
return toImage(decrypted);
|
||||
}, null);
|
||||
}
|
||||
|
||||
/** A single story by id, live-updating. Null while loading / missing. */
|
||||
export function useStory(id: string | null) {
|
||||
return useLiveQueryWithDefault<ComicStory | null>(async () => {
|
||||
|
|
|
|||
231
apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte
Normal file
231
apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<!--
|
||||
Comic story detail — meta card (title + style + visibility +
|
||||
favorite + archive/delete) and panel strip with a "+ Panel" CTA
|
||||
that opens the PanelEditor sheet inline.
|
||||
|
||||
Removing a panel here strips it from the story's `panelImageIds`
|
||||
and `panelMeta` only — the picture.images row itself survives so
|
||||
the user can keep the render in their Picture gallery. Final
|
||||
deletion happens from Picture, per decision in the plan.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
|
||||
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
||||
import { comicStoriesTable } from '../collections';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import { useStory } from '../queries';
|
||||
import { STYLE_LABELS } from '../constants';
|
||||
import PanelStrip from '../components/PanelStrip.svelte';
|
||||
import PanelEditor from '../components/PanelEditor.svelte';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { ComicPanelMeta, LocalComicStory } from '../types';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const story$ = useStory(id);
|
||||
const story = $derived(story$.value);
|
||||
|
||||
let showEditor = $state(false);
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.toggleFavorite(story.id);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.archiveStory(story.id, !story.isArchived);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!story) return;
|
||||
if (!confirm(`Story "${story.title}" wirklich löschen?`)) return;
|
||||
await comicStoriesStore.deleteStory(story.id);
|
||||
await goto('/comic');
|
||||
}
|
||||
|
||||
async function handleVisibilityChange(next: VisibilityLevel) {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.setVisibility(story.id, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a panel from the story without touching the image row.
|
||||
* Re-encrypts `panelMeta` because it's one JSON blob per the
|
||||
* registry; we can't partially update without decrypting first.
|
||||
*/
|
||||
async function handleRemovePanel(panelId: string) {
|
||||
if (!story) return;
|
||||
if (
|
||||
!confirm(
|
||||
'Panel aus der Story entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden.'
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const existing = await comicStoriesTable.get(story.id);
|
||||
if (!existing) return;
|
||||
const nextIds = (existing.panelImageIds ?? []).filter((pid) => pid !== panelId);
|
||||
const nextMeta: Record<string, ComicPanelMeta> = { ...(existing.panelMeta ?? {}) };
|
||||
delete nextMeta[panelId];
|
||||
const patch = {
|
||||
panelImageIds: nextIds,
|
||||
panelMeta: nextMeta,
|
||||
} as Partial<LocalComicStory>;
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('comicStories', wrapped);
|
||||
await comicStoriesTable.update(story.id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-5 p-4 sm:p-6">
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="/comic"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
aria-label="Zurück zu Comics"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</a>
|
||||
<span class="text-muted-foreground">Comics</span>
|
||||
</nav>
|
||||
|
||||
{#if !story}
|
||||
{#if story$.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">Story 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">{story.title}</h1>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
|
||||
{STYLE_LABELS[story.style].de}
|
||||
</span>
|
||||
<span>
|
||||
{story.panelImageIds.length}
|
||||
{story.panelImageIds.length === 1 ? 'Panel' : 'Panels'}
|
||||
</span>
|
||||
{#if story.characterMediaIds.length > 0}
|
||||
<span class="text-border">·</span>
|
||||
<span>
|
||||
{story.characterMediaIds.length} Referenz{story.characterMediaIds.length === 1
|
||||
? ''
|
||||
: 'en'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<VisibilityPicker
|
||||
level={story.visibility ?? 'space'}
|
||||
onChange={handleVisibilityChange}
|
||||
compact
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleToggleFavorite}
|
||||
aria-label={story.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
title={story.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {story.isFavorite
|
||||
? 'text-rose-500 hover:bg-rose-500/10'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<Heart size={16} weight={story.isFavorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if story.description}
|
||||
<p class="whitespace-pre-wrap text-sm text-foreground">{story.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if story.storyContext}
|
||||
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<strong class="text-foreground">Kontext:</strong>
|
||||
{story.storyContext}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Panels -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Panels</h2>
|
||||
{#if !showEditor && !story.isArchived}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showEditor = 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} />
|
||||
Panel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PanelStrip
|
||||
panelImageIds={story.panelImageIds}
|
||||
panelMeta={story.panelMeta}
|
||||
onRemove={handleRemovePanel}
|
||||
/>
|
||||
|
||||
{#if showEditor && !story.isArchived}
|
||||
<PanelEditor
|
||||
{story}
|
||||
onClose={() => (showEditor = false)}
|
||||
onGenerated={() => {
|
||||
// Keep the editor open for rapid iteration — the user
|
||||
// usually wants to generate 3–5 panels in a row. Reset
|
||||
// happens inside PanelEditor on success.
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleArchive}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{story.isArchived ? '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 story.isArchived}
|
||||
<p
|
||||
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={12} class="inline" /> Archivierte Story — keine Panel-Generierung möglich, bis
|
||||
wieder aktiviert.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<!--
|
||||
Comic list view — grid of stories in the active space, with a "+"
|
||||
CTA at the top to jump into the create flow. Empty-state nudges
|
||||
first-time users to check their face-ref first (comics can't
|
||||
render without a Protagonist).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus, UserCircle } from '@mana/shared-icons';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import { useAllStories } from '../queries';
|
||||
import StoryCard from '../components/StoryCard.svelte';
|
||||
|
||||
const stories$ = useAllStories();
|
||||
const stories = $derived(stories$.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 Comics</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{stories.length}
|
||||
{stories.length === 1 ? 'Story' : 'Stories'} in
|
||||
<strong class="text-foreground">{activeSpace?.name ?? 'diesem Space'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/comic/new"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Neue Story
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if !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">
|
||||
Ohne Face-Ref im aktiven Space kann kein Comic-Panel generiert werden. Hochladen in
|
||||
<a href="/profile/me-images" class="text-primary hover:underline">Profil → Bilder</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stories.length > 0}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each stories as story (story.id)}
|
||||
<StoryCard {story} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !stories$.loading}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch keine Comics.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach
|
||||
einer Idee.
|
||||
</p>
|
||||
<a
|
||||
href="/comic/new"
|
||||
class="mt-4 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Erste Story anlegen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
12
apps/mana/apps/web/src/routes/(app)/comic/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/comic/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import ListView from '$lib/modules/comic/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic">
|
||||
<ListView />
|
||||
</RoutePage>
|
||||
20
apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte
Normal file
20
apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import DetailView from '$lib/modules/comic/views/DetailView.svelte';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic">
|
||||
<!-- Fresh subtree on :id change so liveQuery and local state (panel
|
||||
editor open / close, scroll position) reset cleanly when
|
||||
navigating between /comic/a → /comic/b. -->
|
||||
{#key id}
|
||||
<DetailView {id} />
|
||||
{/key}
|
||||
</RoutePage>
|
||||
20
apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte
Normal file
20
apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import StoryForm from '$lib/modules/comic/components/StoryForm.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic">
|
||||
<div class="mx-auto max-w-2xl space-y-4 p-4 sm:p-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-lg font-semibold text-foreground">Neuer Comic</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Wähle Stil + Protagonist, dann startest du mit dem ersten Panel.
|
||||
</p>
|
||||
</header>
|
||||
<StoryForm />
|
||||
</div>
|
||||
</RoutePage>
|
||||
Loading…
Add table
Add a link
Reference in a new issue