feat(profile): UI for me-images management at /profile/me-images (M2)

M2 of docs/plans/me-images-and-reference-generation.md — the Settings
surface that sits on top of the M1 data layer. Users can now upload
a Face and a Fullbody reference into two primary slots, toss extra
references into a grid, and toggle each image's "KI darf nutzen" flag
individually.

Route placement: /profile/me-images (not /settings/me-images as the
plan originally proposed). The repo convention is per-module subroutes
(/todo/settings, /invoices/settings, …) — there is no global /settings
namespace to hang this off. Plan doc updated accordingly.

- MeImageUploadZone: drag-and-drop + file-picker, pattern from
  picture/ListView but refactored into a reusable component. Fires
  onFiles(File[]) so the parent decides kind + slot.
- MeImageSlotCard: large card for Face / Fullbody primary slots.
  When filled it shows the portrait + the image's AI-toggle + delete
  + a compact "Neues Bild setzen" replacement zone. When empty it
  collapses into a large drop-zone.
- MeImageTile: grid tile for everything that isn't currently holding
  a primary slot — thumbnail, kind badge, Robot-AI-toggle, Star
  primary-promotion (only enabled for kinds that map to a slot),
  Trash delete.
- MeImagesView: orchestrates queries (useImageByPrimary for each
  slot + useAllMeImages for the rest), upload flow (readDimensions →
  uploadMeImageFile → store.createMeImage → optional setPrimary in
  the same tick), and the three write actions (toggleAi, togglePrimary,
  delete). Dropping a file on a slot drop-zone both uploads and claims
  the slot, so the old holder automatically falls into the grid.
- Client: profile/api/me-images.ts wraps the M1 endpoint with
  authStore.getValidToken() → Bearer header and a small
  readImageDimensions helper that exposes natural width/height
  synchronously (mana-media reports them later but we want them for
  the Dexie row's first write).
- Discoverability: profile ListView "Konto" tab gains a "Meine Bilder"
  action button that navigates to the new route with a one-line hint.

Still open (later commits): the hard-migration that rewrites
auth.users.image → meImages(primaryFor='avatar'), the global
aiUsesReferenceImages kill-switch (lives on profile singleton), and
the Picture-generator's Reference picker (M4, rides on top of M3's
backend endpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:01:40 +02:00
parent a1caeaa7f3
commit a64a7e39cf
8 changed files with 564 additions and 5 deletions

View file

@ -211,6 +211,12 @@
</div>
<div class="account-actions">
<button class="account-btn" onclick={() => goto('/profile/me-images')}>
Meine Bilder
<span class="account-btn-hint">
Gesichts- und Ganzkörperbilder für KI-Bildgenerierung
</span>
</button>
<button class="account-btn" onclick={() => (showEditModal = true)}>
Profil bearbeiten
</button>
@ -365,7 +371,8 @@
}
.account-btn {
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 0.625rem 1rem;
border: 1px solid hsl(var(--color-border));
@ -375,6 +382,12 @@
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.account-btn-hint {
margin-top: 0.125rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.account-btn:hover {
background: hsl(var(--color-surface-hover));

View file

@ -0,0 +1,194 @@
<!--
Me-Images settings page — lets the user curate the reference pool
that AI generation (M3+) may pull from. Two primary slots (Face,
Fullbody) live up top; the grid below catches every other upload.
Upload flow: MeImageUploadZone hands us a File[]. For each file we
read dimensions client-side, upload to the auth-protected
/profile/me-images/upload endpoint (M1), and write a LocalMeImage
through the store so encryption + sync happen. If the upload came
from a slot drop-zone, we also claim that primary slot in the same
tick.
All image-level privacy controls (AI opt-in, delete, primary star)
live on the tile/slot components; this file just orchestrates.
-->
<script lang="ts">
import { Info, Sparkle } from '@mana/shared-icons';
import MeImageSlotCard from './components/MeImageSlotCard.svelte';
import MeImageTile from './components/MeImageTile.svelte';
import MeImageUploadZone from './components/MeImageUploadZone.svelte';
import { useAllMeImages, useImageByPrimary } from './queries';
import { meImagesStore } from './stores/me-images.svelte';
import { readImageDimensions, uploadMeImageFile } from './api/me-images';
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
const allImages$ = useAllMeImages();
const faceSlot$ = useImageByPrimary('face-ref');
const bodySlot$ = useImageByPrimary('body-ref');
const allImages = $derived(allImages$.value ?? []);
const faceImage = $derived(faceSlot$.value ?? null);
const bodyImage = $derived(bodySlot$.value ?? null);
/**
* Images shown in the "weitere Bilder" grid. Anything that isn't
* currently holding a primary slot goes here, so the user sees the
* full pool and can promote any tile into a slot via the star.
*/
const extraImages = $derived(
allImages.filter((img) => img.id !== faceImage?.id && img.id !== bodyImage?.id)
);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
function primarySlotForKind(kind: MeImageKind): MeImagePrimarySlot | null {
if (kind === 'face') return 'face-ref';
if (kind === 'fullbody') return 'body-ref';
return null;
}
async function ingestFiles(files: File[], kind: MeImageKind, claimSlot?: MeImagePrimarySlot) {
uploading = true;
uploadError = null;
try {
for (const file of files) {
const dims = (await readImageDimensions(file)) ?? { width: 0, height: 0 };
const uploaded = await uploadMeImageFile(file);
const created = await meImagesStore.createMeImage({
kind,
mediaId: uploaded.mediaId,
storagePath: uploaded.storagePath,
publicUrl: uploaded.publicUrl,
thumbnailUrl: uploaded.thumbnailUrl ?? null,
width: dims.width,
height: dims.height,
});
if (claimSlot) {
// setPrimary transactionally clears any previous slot-holder,
// so the old Face/Fullbody automatically drops into the grid.
await meImagesStore.setPrimary(created.id, claimSlot);
}
}
} catch (err) {
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploading = false;
}
}
async function handleToggleAi(id: string, enabled: boolean) {
await meImagesStore.setAiReferenceEnabled(id, enabled);
}
async function handleTogglePrimary(img: MeImage) {
const slot = primarySlotForKind(img.kind);
if (!slot) return;
const isPrimary = img.primaryFor === slot;
await meImagesStore.setPrimary(img.id, isPrimary ? null : slot);
}
async function handleDelete(id: string) {
if (!confirm('Bild wirklich löschen?')) return;
await meImagesStore.deleteMeImage(id);
}
</script>
<div class="mx-auto max-w-4xl space-y-8 p-4 sm:p-6">
<!-- Intro + privacy hint -->
<section class="rounded-2xl border border-border bg-card p-5">
<div class="mb-2 flex items-center gap-2 text-foreground">
<Sparkle size={18} weight="fill" class="text-primary" />
<h2 class="text-base font-semibold">Meine Bilder</h2>
</div>
<p class="text-sm text-muted-foreground">
Hinterlege hier ein Gesichts- und ein Ganzkörper-Bild sowie weitere Referenzen. Die
Bildgenerierung nutzt diese später, um dich selbst zu visualisieren — etwa um Outfits, Brillen
oder Frisuren anzuprobieren.
</p>
<p class="mt-3 flex items-start gap-2 text-xs text-muted-foreground">
<Info size={14} weight="regular" class="mt-0.5 flex-shrink-0" />
<span>
Pro Bild entscheidest du mit dem "KI darf nutzen"-Schalter, ob es an den Bildgenerator
gesendet werden darf. Ohne diesen Schalter bleibt das Bild nur für dich sichtbar.
</span>
</p>
</section>
{#if uploadError}
<div
class="rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
role="alert"
>
{uploadError}
</div>
{/if}
<!-- Primary slots -->
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<MeImageSlotCard
title="Gesicht"
kind="face"
slot="face-ref"
image={faceImage}
emptyLabel="Porträt hochladen"
emptyHint="Kopf + Schulter, möglichst neutrale Beleuchtung"
{uploading}
onFiles={(files, kind, slot) => ingestFiles(files, kind, slot)}
onToggleAi={handleToggleAi}
onDelete={handleDelete}
/>
<MeImageSlotCard
title="Ganzkörper"
kind="fullbody"
slot="body-ref"
image={bodyImage}
emptyLabel="Ganzkörperbild hochladen"
emptyHint="Stehend, freier Hintergrund, gut erkennbare Körperhaltung"
{uploading}
onFiles={(files, kind, slot) => ingestFiles(files, kind, slot)}
onToggleAi={handleToggleAi}
onDelete={handleDelete}
/>
</section>
<!-- Additional references -->
<section>
<header class="mb-3 flex items-baseline justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Weitere Bilder
</h3>
{#if extraImages.length > 0}
<span class="text-xs text-muted-foreground">
{extraImages.length}
{extraImages.length === 1 ? 'Bild' : 'Bilder'}
</span>
{/if}
</header>
{#if extraImages.length > 0}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each extraImages as img (img.id)}
<MeImageTile
image={img}
primarySlotForKind={primarySlotForKind(img.kind)}
onToggleAi={(v) => handleToggleAi(img.id, v)}
onTogglePrimary={() => handleTogglePrimary(img)}
onDelete={() => handleDelete(img.id)}
/>
{/each}
</div>
{/if}
<div class="mt-3">
<MeImageUploadZone
variant="compact"
label="Weitere Referenzen hochladen"
hint="z.B. andere Posen, Outfits, Hände — standardmäßig als „Referenz“ markiert"
disabled={uploading}
onFiles={(files) => ingestFiles(files, 'reference')}
/>
</div>
</section>
</div>

View file

@ -0,0 +1,61 @@
/**
* Client for `POST /api/v1/profile/me-images/upload` the M1 endpoint
* that wraps mana-media (CAS dedup + thumbnails) with auth.
*
* Returns what the Dexie row needs: mediaId, storagePath, publicUrl,
* thumbnailUrl. Dimensions are read client-side so the call site can
* stamp width/height on the LocalMeImage without waiting for
* mana-media's async processing pass.
*/
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
export interface UploadMeImageResult {
mediaId: string;
storagePath: string;
publicUrl: string;
thumbnailUrl?: string;
}
export async function uploadMeImageFile(file: File): Promise<UploadMeImageResult> {
const token = await authStore.getValidToken();
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${getManaApiUrl()}/api/v1/profile/me-images/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error(body.error || `Upload failed (${response.status})`);
}
return response.json() as Promise<UploadMeImageResult>;
}
/**
* Read the natural dimensions of an image file client-side. mana-media
* also reports dimensions post-processing, but we want them synchronously
* so the Dexie row lands with `width` and `height` populated on first
* write that lets the UI pick the right aspect-ratio tile immediately
* instead of re-flowing once the server catches up.
*/
export function readImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
}

View file

@ -0,0 +1,103 @@
<!--
Primary-slot card for Face / Fullbody. When the slot is filled it
renders a big preview with the slot holder's controls (AI toggle,
"neues Bild setzen", delete). When empty it collapses to a drop-zone.
Replacing the slot is a single gesture: dropping a file on a filled
card uploads, creates a new meImage with the slot's kind, and claims
the slot — the previous holder stays in the grid below (minus its
primary-star), so the user can delete or re-promote if they prefer.
-->
<script lang="ts">
import { Robot, Trash } from '@mana/shared-icons';
import { Toggle } from '@mana/shared-ui';
import MeImageUploadZone from './MeImageUploadZone.svelte';
import type { MeImage, MeImageKind, MeImagePrimarySlot } from '../types';
interface Props {
title: string;
kind: MeImageKind;
slot: MeImagePrimarySlot;
image: MeImage | null;
emptyLabel?: string;
emptyHint?: string;
uploading?: boolean;
onFiles: (files: File[], kind: MeImageKind, slot: MeImagePrimarySlot) => void;
onToggleAi: (id: string, enabled: boolean) => void;
onDelete: (id: string) => void;
}
let {
title,
kind,
slot,
image,
emptyLabel,
emptyHint,
uploading = false,
onFiles,
onToggleAi,
onDelete,
}: Props = $props();
</script>
<article class="rounded-2xl border border-border bg-card p-4 shadow-sm">
<header class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-foreground">{title}</h3>
{#if image}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
Primär
</span>
{/if}
</header>
{#if image}
<div class="relative overflow-hidden rounded-xl bg-muted">
<img
src={image.publicUrl}
alt={image.label ?? title}
class="aspect-[4/5] w-full object-cover"
/>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<label class="flex items-center gap-2 text-xs text-foreground">
<Robot size={14} weight="regular" class="text-muted-foreground" />
<span>KI darf nutzen</span>
<Toggle
isOn={image.usage.aiReference}
onToggle={(v) => onToggleAi(image.id, v)}
size="sm"
/>
</label>
<button
type="button"
onclick={() => onDelete(image.id)}
aria-label="Bild löschen"
title="Bild löschen"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash size={15} weight="regular" />
</button>
</div>
<!-- Compact "replace" zone -->
<div class="mt-3">
<MeImageUploadZone
variant="compact"
label="Neues Bild setzen"
hint="Ersetzt das aktuelle Primärbild"
disabled={uploading}
onFiles={(files) => onFiles(files, kind, slot)}
/>
</div>
{:else}
<MeImageUploadZone
variant="large"
label={emptyLabel ?? `${title} hochladen`}
hint={emptyHint ?? 'Ziehe ein Bild hierher oder klicke'}
disabled={uploading}
onFiles={(files) => onFiles(files, kind, slot)}
/>
{/if}
</article>

View file

@ -0,0 +1,87 @@
<!--
Grid tile for a single me-image. Shows thumbnail, kind badge, and
the three hot controls: AI opt-in toggle, primary-star toggle, and
delete. The parent owns the store-write callbacks so this component
stays presentational.
-->
<script lang="ts">
import { Star, Trash, Robot } from '@mana/shared-icons';
import { Toggle } from '@mana/shared-ui';
import type { MeImage, MeImagePrimarySlot } from '../types';
interface Props {
image: MeImage;
primarySlotForKind: MeImagePrimarySlot | null;
onToggleAi: (enabled: boolean) => void;
onTogglePrimary: () => void;
onDelete: () => void;
}
let { image, primarySlotForKind, onToggleAi, onTogglePrimary, onDelete }: Props = $props();
const KIND_LABELS: Record<string, string> = {
face: 'Gesicht',
fullbody: 'Ganzkörper',
halfbody: 'Halbkörper',
hands: 'Hände',
reference: 'Referenz',
};
const isPrimary = $derived(image.primaryFor !== null && image.primaryFor !== undefined);
const canBePrimary = $derived(primarySlotForKind !== null);
</script>
<article
class="group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md"
>
<!-- Thumbnail -->
<div class="relative aspect-square bg-muted">
{#if image.thumbnailUrl || image.publicUrl}
<img
src={image.thumbnailUrl ?? image.publicUrl}
alt={image.label ?? KIND_LABELS[image.kind]}
class="h-full w-full object-cover"
loading="lazy"
/>
{/if}
<!-- Kind badge -->
<span
class="absolute left-2 top-2 rounded-md bg-background/90 px-2 py-0.5 text-xs font-medium text-foreground shadow-sm backdrop-blur-sm"
>
{KIND_LABELS[image.kind] ?? image.kind}
</span>
<!-- Primary star (top-right) -->
{#if canBePrimary}
<button
type="button"
onclick={onTogglePrimary}
aria-label={isPrimary ? 'Primär aufheben' : 'Als primär setzen'}
title={isPrimary ? 'Primär aufheben' : 'Als primär setzen'}
class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-background
{isPrimary ? 'text-primary' : 'text-muted-foreground'}"
>
<Star size={16} weight={isPrimary ? 'fill' : 'regular'} />
</button>
{/if}
</div>
<!-- Footer: AI opt-in + delete -->
<div class="flex items-center justify-between gap-2 px-3 py-2">
<label class="flex min-w-0 flex-1 items-center gap-2 text-xs text-foreground">
<Robot size={14} weight="regular" class="flex-shrink-0 text-muted-foreground" />
<span class="truncate">KI darf nutzen</span>
<Toggle isOn={image.usage.aiReference} onToggle={(v) => onToggleAi(v)} size="sm" />
</label>
<button
type="button"
onclick={onDelete}
aria-label="Bild löschen"
title="Bild löschen"
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash size={14} weight="regular" />
</button>
</div>
</article>

View file

@ -0,0 +1,89 @@
<!--
Drop-zone / file picker for me-images. Decoupled from any Dexie
logic — just hands back File[] when the user drops or picks. The
parent decides what `kind` to stamp and whether to claim a primary
slot with the result.
-->
<script lang="ts">
import { UploadSimple } from '@mana/shared-icons';
interface Props {
variant?: 'large' | 'compact';
label?: string;
hint?: string;
disabled?: boolean;
onFiles: (files: File[]) => void;
}
let {
variant = 'compact',
label = 'Bilder hochladen',
hint = 'Ziehe Dateien hierher oder klicke',
disabled = false,
onFiles,
}: Props = $props();
let dragActive = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
function isFileDrag(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
function handleDrop(e: DragEvent) {
if (disabled || !isFileDrag(e)) return;
e.preventDefault();
dragActive = false;
const files = Array.from(e.dataTransfer?.files ?? []).filter((f) =>
f.type.startsWith('image/')
);
if (files.length > 0) onFiles(files);
}
function handleDragOver(e: DragEvent) {
if (disabled || !isFileDrag(e)) return;
e.preventDefault();
dragActive = true;
}
function handleDragLeave() {
dragActive = false;
}
function handlePick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/'));
if (files.length > 0) onFiles(files);
input.value = '';
}
</script>
<button
type="button"
{disabled}
onclick={() => fileInput?.click()}
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
class="group relative flex w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition-colors
{variant === 'large' ? 'min-h-[220px] p-6' : 'min-h-[120px] p-4'}
{dragActive
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-muted-foreground hover:border-primary/60 hover:text-foreground'}
{disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
>
<UploadSimple size={variant === 'large' ? 32 : 22} weight="regular" />
<span class="text-sm font-medium">{label}</span>
{#if hint}
<span class="text-xs opacity-70">{hint}</span>
{/if}
</button>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={handlePick}
/>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { RoutePage } from '$lib/components/shell';
import MeImagesView from '$lib/modules/profile/MeImagesView.svelte';
</script>
<svelte:head>
<title>Meine Bilder · Mana</title>
</svelte:head>
<RoutePage appId="profile" backHref="/profile">
<MeImagesView />
</RoutePage>

View file

@ -58,7 +58,7 @@ Jedes `meImage` hat ein `usage.aiReference: boolean` Flag. Default beim Upload:
```
┌─ Client (SvelteKit) ────────────────────────────────────┐
│ /settings/me-images (Upload + Toggles) │
│ /profile/me-images (Upload + Toggles) │
│ picture/GeneratorForm (Reference-Picker) │
│ Dexie: meImages (encrypted label/tags/kind) │
└──────┬──────────────────────────────────────────────────┘
@ -205,7 +205,7 @@ Python/FastAPI-Seite bekommt einen `POST /edit` Endpoint, der IP-Adapter oder Pu
## UI: zwei Touchpoints
### 1. `/settings/me-images` (neu)
### 1. `/profile/me-images` (neu)
- 2 prominente Slots oben: **Gesicht** (quadratisch, 512×512 empfohlen) und **Ganzkörper** (portrait, min 1024 hoch)
- Darunter Grid für zusätzliche Referenzen (Drag-and-Drop, Multi-Select-Upload — Pattern aus `picture/ListView.svelte:165-217` klauen)
@ -295,7 +295,7 @@ Soft-first/Hard-follow-up-Regel (siehe Memory):
- [ ] Sync-Schema registrieren
- [ ] Upload-Wrapper nutzt bestehenden `picture/upload`-Endpoint mit `app=me` (neuer Bucket `me-storage` in MinIO)
- **M2 — UI Route `/settings/me-images`** (~1 Tag)
- **M2 — UI Route `/profile/me-images`** (~1 Tag)
- [ ] Route + ModuleShell-Wrapping (wie andere Settings-Routen)
- [ ] Slot-Komponenten für Face/Fullbody, Grid für Reste
- [ ] Drag-and-Drop-Upload + Multi-File
@ -342,7 +342,7 @@ Soft-first/Hard-follow-up-Regel (siehe Memory):
3. **OpenAI Ref-Image-Format**: Original-Format durchreichen (PNG/JPG/WEBP — OpenAI akzeptiert alle). Keine Server-Konvertierung.
4. **Credit-Kosten für Multi-Ref-Edits**: identisch zu `/generate`, pro Output-Bild, unabhängig von Reference-Anzahl.
5. **`profile.aiUsesReferenceImages`-Default**: `true` (globaler Panic-Kill-Switch; Pro-Bild-Opt-in ist die eigentliche Hürde).
6. **Alter Avatar-Upload-Pfad**: bleibt in M1 unangetastet; M2 biegt `EditProfileModal` auf `/settings/me-images` um und räumt den toten Endpoint-Call weg.
6. **Alter Avatar-Upload-Pfad**: bleibt in M1 unangetastet; M2 biegt `EditProfileModal` auf `/profile/me-images` um und räumt den toten Endpoint-Call weg.
## Verweise