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} +