feat(profile,wardrobe,picture): inline me-image upload instead of deep-link

Missing-reference states in wardrobe and picture used to render a deep-
link to /profile/me-images and nothing else. Leaving an outfit detail
(or worse, a workbench card) to upload a face photo and coming back is
jarring, and the cross-navigation loses tab state in the carousel.

Switch to inline upload at the three existing gate-points. Each site
calls a new pipeline helper that encapsulates the orchestration the
profile page's ingestFiles() loop already did — kept minimal: no new
components, no requirement-mode abstraction, no shared "gate" wrapper.
If a fourth call-site appears (memoro avatar, MCP me.* tool) we can
promote to a shared component then.

- profile/api/me-images.ts: new ingestMeImageFile(file, { kind,
  claimSlot?, autoAiReference? }) that runs readImageDimensions →
  uploadMeImageFile → meImagesStore.createMeImage → optional
  setPrimary. MeImagesView.ingestFiles now delegates to it (same
  behaviour, 30 fewer lines).
- wardrobe/ListView: face-ref banner with MeImageUploadZone when
  useImageByPrimary('face-ref') is empty. Banner auto-hides via
  liveQuery once the slot is claimed. Body-ref is deferred to the
  detail button to avoid a two-upload wall on first open.
- wardrobe/TryOnButton + GarmentTryOnButton: the missing-refs block
  now renders one MeImageUploadZone per missing slot (face and/or
  body depending on accessoryOnly), claiming the right primary slot
  on drop. The /profile/me-images link stays as a secondary "manage"
  CTA for the full pool.
- picture/ReferenceImagePicker: empty-pool state swaps the deep-link
  for an inline upload with autoAiReference=true — the user entered
  this picker explicitly to feed references into the generator, so
  opting in here is contextual consent. Everywhere else,
  /profile/me-images's opt-in-per-image remains the default.

No schema changes, no new dependencies. Types, svelte-check, and both
theme validators pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 23:08:13 +02:00
parent bb8e7c207e
commit aeba23f772
6 changed files with 305 additions and 59 deletions

View file

@ -10,6 +10,8 @@
<script lang="ts">
import { Check, UserCircle } from '@mana/shared-icons';
import { useReferenceImages } from '$lib/modules/profile/queries';
import MeImageUploadZone from '$lib/modules/profile/components/MeImageUploadZone.svelte';
import { ingestMeImageFile } from '$lib/modules/profile/api/me-images';
import type { MeImage } from '$lib/modules/profile/types';
interface Props {
@ -31,6 +33,29 @@
reference: 'Referenz',
};
// Inline upload state for the empty-pool case. Uploads here opt the
// image in for AI reference use by default (autoAiReference=true) —
// the user came into this picker explicitly to feed something into
// the generator, so the consent is contextual. /profile/me-images
// keeps opt-in-per-image as the default everywhere else.
let uploading = $state(false);
let uploadError = $state<string | null>(null);
async function handleUpload(files: File[]) {
if (files.length === 0) return;
uploading = true;
uploadError = null;
try {
for (const file of files) {
await ingestMeImageFile(file, { kind: 'reference', autoAiReference: true });
}
} catch (err) {
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploading = false;
}
}
function isSelected(id: string): boolean {
return selectedIds.includes(id);
}
@ -55,19 +80,38 @@
{#if loading && referenceImages.length === 0}
<p class="text-xs text-muted-foreground">Lade Referenz-Bilder…</p>
{:else if referenceImages.length === 0}
<div
class="flex items-start gap-3 rounded-md border border-dashed border-border bg-background/50 p-3 text-xs text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-foreground">Noch keine Referenzbilder freigegeben.</p>
<p>
Lade ein Gesichts- oder Ganzkörperbild hoch und aktiviere "KI darf nutzen" unter
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
<div class="space-y-2 rounded-md border border-dashed border-border bg-background/50 p-3">
<div class="flex items-start gap-3 text-xs text-muted-foreground">
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-foreground">Noch keine Referenzbilder.</p>
<p>
Lade ein Bild hoch — es wird automatisch für KI-Referenzen freigegeben und erscheint hier
in der Auswahl.
</p>
</div>
</div>
<MeImageUploadZone
variant="compact"
label="Referenzbild hochladen"
hint="Gesicht, Ganzkörper, Hände — was du in Generierungen sehen willst"
disabled={uploading}
onFiles={handleUpload}
/>
{#if uploadError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
{uploadError}
</div>
{/if}
<p class="text-xs text-muted-foreground">
Mehrere Bilder + AI-Opt-in pro Bild:
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
{:else}
<div class="space-y-2">

View file

@ -22,7 +22,7 @@
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 { ingestMeImageFile } from './api/me-images';
import { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar';
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
@ -70,22 +70,7 @@
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);
}
await ingestMeImageFile(file, { kind, claimSlot });
}
} catch (err) {
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';

View file

@ -10,6 +10,8 @@
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
import { meImagesStore } from '../stores/me-images.svelte';
import type { MeImage, MeImageKind, MeImagePrimarySlot } from '../types';
export interface UploadMeImageResult {
mediaId: string;
@ -59,3 +61,48 @@ export function readImageDimensions(file: File): Promise<{ width: number; height
img.src = url;
});
}
/**
* Full ingest pipeline for a single File: read dimensions upload to the
* auth-protected endpoint write a LocalMeImage through the store (which
* handles encryption + sync) optionally claim a primary slot.
*
* This is the exact sequence `MeImagesView.ingestFiles()` runs for every
* file it receives. Extracted so in-context upload cards in other modules
* (wardrobe try-on, picture reference picker) can trigger the same write
* without duplicating the orchestration.
*
* `autoAiReference: true` flips `usage.aiReference=true` on creation
* only intended for call-sites where the upload context itself is the
* consent (e.g. "upload a reference for the picture generator"). The
* default remains opt-in-per-image, matching the /profile/me-images
* flow.
*/
export async function ingestMeImageFile(
file: File,
options: {
kind: MeImageKind;
claimSlot?: MeImagePrimarySlot;
autoAiReference?: boolean;
}
): Promise<MeImage> {
const dims = (await readImageDimensions(file)) ?? { width: 0, height: 0 };
const uploaded = await uploadMeImageFile(file);
const created = await meImagesStore.createMeImage({
kind: options.kind,
mediaId: uploaded.mediaId,
storagePath: uploaded.storagePath,
publicUrl: uploaded.publicUrl,
thumbnailUrl: uploaded.thumbnailUrl ?? null,
width: dims.width,
height: dims.height,
usage: options.autoAiReference ? { aiReference: true } : undefined,
});
if (options.claimSlot) {
// setPrimary transactionally clears any previous slot-holder, so an
// old face/body automatically drops into the grid on the profile
// page without a separate cleanup step.
await meImagesStore.setPrimary(created.id, options.claimSlot);
}
return created;
}

View file

@ -12,6 +12,10 @@
the same way picture/ListView does.
-->
<script lang="ts">
import { 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 GridView from './views/GridView.svelte';
import OutfitsView from './views/OutfitsView.svelte';
@ -23,6 +27,29 @@
{ key: 'garments', label: 'Kleidung' },
{ key: 'outfits', label: 'Outfits' },
];
// Face-ref banner: the minimum requirement for *any* wardrobe try-on
// (outfit or solo-garment, accessory or full). Body-ref is asked for
// later in the detail flow — keeping the top-level banner to one slot
// avoids a two-upload wall on first open.
const face$ = useImageByPrimary('face-ref');
const face = $derived(face$.value);
let uploadingFace = $state(false);
let faceUploadError = $state<string | null>(null);
async function handleFaceUpload(files: File[]) {
if (files.length === 0) return;
uploadingFace = true;
faceUploadError = null;
try {
await ingestMeImageFile(files[0], { kind: 'face', claimSlot: 'face-ref' });
} catch (err) {
faceUploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploadingFace = false;
}
}
</script>
<div class="wardrobe-root">
@ -40,6 +67,36 @@
{/each}
</nav>
{#if !face$.loading && !face}
<div class="space-y-3 rounded-xl border border-dashed border-border bg-background/50 p-4">
<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 Try-On Kleidung an dir visualisieren kann. Das Bild
bleibt lokal und wird nur für deine eigenen Generierungen genutzt.
</p>
</div>
</div>
<MeImageUploadZone
variant="compact"
label="Gesichtsbild hochladen"
hint="Kopf + Schulter, möglichst neutrale Beleuchtung"
disabled={uploadingFace}
onFiles={handleFaceUpload}
/>
{#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}
</div>
{/if}
<div class="wardrobe-body">
{#if activeTab === 'garments'}
<GridView />

View file

@ -14,6 +14,8 @@
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
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 { isAccessoryGarment, runGarmentTryOn } from '../api/try-on';
import type { Garment } from '../types';
@ -40,6 +42,9 @@
let error = $state<string | null>(null);
let lastResultUrl = $state<string | null>(null);
let uploadingRef = $state(false);
let uploadRefError = $state<string | null>(null);
const estimatedCredits = 10;
async function handleClick() {
@ -60,6 +65,23 @@
running = false;
}
}
async function handleRefUpload(
files: File[],
kind: 'face' | 'fullbody',
slot: 'face-ref' | 'body-ref'
) {
if (files.length === 0) return;
uploadingRef = true;
uploadRefError = null;
try {
await ingestMeImageFile(files[0], { kind, claimSlot: slot });
} catch (err) {
uploadRefError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploadingRef = false;
}
}
</script>
{#if !hasPhoto}
@ -67,22 +89,53 @@
Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.
</p>
{:else if missingFace || missingBody}
<div
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="text-foreground">Lade erst Referenzbilder hoch, um das Stück an dir zu sehen.</p>
<p class="text-xs">
Solo-Try-On braucht ein {accessoryOnly
? 'Gesichtsbild'
: 'Gesichts- und ein Ganzkörperbild'}
in diesem Space. Öffne dafür
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
<div class="space-y-3 rounded-xl border border-dashed border-border bg-background/50 p-4">
<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">Für Solo-Try-On brauchen wir dich auf Bild.</p>
<p class="text-xs text-muted-foreground">
{accessoryOnly
? 'Ein Gesichtsbild reicht — das Stück wird darauf montiert.'
: 'Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.'}
</p>
</div>
</div>
{#if missingFace}
<MeImageUploadZone
variant="compact"
label="Gesichtsbild hochladen"
hint="Kopf + Schulter, möglichst neutrale Beleuchtung"
disabled={uploadingRef}
onFiles={(files) => handleRefUpload(files, 'face', 'face-ref')}
/>
{/if}
{#if missingBody}
<MeImageUploadZone
variant="compact"
label="Ganzkörperbild hochladen"
hint="Stehend, freier Hintergrund, gut erkennbare Haltung"
disabled={uploadingRef}
onFiles={(files) => handleRefUpload(files, 'fullbody', 'body-ref')}
/>
{/if}
{#if uploadRefError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
{uploadRefError}
</div>
{/if}
<p class="text-xs text-muted-foreground">
Weitere Referenzen oder AI-Opt-ins pro Bild:
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
{:else}
<div class="space-y-2">

View file

@ -8,6 +8,8 @@
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
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 { isAccessoryOnlyOutfit, runOutfitTryOn } from '../api/try-on';
import { CATEGORY_LABELS_SINGULAR } from '../constants';
import type { Garment, Outfit } from '../types';
@ -35,6 +37,13 @@
let running = $state(false);
let error = $state<string | null>(null);
// Inline ref-upload state. Deliberately local — the missing-ref
// experience lives right here instead of deep-linking to /profile/
// me-images, because leaving the outfit detail to upload a face photo
// and coming back is jarring (especially inside the workbench card).
let uploadingRef = $state(false);
let uploadRefError = $state<string | null>(null);
// Rough credit estimate — mirrors the server tariff from the M3 plan
// (3 low / 10 medium / 25 high; we default to medium). Shown on the
// button so the user knows the hit before clicking.
@ -57,25 +66,76 @@
running = false;
}
}
async function handleRefUpload(
files: File[],
kind: 'face' | 'fullbody',
slot: 'face-ref' | 'body-ref'
) {
if (files.length === 0) return;
uploadingRef = true;
uploadRefError = null;
try {
// Only take the first file — these slots are single-image.
await ingestMeImageFile(files[0], { kind, claimSlot: slot });
// face$ / body$ live-queries re-run automatically, so the
// missing-block disappears and the button becomes active.
} catch (err) {
uploadRefError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploadingRef = false;
}
}
</script>
{#if missingFace || missingBody}
<div
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="text-foreground">Lade erst Referenzbilder hoch, um dich im Outfit zu sehen.</p>
<p class="text-xs">
Try-On braucht mindestens ein {accessoryOnly
? 'Gesichtsbild'
: 'Gesichts- und ein Ganzkörperbild'}
in diesem Space. Öffne dafür
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
<div class="space-y-3 rounded-xl border border-dashed border-border bg-background/50 p-4">
<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">Für Try-On brauchen wir dich auf Bild.</p>
<p class="text-xs text-muted-foreground">
{accessoryOnly
? 'Ein Gesichtsbild reicht — der Rest bleibt wie auf deinem Foto.'
: 'Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.'}
</p>
</div>
</div>
{#if missingFace}
<MeImageUploadZone
variant="compact"
label="Gesichtsbild hochladen"
hint="Kopf + Schulter, möglichst neutrale Beleuchtung"
disabled={uploadingRef}
onFiles={(files) => handleRefUpload(files, 'face', 'face-ref')}
/>
{/if}
{#if missingBody}
<MeImageUploadZone
variant="compact"
label="Ganzkörperbild hochladen"
hint="Stehend, freier Hintergrund, gut erkennbare Haltung"
disabled={uploadingRef}
onFiles={(files) => handleRefUpload(files, 'fullbody', 'body-ref')}
/>
{/if}
{#if uploadRefError}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
role="alert"
>
{uploadRefError}
</div>
{/if}
<p class="text-xs text-muted-foreground">
Weitere Referenzen oder AI-Opt-ins pro Bild:
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
{:else}
<div class="space-y-2">