mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
a1caeaa7f3
commit
a64a7e39cf
8 changed files with 564 additions and 5 deletions
|
|
@ -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));
|
||||
|
|
|
|||
194
apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte
Normal file
194
apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte
Normal 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>
|
||||
61
apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts
Normal file
61
apps/mana/apps/web/src/lib/modules/profile/api/me-images.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue