diff --git a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte index 6e5d0468e..5da06a545 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte @@ -211,6 +211,12 @@
+ @@ -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)); diff --git a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte new file mode 100644 index 000000000..cd07eb571 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte @@ -0,0 +1,194 @@ + + + +
+ +
+
+ +

Meine Bilder

+
+

+ 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. +

+

+ + + 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. + +

+
+ + {#if uploadError} + + {/if} + + +
+ ingestFiles(files, kind, slot)} + onToggleAi={handleToggleAi} + onDelete={handleDelete} + /> + ingestFiles(files, kind, slot)} + onToggleAi={handleToggleAi} + onDelete={handleDelete} + /> +
+ + +
+
+

+ Weitere Bilder +

+ {#if extraImages.length > 0} + + {extraImages.length} + {extraImages.length === 1 ? 'Bild' : 'Bilder'} + + {/if} +
+ + {#if extraImages.length > 0} +
+ {#each extraImages as img (img.id)} + handleToggleAi(img.id, v)} + onTogglePrimary={() => handleTogglePrimary(img)} + onDelete={() => handleDelete(img.id)} + /> + {/each} +
+ {/if} + +
+ ingestFiles(files, 'reference')} + /> +
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts b/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts new file mode 100644 index 000000000..de8aa3892 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts @@ -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 { + 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; +} + +/** + * 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; + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/profile/components/MeImageSlotCard.svelte b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageSlotCard.svelte new file mode 100644 index 000000000..21a0b211f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageSlotCard.svelte @@ -0,0 +1,103 @@ + + + +
+
+

{title}

+ {#if image} + + Primär + + {/if} +
+ + {#if image} +
+ {image.label +
+ +
+ + +
+ + +
+ onFiles(files, kind, slot)} + /> +
+ {:else} + onFiles(files, kind, slot)} + /> + {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/profile/components/MeImageTile.svelte b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageTile.svelte new file mode 100644 index 000000000..9301bc438 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageTile.svelte @@ -0,0 +1,87 @@ + + + +
+ +
+ {#if image.thumbnailUrl || image.publicUrl} + {image.label + {/if} + + + + {KIND_LABELS[image.kind] ?? image.kind} + + + + {#if canBePrimary} + + {/if} +
+ + +
+ + +
+
diff --git a/apps/mana/apps/web/src/lib/modules/profile/components/MeImageUploadZone.svelte b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageUploadZone.svelte new file mode 100644 index 000000000..eb9437322 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/components/MeImageUploadZone.svelte @@ -0,0 +1,89 @@ + + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/profile/me-images/+page.svelte b/apps/mana/apps/web/src/routes/(app)/profile/me-images/+page.svelte new file mode 100644 index 000000000..425a3a4f4 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/profile/me-images/+page.svelte @@ -0,0 +1,12 @@ + + + + Meine Bilder · Mana + + + + + diff --git a/docs/plans/me-images-and-reference-generation.md b/docs/plans/me-images-and-reference-generation.md index 51fc4278e..d93c36045 100644 --- a/docs/plans/me-images-and-reference-generation.md +++ b/docs/plans/me-images-and-reference-generation.md @@ -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