mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
450372e545
commit
1c4486ceba
3 changed files with 180 additions and 44 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue