From 5a49bcbf02e4d97fcbec47b4be372f3cd369a457 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 18:37:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(wardrobe):=20garments=20UI=20=E2=80=94=20/?= =?UTF-8?q?wardrobe=20+=20/wardrobe/garment/[id]=20(M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 of docs/plans/wardrobe-module.md — the first interactive surface on top of the M1 data layer. Users can now upload photos, browse their garment grid filtered by category, and edit/archive/delete individual items. Outfits (M3) and Try-On (M4) are still placeholders. Route: - /wardrobe — grid view with active-space badge in the intro card (identical pattern to /profile/me-images since the pool IS per- space). Category tabs across the top: "Alle" + eleven categories with live counts. Dropping files while a category tab is active creates garments with that category preselected; dropping on "Alle" defaults to `other` and the user edits on the detail page. - /wardrobe/garment/[id] — detail view. Renders the primary photo + metadata card; a pencil toggles into GarmentForm for inline edit. Three actions: "Heute getragen" (bumps wearCount + stamps lastWornAt, prominent primary button), Archive, and Delete with confirm. The route wraps DetailGarmentView in `{#key id}` so navigating between different garments cleanly remounts the liveQuery + form state. Components: - CategoryTabs — horizontal pill row with per-category count badges. Stays compact on mobile via overflow-x-auto. - GarmentCard — tile with primary photo + name + brand + wear- count hint; click navigates to detail. - GarmentForm — inline edit sheet (name, category, brand, color, size, material, tags comma-separated, notes, price+currency). Comma→array for tags because that's how most users think about them; the store normalizes on save. - GridView — orchestrates queries, filter tabs, drop zone (reuses MeImageUploadZone from profile since it's already generic about what "files" mean), and the empty states (no garments at all vs. no garments in this category). Small conveniences: - api/upload.ts wraps the M1 POST /api/v1/wardrobe/garments/upload endpoint with fetchWithAuth; same shape as profile's me-images client (mediaId/storagePath/publicUrl/thumbnailUrl). - api/media-url.ts — tiny mediaId → URL resolver using the same inline PUBLIC_MANA_MEDIA_URL pattern wallpaper and invoices/ pdf/logo already use. Worth a shared helper later but premature while three call sites disagree on which variant to default to. - constants.ts — CATEGORY_ORDER / CATEGORY_LABELS plus OCCASION_LABELS and SEASON_LABELS for M3 to pick up. Svelte 5 note: GarmentForm's `$state(garment.xxx)` initializers trip the state_referenced_locally check, but the intent is correct — the parent uses `{#key id}` to remount on navigation, so the captures are a feature, not a bug. Suppressed per-line with `svelte-ignore` and a comment pointing at the remount mechanism. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/modules/wardrobe/ListView.svelte | 12 + .../src/lib/modules/wardrobe/api/media-url.ts | 26 ++ .../src/lib/modules/wardrobe/api/upload.ts | 35 +++ .../wardrobe/components/CategoryTabs.svelte | 48 ++++ .../wardrobe/components/GarmentCard.svelte | 54 ++++ .../wardrobe/components/GarmentForm.svelte | 265 ++++++++++++++++++ .../web/src/lib/modules/wardrobe/constants.ts | 82 ++++++ .../wardrobe/views/DetailGarmentView.svelte | 221 +++++++++++++++ .../modules/wardrobe/views/GridView.svelte | 152 ++++++++++ .../src/routes/(app)/wardrobe/+page.svelte | 12 + .../(app)/wardrobe/garment/[id]/+page.svelte | 19 ++ 11 files changed, 926 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte new file mode 100644 index 000000000..a696c0e87 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte @@ -0,0 +1,12 @@ + + + +
+ +
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts new file mode 100644 index 000000000..a84019fab --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts @@ -0,0 +1,26 @@ +/** + * Tiny resolver mediaId → mana-media URL. Each module that renders + * mana-media files keeps its own — consolidating into a shared helper + * is a future-optimization, not an M2 task. Mirrors the inline pattern + * in wallpaper and invoices/pdf/logo. + */ + +import { browser } from '$app/environment'; + +function mediaBaseUrl(): string { + if (browser) { + const injected = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }) + .__PUBLIC_MANA_MEDIA_URL__; + if (injected) return injected; + } + return import.meta.env.PUBLIC_MANA_MEDIA_URL ?? 'http://localhost:3015'; +} + +export function garmentPhotoUrl( + mediaId: string, + variant: 'original' | 'large' | 'medium' | 'thumb' = 'medium' +): string { + const base = mediaBaseUrl(); + if (variant === 'original') return `${base}/api/v1/media/${mediaId}/file`; + return `${base}/api/v1/media/${mediaId}/file/${variant}`; +} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts new file mode 100644 index 000000000..b96edf7af --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts @@ -0,0 +1,35 @@ +/** + * Client for `POST /api/v1/wardrobe/garments/upload` — the M1 endpoint + * that wraps mana-media with `app='wardrobe'` tagging. Mirror of + * `profile/api/me-images.ts` — same shape, different endpoint, so later + * generalization is a rename away. + */ + +import { getManaApiUrl } from '$lib/api/config'; +import { authStore } from '$lib/stores/auth.svelte'; + +export interface UploadGarmentResult { + mediaId: string; + storagePath: string; + publicUrl: string; + thumbnailUrl?: string; +} + +export async function uploadGarmentPhoto(file: File): Promise { + const token = await authStore.getValidToken(); + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${getManaApiUrl()}/api/v1/wardrobe/garments/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; +} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte new file mode 100644 index 000000000..3a7e9e88a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/components/CategoryTabs.svelte @@ -0,0 +1,48 @@ + + + +
+ + {#each CATEGORY_ORDER as category} + + {/each} +
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte new file mode 100644 index 000000000..9b5533bdd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentCard.svelte @@ -0,0 +1,54 @@ + + + + +
+ {#if primaryUrl} + {garment.name} + {/if} + + {CATEGORY_LABELS_SINGULAR[garment.category] ?? garment.category} + + {#if garment.wearCount && garment.wearCount > 0} + + {garment.wearCount}× + + {/if} +
+
+

{garment.name}

+ {#if garment.brand} +

{garment.brand}

+ {/if} +
+
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte new file mode 100644 index 000000000..1ef933b13 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/components/GarmentForm.svelte @@ -0,0 +1,265 @@ + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ + {#if error} + + {/if} + +
+ + {#if onCancel} + + {/if} +
+
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts new file mode 100644 index 000000000..612c370a9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts @@ -0,0 +1,82 @@ +/** + * Wardrobe module — display constants. + * + * CATEGORY_ORDER is the order the tabs and pickers render in; CATEGORY_LABELS + * is the DE display label per category id. OCCASION_LABELS + SEASON_LABELS + * are consumed by the M3 outfit composer. + */ + +import type { GarmentCategory, OutfitOccasion, OutfitSeason } from './types'; + +export const CATEGORY_ORDER: readonly GarmentCategory[] = [ + 'top', + 'bottom', + 'dress', + 'outerwear', + 'shoes', + 'bag', + 'accessory', + 'glasses', + 'jewelry', + 'hat', + 'other', +] as const; + +export const CATEGORY_LABELS: Record = { + top: 'Oberteile', + bottom: 'Hosen', + dress: 'Kleider', + outerwear: 'Jacken', + shoes: 'Schuhe', + bag: 'Taschen', + accessory: 'Accessoires', + glasses: 'Brillen', + jewelry: 'Schmuck', + hat: 'Kopfbedeckung', + other: 'Sonstiges', +}; + +export const CATEGORY_LABELS_SINGULAR: Record = { + top: 'Oberteil', + bottom: 'Hose', + dress: 'Kleid', + outerwear: 'Jacke', + shoes: 'Schuh', + bag: 'Tasche', + accessory: 'Accessoire', + glasses: 'Brille', + jewelry: 'Schmuck', + hat: 'Kopfbedeckung', + other: 'Item', +}; + +export const OCCASION_ORDER: readonly OutfitOccasion[] = [ + 'casual', + 'work', + 'formal', + 'workout', + 'date', + 'travel', + 'event', + 'sleep', + 'other', +] as const; + +export const OCCASION_LABELS: Record = { + casual: 'Casual', + work: 'Arbeit', + formal: 'Festlich', + workout: 'Sport', + date: 'Date', + travel: 'Reise', + event: 'Event', + sleep: 'Schlafanzug', + other: 'Sonstiges', +}; + +export const SEASON_LABELS: Record = { + spring: 'Frühling', + summer: 'Sommer', + autumn: 'Herbst', + winter: 'Winter', +}; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte new file mode 100644 index 000000000..5f9cb1bbd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailGarmentView.svelte @@ -0,0 +1,221 @@ + + + +
+ + + {#if !garment} + {#if garment$.loading} +

Lädt…

+ {:else} +
+

Nicht gefunden.

+

+ Das Kleidungsstück wurde gelöscht oder gehört zu einem anderen Space. +

+
+ {/if} + {:else} +
+ +
+ {#if garment.mediaIds[0]} + {garment.name} + {/if} +
+ + +
+ {#if editing} + (editing = false)} /> + {:else} +
+
+
+

{garment.name}

+

{CATEGORY_LABELS[garment.category]}

+
+ +
+ +
+ {#if garment.brand} +
+
Marke
+
{garment.brand}
+
+ {/if} + {#if garment.color} +
+
Farbe
+
{garment.color}
+
+ {/if} + {#if garment.size} +
+
Größe
+
{garment.size}
+
+ {/if} + {#if garment.material} +
+
Material
+
{garment.material}
+
+ {/if} + {#if garment.priceCents} +
+
Preis
+
+ {(garment.priceCents / 100).toFixed(2)} + {garment.currency ?? ''} +
+
+ {/if} + {#if garment.wearCount && garment.wearCount > 0} +
+
Getragen
+
+ {garment.wearCount}×{garment.lastWornAt + ? ` · zuletzt ${garment.lastWornAt}` + : ''} +
+
+ {/if} +
+ + {#if garment.tags.length > 0} +
+ {#each garment.tags as tag} + + {tag} + + {/each} +
+ {/if} + + {#if garment.notes} +

{garment.notes}

+ {/if} +
+ + + + + +
+ + +
+ {/if} +
+
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte new file mode 100644 index 000000000..be5873d0e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/views/GridView.svelte @@ -0,0 +1,152 @@ + + + +
+ +
+
+
+ +

Kleiderschrank

+
+ {#if activeSpace} + + {activeSpace.type === 'personal' ? 'Persönlich' : activeSpace.name} + + {/if} +
+

+ Fotografiere Kleidungsstücke und Accessoires, gruppiere sie in Outfits, und probiere sie mit + KI an dir selbst an. Du kannst sie später im Generator als Referenz nutzen. +

+

+ + + Aktive Kategorie bestimmt den Typ für neue Uploads — auf "Alle" landen sie als "{CATEGORY_LABELS_SINGULAR.other}" + und können auf der Detailseite umgestellt werden. + +

+
+ + + (activeTab = next)} /> + + {#if uploadError} + + {/if} + + + {#if filtered.length > 0} +
+ {#each filtered as g (g.id)} + + {/each} +
+ {:else if garments.length === 0} +
+

Noch nichts im Schrank.

+

+ Lade dein erstes Kleidungsstück hoch — unten auf "Hinzufügen" oder zieh eine Datei direkt in + die Zone. +

+
+ {:else} +
+

+ Keine Einträge unter {activeTab === 'all' ? 'Alle' : CATEGORY_LABELS[activeTab]}. +

+
+ {/if} + + + +
diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte new file mode 100644 index 000000000..206ba31b3 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte @@ -0,0 +1,12 @@ + + + + Kleiderschrank · Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte new file mode 100644 index 000000000..da0d53b6f --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/wardrobe/garment/[id]/+page.svelte @@ -0,0 +1,19 @@ + + + + Kleidungsstück · Mana + + + + + {#key id} + + {/key} +