mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(wardrobe): garments UI — /wardrobe + /wardrobe/garment/[id] (M2)
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) <noreply@anthropic.com>
This commit is contained in:
parent
d518169ce9
commit
5a49bcbf02
11 changed files with 926 additions and 0 deletions
12
apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte
Normal file
12
apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!--
|
||||
Wardrobe module root. For M2 it's just the Garments grid; M3 will add
|
||||
a second tab/section for Outfits. Keep the root thin so swapping the
|
||||
layout later doesn't cascade.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import GridView from './views/GridView.svelte';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl p-4 sm:p-6">
|
||||
<GridView />
|
||||
</div>
|
||||
26
apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts
Normal file
26
apps/mana/apps/web/src/lib/modules/wardrobe/api/media-url.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
35
apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts
Normal file
35
apps/mana/apps/web/src/lib/modules/wardrobe/api/upload.ts
Normal file
|
|
@ -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<UploadGarmentResult> {
|
||||
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<UploadGarmentResult>;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<!--
|
||||
Horizontal pill-tabs for the garment grid. "Alle" is the first tab;
|
||||
the selected category drives both the filter and the default category
|
||||
for new uploads on the current tab (drops land in the active tab's
|
||||
category, so "Oberteile aktiv → Datei droppen → landet als top").
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from '../constants';
|
||||
import type { GarmentCategory } from '../types';
|
||||
|
||||
interface Props {
|
||||
active: GarmentCategory | 'all';
|
||||
/** Count badge per category; omitted -> no badge. */
|
||||
counts?: Partial<Record<GarmentCategory | 'all', number>>;
|
||||
onChange: (next: GarmentCategory | 'all') => void;
|
||||
}
|
||||
|
||||
let { active, counts, onChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-nowrap gap-1.5 overflow-x-auto pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange('all')}
|
||||
class="shrink-0 rounded-full border px-3 py-1 text-sm transition-colors {active === 'all'
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground'}"
|
||||
>
|
||||
Alle
|
||||
{#if counts?.all !== undefined}
|
||||
<span class="ml-1 text-xs opacity-70">{counts.all}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#each CATEGORY_ORDER as category}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange(category)}
|
||||
class="shrink-0 rounded-full border px-3 py-1 text-sm transition-colors {active === category
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground'}"
|
||||
>
|
||||
{CATEGORY_LABELS[category]}
|
||||
{#if counts?.[category] !== undefined && counts[category]! > 0}
|
||||
<span class="ml-1 text-xs opacity-70">{counts[category]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<!--
|
||||
Grid tile for a single garment. Renders the primary photo (mediaIds[0]),
|
||||
name, brand, and a small wear-count hint. Clicking the card navigates
|
||||
to the detail page — edit/delete/worn-today live there, not on the tile.
|
||||
|
||||
Archived + deletedAt are filtered out one level up in the grid; this
|
||||
component trusts the input.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CATEGORY_LABELS_SINGULAR } from '../constants';
|
||||
import { garmentPrimaryMediaId } from '../types';
|
||||
import { garmentPhotoUrl } from '../api/media-url';
|
||||
import type { Garment } from '../types';
|
||||
|
||||
interface Props {
|
||||
garment: Garment;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
let { garment, href = `/wardrobe/garment/${garment.id}` }: Props = $props();
|
||||
|
||||
const primaryMediaId = $derived(garmentPrimaryMediaId(garment));
|
||||
const primaryUrl = $derived(primaryMediaId ? garmentPhotoUrl(primaryMediaId, 'medium') : null);
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="group flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="relative aspect-square bg-muted">
|
||||
{#if primaryUrl}
|
||||
<img src={primaryUrl} alt={garment.name} loading="lazy" class="h-full w-full object-cover" />
|
||||
{/if}
|
||||
<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"
|
||||
>
|
||||
{CATEGORY_LABELS_SINGULAR[garment.category] ?? garment.category}
|
||||
</span>
|
||||
{#if garment.wearCount && garment.wearCount > 0}
|
||||
<span
|
||||
class="absolute right-2 top-2 rounded-full bg-primary/90 px-2 py-0.5 text-xs font-medium text-primary-foreground shadow-sm"
|
||||
title="{garment.wearCount}× getragen"
|
||||
>
|
||||
{garment.wearCount}×
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-3 py-2">
|
||||
<p class="truncate text-sm font-medium text-foreground">{garment.name}</p>
|
||||
{#if garment.brand}
|
||||
<p class="truncate text-xs text-muted-foreground">{garment.brand}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
<!--
|
||||
Inline edit form for a single garment. Hosted on the detail view.
|
||||
Presentational — the parent owns the `onSave` callback that writes
|
||||
through `wardrobeGarmentsStore.updateGarment`. Creation is not done
|
||||
here: new garments come from the upload flow with default values,
|
||||
and the detail view immediately opens in edit mode.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from '../constants';
|
||||
import type { Garment, GarmentCategory } from '../types';
|
||||
|
||||
interface Props {
|
||||
garment: Garment;
|
||||
onSave: (patch: {
|
||||
name: string;
|
||||
category: GarmentCategory;
|
||||
brand?: string | null;
|
||||
color?: string | null;
|
||||
size?: string | null;
|
||||
material?: string | null;
|
||||
tags: string[];
|
||||
notes?: string | null;
|
||||
priceCents?: number | null;
|
||||
currency?: string | null;
|
||||
}) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
let { garment, onSave, onCancel, saving = false }: Props = $props();
|
||||
|
||||
// Form state captures the garment's initial values. The parent
|
||||
// (DetailGarmentView) wraps us under `{#key id}` in the route so a
|
||||
// navigation to a different garment remounts us and these initializers
|
||||
// re-run — the captures are intentional, not a bug.
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let name = $state(garment.name);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let category = $state<GarmentCategory>(garment.category);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let brand = $state(garment.brand ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let color = $state(garment.color ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let size = $state(garment.size ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let material = $state(garment.material ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let tagsText = $state((garment.tags ?? []).join(', '));
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let notes = $state(garment.notes ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let priceEuros = $state(
|
||||
garment.priceCents !== undefined && garment.priceCents !== null
|
||||
? (garment.priceCents / 100).toFixed(2)
|
||||
: ''
|
||||
);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let currency = $state(garment.currency ?? 'EUR');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
error = 'Name darf nicht leer sein';
|
||||
return;
|
||||
}
|
||||
error = null;
|
||||
const parsedPrice = priceEuros.trim() ? Math.round(parseFloat(priceEuros) * 100) : null;
|
||||
const tagList = tagsText
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
category,
|
||||
brand: brand.trim() || null,
|
||||
color: color.trim() || null,
|
||||
size: size.trim() || null,
|
||||
material: material.trim() || null,
|
||||
tags: tagList,
|
||||
notes: notes.trim() || null,
|
||||
priceCents: parsedPrice !== null && !Number.isNaN(parsedPrice) ? parsedPrice : null,
|
||||
currency: currency.trim() || null,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="garment-name" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Name <span class="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="garment-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
disabled={saving}
|
||||
required
|
||||
placeholder="z.B. Blau-weiß gestreiftes Hemd"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-category" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
id="garment-category"
|
||||
bind:value={category}
|
||||
disabled={saving}
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
>
|
||||
{#each CATEGORY_ORDER as c}
|
||||
<option value={c}>{CATEGORY_LABELS[c]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-brand" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Marke
|
||||
</label>
|
||||
<input
|
||||
id="garment-brand"
|
||||
type="text"
|
||||
bind:value={brand}
|
||||
disabled={saving}
|
||||
placeholder="z.B. Uniqlo"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-color" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Farbe
|
||||
</label>
|
||||
<input
|
||||
id="garment-color"
|
||||
type="text"
|
||||
bind:value={color}
|
||||
disabled={saving}
|
||||
placeholder="z.B. navy"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-size" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Größe
|
||||
</label>
|
||||
<input
|
||||
id="garment-size"
|
||||
type="text"
|
||||
bind:value={size}
|
||||
disabled={saving}
|
||||
placeholder="z.B. M oder 42"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-material" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Material
|
||||
</label>
|
||||
<input
|
||||
id="garment-material"
|
||||
type="text"
|
||||
bind:value={material}
|
||||
disabled={saving}
|
||||
placeholder="z.B. Baumwolle"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label for="garment-tags" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Tags <span class="text-muted-foreground">(komma-getrennt)</span>
|
||||
</label>
|
||||
<input
|
||||
id="garment-tags"
|
||||
type="text"
|
||||
bind:value={tagsText}
|
||||
disabled={saving}
|
||||
placeholder="formal, sommer, lieblingsstück"
|
||||
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="garment-price" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Preis
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="garment-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={priceEuros}
|
||||
disabled={saving}
|
||||
placeholder="0.00"
|
||||
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={currency}
|
||||
disabled={saving}
|
||||
maxlength="3"
|
||||
aria-label="Währung"
|
||||
class="w-16 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label for="garment-notes" class="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="garment-notes"
|
||||
bind:value={notes}
|
||||
disabled={saving}
|
||||
rows="2"
|
||||
placeholder="Anlass, Tragevorschriften, …"
|
||||
class="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !name.trim()}
|
||||
class="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
{#if onCancel}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
disabled={saving}
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
82
apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts
Normal file
82
apps/mana/apps/web/src/lib/modules/wardrobe/constants.ts
Normal file
|
|
@ -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<GarmentCategory, string> = {
|
||||
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<GarmentCategory, string> = {
|
||||
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<OutfitOccasion, string> = {
|
||||
casual: 'Casual',
|
||||
work: 'Arbeit',
|
||||
formal: 'Festlich',
|
||||
workout: 'Sport',
|
||||
date: 'Date',
|
||||
travel: 'Reise',
|
||||
event: 'Event',
|
||||
sleep: 'Schlafanzug',
|
||||
other: 'Sonstiges',
|
||||
};
|
||||
|
||||
export const SEASON_LABELS: Record<OutfitSeason, string> = {
|
||||
spring: 'Frühling',
|
||||
summer: 'Sommer',
|
||||
autumn: 'Herbst',
|
||||
winter: 'Winter',
|
||||
};
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
<!--
|
||||
Garment detail page. Renders the primary photo + a compact metadata
|
||||
card that flips to edit mode via GarmentForm. "Heute getragen"
|
||||
increments wearCount + stamps lastWornAt. Archive and delete both
|
||||
sit in an overflow row below — visible but unobtrusive.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
|
||||
import { useGarment } from '../queries';
|
||||
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
|
||||
import { garmentPhotoUrl } from '../api/media-url';
|
||||
import { CATEGORY_LABELS } from '../constants';
|
||||
import GarmentForm from '../components/GarmentForm.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
|
||||
// id is stable for this component instance — the route file wraps
|
||||
// us in `{#key id}` so a navigation to a different garment re-mounts.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const garment$ = useGarment(id);
|
||||
const garment = $derived(garment$.value);
|
||||
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let markingWorn = $state(false);
|
||||
|
||||
async function handleMarkWorn() {
|
||||
if (!garment) return;
|
||||
markingWorn = true;
|
||||
try {
|
||||
await wardrobeGarmentsStore.markWornToday(garment.id);
|
||||
} finally {
|
||||
markingWorn = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!garment) return;
|
||||
await wardrobeGarmentsStore.archiveGarment(garment.id, !garment.isArchived);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!garment) return;
|
||||
if (!confirm(`"${garment.name}" wirklich löschen?`)) return;
|
||||
await wardrobeGarmentsStore.deleteGarment(garment.id);
|
||||
goto('/wardrobe');
|
||||
}
|
||||
|
||||
type SavePatch = Parameters<typeof wardrobeGarmentsStore.updateGarment>[1];
|
||||
|
||||
async function handleSave(patch: SavePatch) {
|
||||
if (!garment) return;
|
||||
saving = true;
|
||||
try {
|
||||
await wardrobeGarmentsStore.updateGarment(garment.id, patch);
|
||||
editing = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-5 p-4 sm:p-6">
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="/wardrobe"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
aria-label="Zurück zum Kleiderschrank"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</a>
|
||||
<span class="text-muted-foreground">Kleiderschrank</span>
|
||||
</nav>
|
||||
|
||||
{#if !garment}
|
||||
{#if garment$.loading}
|
||||
<p class="text-sm text-muted-foreground">Lädt…</p>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Nicht gefunden.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Das Kleidungsstück wurde gelöscht oder gehört zu einem anderen Space.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<!-- Photo -->
|
||||
<div class="overflow-hidden rounded-2xl border border-border bg-muted">
|
||||
{#if garment.mediaIds[0]}
|
||||
<img
|
||||
src={garmentPhotoUrl(garment.mediaIds[0], 'large')}
|
||||
alt={garment.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata / Edit -->
|
||||
<div class="space-y-4">
|
||||
{#if editing}
|
||||
<GarmentForm {garment} {saving} onSave={handleSave} onCancel={() => (editing = false)} />
|
||||
{:else}
|
||||
<div class="space-y-4 rounded-2xl border border-border bg-card p-5">
|
||||
<header class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-foreground">{garment.name}</h1>
|
||||
<p class="text-sm text-muted-foreground">{CATEGORY_LABELS[garment.category]}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
aria-label="Bearbeiten"
|
||||
title="Bearbeiten"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PencilSimple size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm">
|
||||
{#if garment.brand}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Marke</dt>
|
||||
<dd class="text-foreground">{garment.brand}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if garment.color}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Farbe</dt>
|
||||
<dd class="text-foreground">{garment.color}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if garment.size}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Größe</dt>
|
||||
<dd class="text-foreground">{garment.size}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if garment.material}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Material</dt>
|
||||
<dd class="text-foreground">{garment.material}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if garment.priceCents}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Preis</dt>
|
||||
<dd class="text-foreground">
|
||||
{(garment.priceCents / 100).toFixed(2)}
|
||||
{garment.currency ?? ''}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if garment.wearCount && garment.wearCount > 0}
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wider text-muted-foreground">Getragen</dt>
|
||||
<dd class="text-foreground">
|
||||
{garment.wearCount}×{garment.lastWornAt
|
||||
? ` · zuletzt ${garment.lastWornAt}`
|
||||
: ''}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
{#if garment.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each garment.tags as tag}
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if garment.notes}
|
||||
<p class="whitespace-pre-wrap text-sm text-foreground">{garment.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Primary action: heute getragen -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMarkWorn}
|
||||
disabled={markingWorn}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle size={16} weight="fill" />
|
||||
{markingWorn ? 'Gespeichert…' : 'Heute getragen'}
|
||||
</button>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleArchive}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{garment.isArchived ? 'Wieder aktiv' : 'Archivieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<!--
|
||||
Wardrobe grid view — category tabs + garment grid + upload drop-zone.
|
||||
The active category tab pre-selects the kind for new drops, so "Oberteile"
|
||||
tab + drop = garments created as kind='top'. On the "Alle" tab drops
|
||||
default to 'other' (user edits later on detail page).
|
||||
|
||||
Upload flow: drop file(s) → read dimensions client-side → POST to
|
||||
`/api/v1/wardrobe/garments/upload` → write a LocalWardrobeGarment
|
||||
through the store (encryption + sync happen in there). Name defaults
|
||||
to the filename-sans-extension, as in the picture module's upload.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Info, Sparkle } from '@mana/shared-icons';
|
||||
import MeImageUploadZone from '$lib/modules/profile/components/MeImageUploadZone.svelte';
|
||||
import { readImageDimensions } from '$lib/modules/profile/api/me-images';
|
||||
import { useAllGarments } from '../queries';
|
||||
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
|
||||
import { uploadGarmentPhoto } from '../api/upload';
|
||||
import { CATEGORY_LABELS, CATEGORY_LABELS_SINGULAR } from '../constants';
|
||||
import CategoryTabs from '../components/CategoryTabs.svelte';
|
||||
import GarmentCard from '../components/GarmentCard.svelte';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import type { GarmentCategory } from '../types';
|
||||
|
||||
const garments$ = useAllGarments();
|
||||
const activeSpace = $derived(getActiveSpace());
|
||||
|
||||
let activeTab = $state<GarmentCategory | 'all'>('all');
|
||||
|
||||
const garments = $derived(garments$.value ?? []);
|
||||
const filtered = $derived(
|
||||
activeTab === 'all' ? garments : garments.filter((g) => g.category === activeTab)
|
||||
);
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const map: Partial<Record<GarmentCategory | 'all', number>> = { all: garments.length };
|
||||
for (const g of garments) {
|
||||
map[g.category] = (map[g.category] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
|
||||
function stripExt(filename: string): string {
|
||||
const i = filename.lastIndexOf('.');
|
||||
return i > 0 ? filename.slice(0, i) : filename;
|
||||
}
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
// Pre-select kind from the active tab. Drops on "Alle" land as
|
||||
// 'other' — less specific, user edits on the detail page. Drops
|
||||
// on e.g. "Oberteile" land as 'top' ready-to-use.
|
||||
const defaultCategory: GarmentCategory = activeTab === 'all' ? 'other' : activeTab;
|
||||
uploading = true;
|
||||
uploadError = null;
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Dimensions read but not currently stored — kept for parity
|
||||
// with me-images upload and future "width/height"-on-row work.
|
||||
await readImageDimensions(file);
|
||||
const uploaded = await uploadGarmentPhoto(file);
|
||||
await wardrobeGarmentsStore.createGarment({
|
||||
name: stripExt(file.name),
|
||||
category: defaultCategory,
|
||||
mediaIds: [uploaded.mediaId],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Intro + active-space hint -->
|
||||
<section class="rounded-2xl border border-border bg-card p-5">
|
||||
<div class="mb-2 flex items-center justify-between gap-2 text-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<Sparkle size={18} weight="fill" class="text-primary" />
|
||||
<h2 class="text-base font-semibold">Kleiderschrank</h2>
|
||||
</div>
|
||||
{#if activeSpace}
|
||||
<span
|
||||
class="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
title="Der Kleiderschrank ist pro Space — andere Spaces haben ihren eigenen."
|
||||
>
|
||||
{activeSpace.type === 'personal' ? 'Persönlich' : activeSpace.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</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>
|
||||
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.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Category tabs -->
|
||||
<CategoryTabs active={activeTab} {counts} onChange={(next) => (activeTab = next)} />
|
||||
|
||||
{#if uploadError}
|
||||
<div class="rounded-xl border border-error/30 bg-error/10 p-3 text-sm text-error" role="alert">
|
||||
{uploadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grid -->
|
||||
{#if filtered.length > 0}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each filtered as g (g.id)}
|
||||
<GarmentCard garment={g} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if garments.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch nichts im Schrank.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Lade dein erstes Kleidungsstück hoch — unten auf "Hinzufügen" oder zieh eine Datei direkt in
|
||||
die Zone.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Keine Einträge unter <strong class="text-foreground"
|
||||
>{activeTab === 'all' ? 'Alle' : CATEGORY_LABELS[activeTab]}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload zone -->
|
||||
<MeImageUploadZone
|
||||
variant="compact"
|
||||
label={activeTab === 'all'
|
||||
? 'Kleidungsstück hochladen'
|
||||
: `${CATEGORY_LABELS_SINGULAR[activeTab]} hochladen`}
|
||||
hint="Foto frontal, möglichst heller Hintergrund — bessere Try-On-Ergebnisse"
|
||||
disabled={uploading}
|
||||
onFiles={ingestFiles}
|
||||
/>
|
||||
</div>
|
||||
12
apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/wardrobe/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import ListView from '$lib/modules/wardrobe/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kleiderschrank · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="wardrobe">
|
||||
<ListView />
|
||||
</RoutePage>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import DetailGarmentView from '$lib/modules/wardrobe/views/DetailGarmentView.svelte';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kleidungsstück · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="wardrobe" backHref="/wardrobe">
|
||||
<!-- Force a fresh subtree on :id change so the liveQuery + edit-form
|
||||
local state reset cleanly when navigating between /garment/a → /b. -->
|
||||
{#key id}
|
||||
<DetailGarmentView {id} />
|
||||
{/key}
|
||||
</RoutePage>
|
||||
Loading…
Add table
Add a link
Reference in a new issue