mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +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">
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue