mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 09:06:42 +02:00
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:
parent
bb8e7c207e
commit
aeba23f772
6 changed files with 305 additions and 59 deletions
|
|
@ -10,6 +10,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, UserCircle } from '@mana/shared-icons';
|
import { Check, UserCircle } from '@mana/shared-icons';
|
||||||
import { useReferenceImages } from '$lib/modules/profile/queries';
|
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';
|
import type { MeImage } from '$lib/modules/profile/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -31,6 +33,29 @@
|
||||||
reference: 'Referenz',
|
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 {
|
function isSelected(id: string): boolean {
|
||||||
return selectedIds.includes(id);
|
return selectedIds.includes(id);
|
||||||
}
|
}
|
||||||
|
|
@ -55,19 +80,38 @@
|
||||||
{#if loading && referenceImages.length === 0}
|
{#if loading && referenceImages.length === 0}
|
||||||
<p class="text-xs text-muted-foreground">Lade Referenz-Bilder…</p>
|
<p class="text-xs text-muted-foreground">Lade Referenz-Bilder…</p>
|
||||||
{:else if referenceImages.length === 0}
|
{:else if referenceImages.length === 0}
|
||||||
<div
|
<div class="space-y-2 rounded-md border border-dashed border-border bg-background/50 p-3">
|
||||||
class="flex items-start gap-3 rounded-md border border-dashed border-border bg-background/50 p-3 text-xs text-muted-foreground"
|
<div class="flex items-start gap-3 text-xs text-muted-foreground">
|
||||||
>
|
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
|
||||||
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
|
<div class="space-y-1">
|
||||||
<div class="space-y-1">
|
<p class="text-foreground">Noch keine Referenzbilder.</p>
|
||||||
<p class="text-foreground">Noch keine Referenzbilder freigegeben.</p>
|
<p>
|
||||||
<p>
|
Lade ein Bild hoch — es wird automatisch für KI-Referenzen freigegeben und erscheint hier
|
||||||
Lade ein Gesichts- oder Ganzkörperbild hoch und aktiviere "KI darf nutzen" unter
|
in der Auswahl.
|
||||||
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
|
</p>
|
||||||
Meine Bilder
|
</div>
|
||||||
</a>.
|
|
||||||
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import MeImageUploadZone from './components/MeImageUploadZone.svelte';
|
import MeImageUploadZone from './components/MeImageUploadZone.svelte';
|
||||||
import { useAllMeImages, useImageByPrimary } from './queries';
|
import { useAllMeImages, useImageByPrimary } from './queries';
|
||||||
import { meImagesStore } from './stores/me-images.svelte';
|
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 { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar';
|
||||||
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
|
import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types';
|
||||||
|
|
||||||
|
|
@ -70,22 +70,7 @@
|
||||||
uploadError = null;
|
uploadError = null;
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const dims = (await readImageDimensions(file)) ?? { width: 0, height: 0 };
|
await ingestMeImageFile(file, { kind, claimSlot });
|
||||||
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) {
|
} catch (err) {
|
||||||
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
import { getManaApiUrl } from '$lib/api/config';
|
import { getManaApiUrl } from '$lib/api/config';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { meImagesStore } from '../stores/me-images.svelte';
|
||||||
|
import type { MeImage, MeImageKind, MeImagePrimarySlot } from '../types';
|
||||||
|
|
||||||
export interface UploadMeImageResult {
|
export interface UploadMeImageResult {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
|
|
@ -59,3 +61,48 @@ export function readImageDimensions(file: File): Promise<{ width: number; height
|
||||||
img.src = url;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
the same way picture/ListView does.
|
the same way picture/ListView does.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<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 GridView from './views/GridView.svelte';
|
||||||
import OutfitsView from './views/OutfitsView.svelte';
|
import OutfitsView from './views/OutfitsView.svelte';
|
||||||
|
|
||||||
|
|
@ -23,6 +27,29 @@
|
||||||
{ key: 'garments', label: 'Kleidung' },
|
{ key: 'garments', label: 'Kleidung' },
|
||||||
{ key: 'outfits', label: 'Outfits' },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<div class="wardrobe-root">
|
<div class="wardrobe-root">
|
||||||
|
|
@ -40,6 +67,36 @@
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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">
|
<div class="wardrobe-body">
|
||||||
{#if activeTab === 'garments'}
|
{#if activeTab === 'garments'}
|
||||||
<GridView />
|
<GridView />
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
|
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
|
||||||
import { getActiveSpace } from '$lib/data/scope';
|
import { getActiveSpace } from '$lib/data/scope';
|
||||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
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 { isAccessoryGarment, runGarmentTryOn } from '../api/try-on';
|
||||||
import type { Garment } from '../types';
|
import type { Garment } from '../types';
|
||||||
|
|
||||||
|
|
@ -40,6 +42,9 @@
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let lastResultUrl = $state<string | null>(null);
|
let lastResultUrl = $state<string | null>(null);
|
||||||
|
|
||||||
|
let uploadingRef = $state(false);
|
||||||
|
let uploadRefError = $state<string | null>(null);
|
||||||
|
|
||||||
const estimatedCredits = 10;
|
const estimatedCredits = 10;
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
|
|
@ -60,6 +65,23 @@
|
||||||
running = false;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if !hasPhoto}
|
{#if !hasPhoto}
|
||||||
|
|
@ -67,22 +89,53 @@
|
||||||
Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.
|
Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.
|
||||||
</p>
|
</p>
|
||||||
{:else if missingFace || missingBody}
|
{:else if missingFace || missingBody}
|
||||||
<div
|
<div class="space-y-3 rounded-xl border border-dashed border-border bg-background/50 p-4">
|
||||||
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
|
<div class="flex items-start gap-3 text-sm">
|
||||||
>
|
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
|
||||||
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
|
<div class="space-y-1">
|
||||||
<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-foreground">Lade erst Referenzbilder hoch, um das Stück an dir zu sehen.</p>
|
<p class="text-xs text-muted-foreground">
|
||||||
<p class="text-xs">
|
{accessoryOnly
|
||||||
Solo-Try-On braucht ein {accessoryOnly
|
? 'Ein Gesichtsbild reicht — das Stück wird darauf montiert.'
|
||||||
? 'Gesichtsbild'
|
: 'Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.'}
|
||||||
: 'Gesichts- und ein Ganzkörperbild'}
|
</p>
|
||||||
in diesem Space. Öffne dafür
|
</div>
|
||||||
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
|
|
||||||
Meine Bilder
|
|
||||||
</a>.
|
|
||||||
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
|
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
|
||||||
import { getActiveSpace } from '$lib/data/scope';
|
import { getActiveSpace } from '$lib/data/scope';
|
||||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
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 { isAccessoryOnlyOutfit, runOutfitTryOn } from '../api/try-on';
|
||||||
import { CATEGORY_LABELS_SINGULAR } from '../constants';
|
import { CATEGORY_LABELS_SINGULAR } from '../constants';
|
||||||
import type { Garment, Outfit } from '../types';
|
import type { Garment, Outfit } from '../types';
|
||||||
|
|
@ -35,6 +37,13 @@
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let error = $state<string | null>(null);
|
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
|
// Rough credit estimate — mirrors the server tariff from the M3 plan
|
||||||
// (3 low / 10 medium / 25 high; we default to medium). Shown on the
|
// (3 low / 10 medium / 25 high; we default to medium). Shown on the
|
||||||
// button so the user knows the hit before clicking.
|
// button so the user knows the hit before clicking.
|
||||||
|
|
@ -57,25 +66,76 @@
|
||||||
running = false;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if missingFace || missingBody}
|
{#if missingFace || missingBody}
|
||||||
<div
|
<div class="space-y-3 rounded-xl border border-dashed border-border bg-background/50 p-4">
|
||||||
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
|
<div class="flex items-start gap-3 text-sm">
|
||||||
>
|
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
|
||||||
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
|
<div class="space-y-1">
|
||||||
<div class="space-y-1">
|
<p class="font-medium text-foreground">Für Try-On brauchen wir dich auf Bild.</p>
|
||||||
<p class="text-foreground">Lade erst Referenzbilder hoch, um dich im Outfit zu sehen.</p>
|
<p class="text-xs text-muted-foreground">
|
||||||
<p class="text-xs">
|
{accessoryOnly
|
||||||
Try-On braucht mindestens ein {accessoryOnly
|
? 'Ein Gesichtsbild reicht — der Rest bleibt wie auf deinem Foto.'
|
||||||
? 'Gesichtsbild'
|
: 'Ein Gesichts- und ein Ganzkörperbild. Beide werden nur für deine eigenen Generierungen genutzt.'}
|
||||||
: 'Gesichts- und ein Ganzkörperbild'}
|
</p>
|
||||||
in diesem Space. Öffne dafür
|
</div>
|
||||||
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
|
|
||||||
Meine Bilder
|
|
||||||
</a>.
|
|
||||||
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue