fix(comic): inline face-upload banner — Parität mit Wardrobe-UX

User-Feedback: in Wardrobe konnte man das Gesichtsbild direkt aus
der Workbench-Card hochladen, in Comic verwies der Hint nur auf
/profile/me-images. Asymmetrie geheilt — beide Module nutzen jetzt
das gleiche Banner-Pattern.

Comic-ListView (Modul-Root, oberhalb der Tabs):
- Wardrobe-Banner verbatim übernommen (MeImageUploadZone +
  3-Phasen-State-Machine idle/uploading/success + 2.5s
  success-card mit fade-out + dismissable + spinner-overlay
  während upload + error-card auf Fehler).
- Sitzt oberhalb der Tabs, damit es für BEIDE Sub-Views
  (Stories | Characters) sichtbar ist — Comic-Panel UND
  Charakter-Generierung brauchen das Face-Ref. Banner blendet
  sich automatisch aus sobald face$ via liveQuery flippt + die
  2.5s success-Window vorbei ist.
- Copy angepasst: "Wir brauchen dich auf Bild, damit Comic-Panels
  und Charakter-Varianten von dir gerendert werden können"
  statt Wardrobe's "Try-On Kleidung an dir visualisieren".
  Success-CTA: "als nächstes baust du deinen ersten Comic-
  Character oder legst direkt eine Story an".

Sub-Views aufgeräumt:
- views/ListView.svelte (StoriesView): hat den redundanten
  "Lade erst dein Gesichtsbild"-Hint inkl. UserCircle-Import +
  useImageByPrimary-Hook gehabt → entfernt. Modul-Root liefert
  das jetzt.
- views/CharactersView.svelte: gleicher Cleanup. Imports von
  UserCircle und useImageByPrimary raus.

Repair-Hook (`repairSilentTwinAvatarRows`) bewusst NICHT
kopiert — das war eine Wardrobe-spezifische Migration für die
M2.5-silent-twin-Bug; Comic ist nach v40 entstanden, hat das
Problem nie gehabt.

Comic-Files type-checken sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:48:20 +02:00
parent 450372e545
commit 1c4486ceba
3 changed files with 180 additions and 44 deletions

View file

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

View file

@ -1,12 +1,12 @@
<!--
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.
space, with a "+ Neuer Character" CTA. The face-ref upload banner
lives one level up in the module-root ListView (above the tabs),
so we don't repeat it here per tab.
-->
<script lang="ts">
import { Plus, UserCircle } from '@mana/shared-icons';
import { Plus } 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';
@ -14,8 +14,6 @@
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">
@ -37,21 +35,6 @@
</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)}

View file

@ -1,13 +1,11 @@
<!--
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).
Comic stories list view — grid of stories in the active space.
The face-ref upload banner lives one level up in the module-root
ListView (above the tabs), so we don't repeat it here per tab.
-->
<script lang="ts">
import { Plus, UserCircle } from '@mana/shared-icons';
import { Plus } 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';
@ -15,8 +13,6 @@
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">
@ -38,21 +34,6 @@
</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)}