feat(wardrobe): outfits composer + detail page + tab switcher (M3)

M3 of docs/plans/wardrobe-module.md — layers outfit composition on top
of M2's garment grid. Users can now combine their garments into named
outfits, see them in a second tab under /wardrobe, open a per-outfit
detail page, and edit via the same composer route.

Routes:
- /wardrobe/compose — empty composer, creates a new outfit
- /wardrobe/compose/[outfitId] — composer pre-populated with an
  existing outfit, saves back into it (SvelteKit optional-param
  `[[outfitId]]` folder name). Both wrap OutfitComposer in
  `{#key outfitId ?? 'new'}` so create→edit navigation cleanly
  re-mounts with the right initial state.
- /wardrobe/outfit/[id] — outfit detail; wrapped in `{#key id}`
  for the same reason as the garment detail route.

Components:
- OutfitCard — grid tile. Cover precedence: lastTryOn.imageUrl
  (M4 payload) → 2×2 garment-thumbnail collage → empty state.
  Shows name + "<n> Stücke · <occasion>" line + favorite heart
  overlay when set.
- OutfitComposer — two-column editor. Left: garments grouped by
  category with +/✓ overlay toggles and a scroll container capped
  at 70vh so the right-hand editor doesn't disappear below the
  fold on long libraries. Right: name + description + occasion
  dropdown + season pill-toggles + comma-tags + composition chips
  with hover-× to remove. Click-to-add (no drag-drop — simpler
  mental model, keyboard-accessible for free, 100% of the
  workflow covered).
- OutfitsView — sibling to GridView, renders the outfit grid and
  the "+ Neues Outfit" CTA. Shows a garments-first empty state
  when the user has no clothing at all, an outfit-only empty state
  when they do but haven't composed anything yet.
- DetailOutfitView — cover + metadata card + "Zusammenstellung"
  grid (each garment tile links back to its own detail page).
  Try-On button is a stub for M4 ("kommt bald"); the Try-On
  history strip reads from picture.images via the existing
  useOutfitTryOns query and renders once M4 starts writing those
  back-references.

ListView now toggles between Garments (GridView, default) and
Outfits (OutfitsView) tabs; local state, lost on hard reload,
kept across in-app navigation.

Types: OutfitTryOn gains `imageUrl: string` (mana-media URL cached
alongside the picture.images.id pointer). Needed so the OutfitCard
renders the try-on thumb with one HTTP round-trip instead of a
Dexie→picture.images→mana-media lookup chain. Source of truth
remains the picture.images row; this is just a cache.

No M1 data shape breaks — only additive field on OutfitTryOn and
that type wasn't used anywhere in shipped code yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:45:21 +02:00
parent 441f95697b
commit 2b89bf7955
8 changed files with 997 additions and 5 deletions

View file

@ -1,12 +1,45 @@
<!--
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.
Wardrobe module root — two tabs: Kleidung (GridView, default) and
Outfits (OutfitsView). Keep the tab state local; SvelteKit keeps
ListView mounted across navigations within /wardrobe/, so scrolling
back to "/wardrobe" preserves which tab the user last opened.
-->
<script lang="ts">
import GridView from './views/GridView.svelte';
import OutfitsView from './views/OutfitsView.svelte';
type Tab = 'garments' | 'outfits';
let activeTab = $state<Tab>('garments');
const TABS: { key: Tab; label: string }[] = [
{ key: 'garments', label: 'Kleidung' },
{ key: 'outfits', label: 'Outfits' },
];
</script>
<div class="mx-auto max-w-5xl p-4 sm:p-6">
<GridView />
<nav class="mb-6 flex gap-1 border-b border-border">
{#each TABS as tab (tab.key)}
<button
type="button"
onclick={() => (activeTab = tab.key)}
class="relative px-3 py-2 text-sm font-medium transition-colors {activeTab === tab.key
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'}"
>
{tab.label}
{#if activeTab === tab.key}
<span class="absolute -bottom-px left-0 right-0 h-0.5 bg-primary" aria-hidden="true"
></span>
{/if}
</button>
{/each}
</nav>
{#if activeTab === 'garments'}
<GridView />
{:else}
<OutfitsView />
{/if}
</div>

View file

@ -0,0 +1,105 @@
<!--
Grid tile for an outfit. Cover visual precedence:
1. lastTryOn.imageId — the AI-rendered user in this outfit (M4+)
2. Up to 4 garment thumbnails in a 2×2 collage
3. Empty state: placeholder with the garment count
Click navigates to /wardrobe/outfit/[id]. Metadata (Try-On, edit,
favorite) lives on the detail page.
-->
<script lang="ts">
import { Heart, Sparkle } from '@mana/shared-icons';
import { garmentPhotoUrl } from '../api/media-url';
import { OCCASION_LABELS } from '../constants';
import type { Garment, Outfit } from '../types';
interface Props {
outfit: Outfit;
/**
* Garment lookup — the outfit only stores garmentIds; resolving
* to full rows (for thumbnail urls) happens one level up where
* the useAllGarments live-query already runs.
*/
garmentsById: Record<string, Garment>;
href?: string;
}
let { outfit, garmentsById, href = `/wardrobe/outfit/${outfit.id}` }: Props = $props();
// The cached URL from the most recent try-on (see OutfitTryOn). Null
// when the outfit was never tried on or when the user deleted the
// picture.images row — either way, fall through to the garment
// collage render below.
const tryOnUrl = $derived(outfit.lastTryOn?.imageUrl ?? null);
const resolvedGarments = $derived.by(() => {
const out: Garment[] = [];
for (const id of outfit.garmentIds ?? []) {
const g = garmentsById[id];
if (g) out.push(g);
if (out.length >= 4) break;
}
return out;
});
</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 tryOnUrl}
<img src={tryOnUrl} alt={outfit.name} loading="lazy" class="h-full w-full object-cover" />
<span
class="absolute left-2 top-2 flex items-center gap-1 rounded-md bg-primary/90 px-2 py-0.5 text-xs font-medium text-primary-foreground shadow-sm backdrop-blur-sm"
title="Try-On Vorschau"
>
<Sparkle size={11} weight="fill" />
Try-On
</span>
{:else if resolvedGarments.length > 0}
<div class="grid h-full w-full grid-cols-2 grid-rows-2 gap-0.5 bg-border">
{#each resolvedGarments as g}
{@const mediaId = g.mediaIds[0]}
<div class="relative overflow-hidden bg-muted">
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
</div>
{/each}
{#if resolvedGarments.length < 4}
{#each Array(4 - resolvedGarments.length) as _, i (i)}
<div class="bg-muted"></div>
{/each}
{/if}
</div>
{:else}
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Leer
</div>
{/if}
{#if outfit.isFavorite}
<span
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-background/90 text-rose-500 shadow-sm backdrop-blur-sm"
title="Favorit"
>
<Heart size={14} weight="fill" />
</span>
{/if}
</div>
<div class="px-3 py-2">
<p class="truncate text-sm font-medium text-foreground">{outfit.name}</p>
<div class="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>{outfit.garmentIds.length}{outfit.garmentIds.length === 1 ? ' Stück' : ' Stücke'}</span>
{#if outfit.occasion}
<span class="text-border">·</span>
<span>{OCCASION_LABELS[outfit.occasion]}</span>
{/if}
</div>
</div>
</a>

View file

@ -0,0 +1,375 @@
<!--
Two-column outfit composer. Left: library of all non-archived
garments in the active space, grouped by category. Right: the
outfit being built — name + description + occasion/season/tags +
the selected garment chips. Click a library tile to add, click a
chip's × to remove, hit Save to persist.
Create vs. edit: the parent passes an `outfit` prop (new or
existing). Save-handler is onSave — it receives the patch and
resolves with the saved outfit id. Parent handles redirect.
No drag-drop in M3 (plan mentions it but click-to-add covers 100%
of the workflow and is keyboard-accessible for free).
-->
<script lang="ts">
import { Check, Plus, X } from '@mana/shared-icons';
import { garmentPhotoUrl } from '../api/media-url';
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
OCCASION_LABELS,
OCCASION_ORDER,
SEASON_LABELS,
} from '../constants';
import type { Garment, GarmentCategory, Outfit, OutfitOccasion, OutfitSeason } from '../types';
interface Props {
/** Full library of garments available in the active space. */
garments: Garment[];
/** null → compose mode (creating); Outfit → edit mode. */
outfit?: Outfit | null;
saving?: boolean;
onSave: (patch: {
name: string;
description?: string | null;
garmentIds: string[];
occasion?: OutfitOccasion | null;
season?: OutfitSeason[];
tags: string[];
}) => Promise<void> | void;
onCancel?: () => void;
}
let { garments, outfit = null, saving = false, onSave, onCancel }: Props = $props();
// svelte-ignore state_referenced_locally
let name = $state(outfit?.name ?? '');
// svelte-ignore state_referenced_locally
let description = $state(outfit?.description ?? '');
// svelte-ignore state_referenced_locally
let selectedIds = $state<string[]>([...(outfit?.garmentIds ?? [])]);
// svelte-ignore state_referenced_locally
let occasion = $state<OutfitOccasion | ''>(outfit?.occasion ?? '');
// svelte-ignore state_referenced_locally
let selectedSeasons = $state<OutfitSeason[]>([...(outfit?.season ?? [])]);
// svelte-ignore state_referenced_locally
let tagsText = $state((outfit?.tags ?? []).join(', '));
let error = $state<string | null>(null);
const garmentsById = $derived.by<Record<string, Garment>>(() => {
const map: Record<string, Garment> = {};
for (const g of garments) map[g.id] = g;
return map;
});
const selectedGarments = $derived(
selectedIds.map((id) => garmentsById[id]).filter((g): g is Garment => Boolean(g))
);
const grouped = $derived.by(() => {
const map: Record<GarmentCategory, Garment[]> = {
top: [],
bottom: [],
dress: [],
outerwear: [],
shoes: [],
bag: [],
accessory: [],
glasses: [],
jewelry: [],
hat: [],
other: [],
};
for (const g of garments) map[g.category].push(g);
return map;
});
function toggleGarment(id: string) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter((x) => x !== id);
} else {
selectedIds = [...selectedIds, id];
}
}
function removeGarment(id: string) {
selectedIds = selectedIds.filter((x) => x !== id);
}
function toggleSeason(s: OutfitSeason) {
if (selectedSeasons.includes(s)) {
selectedSeasons = selectedSeasons.filter((x) => x !== s);
} else {
selectedSeasons = [...selectedSeasons, s];
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) {
error = 'Gib dem Outfit einen Namen.';
return;
}
if (selectedIds.length === 0) {
error = 'Wähle mindestens ein Kleidungsstück aus.';
return;
}
error = null;
const tagList = tagsText
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
try {
await onSave({
name: name.trim(),
description: description.trim() || null,
garmentIds: [...selectedIds],
occasion: occasion === '' ? null : occasion,
season: selectedSeasons.length > 0 ? [...selectedSeasons] : undefined,
tags: tagList,
});
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
}
}
</script>
<form onsubmit={handleSubmit} class="grid gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<!-- LEFT: garment library -->
<section class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Kleiderschrank
</h2>
<span class="text-xs text-muted-foreground">
{garments.length}
{garments.length === 1 ? 'Stück' : 'Stücke'} verfügbar
</span>
</header>
{#if garments.length === 0}
<div class="rounded-xl border border-dashed border-border bg-background/50 p-6 text-center">
<p class="text-sm font-medium text-foreground">Nichts zum Kombinieren.</p>
<p class="mt-1 text-sm text-muted-foreground">
Lade zuerst ein paar Kleidungsstücke im Tab
<a href="/wardrobe" class="font-medium text-primary hover:underline">Kleidung</a>
hoch.
</p>
</div>
{:else}
<div
class="space-y-5 max-h-[70vh] overflow-y-auto rounded-xl border border-border bg-background/50 p-3"
>
{#each CATEGORY_ORDER as category}
{@const list = grouped[category]}
{#if list.length > 0}
<div>
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{CATEGORY_LABELS[category]}
<span class="text-border"> · {list.length}</span>
</h3>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
{#each list as g (g.id)}
{@const mediaId = g.mediaIds[0]}
{@const selected = selectedIds.includes(g.id)}
<button
type="button"
onclick={() => toggleGarment(g.id)}
aria-pressed={selected}
title={g.name}
class="relative aspect-square overflow-hidden rounded-md border bg-muted transition-all {selected
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'}"
>
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
<span
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full {selected
? 'bg-primary text-primary-foreground'
: 'bg-background/90 text-muted-foreground'} shadow-sm backdrop-blur-sm"
>
{#if selected}
<Check size={12} weight="bold" />
{:else}
<Plus size={12} weight="bold" />
{/if}
</span>
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</section>
<!-- RIGHT: outfit editor -->
<section class="space-y-4">
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div>
<label for="outfit-name" class="mb-1.5 block text-sm font-medium text-foreground">
Name <span class="text-error">*</span>
</label>
<input
id="outfit-name"
type="text"
bind:value={name}
required
disabled={saving}
placeholder="z.B. Bürooutfit Juni"
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="outfit-description" class="mb-1.5 block text-sm font-medium text-foreground">
Beschreibung
</label>
<textarea
id="outfit-description"
bind:value={description}
disabled={saving}
rows="2"
placeholder="Für welchen Anlass? Besonderheiten?"
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>
<label for="outfit-occasion" class="mb-1.5 block text-sm font-medium text-foreground">
Anlass
</label>
<select
id="outfit-occasion"
bind:value={occasion}
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"
>
<option value="">— kein Anlass —</option>
{#each OCCASION_ORDER as o}
<option value={o}>{OCCASION_LABELS[o]}</option>
{/each}
</select>
</div>
<fieldset>
<legend class="mb-1.5 text-sm font-medium text-foreground">Jahreszeit</legend>
<div class="flex flex-wrap gap-1.5">
{#each Object.entries(SEASON_LABELS) as [season, label]}
{@const s = season as OutfitSeason}
{@const on = selectedSeasons.includes(s)}
<button
type="button"
onclick={() => toggleSeason(s)}
disabled={saving}
aria-pressed={on}
class="rounded-full border px-3 py-1 text-xs transition-colors {on
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground'} disabled:opacity-50"
>
{label}
</button>
{/each}
</div>
</fieldset>
<div>
<label for="outfit-tags" class="mb-1.5 block text-sm font-medium text-foreground">
Tags <span class="text-muted-foreground">(komma-getrennt)</span>
</label>
<input
id="outfit-tags"
type="text"
bind:value={tagsText}
disabled={saving}
placeholder="minimal, layering, meeting"
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>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<header class="flex items-center justify-between">
<h3 class="text-sm font-medium text-foreground">
Zusammenstellung
<span class="ml-1 text-xs text-muted-foreground">
· {selectedGarments.length}
{selectedGarments.length === 1 ? 'Stück' : 'Stücke'}
</span>
</h3>
</header>
{#if selectedGarments.length === 0}
<p
class="rounded-md border border-dashed border-border bg-background/50 p-3 text-xs text-muted-foreground"
>
Klicke links auf Kleidungsstücke, um sie dem Outfit hinzuzufügen.
</p>
{:else}
<div class="flex flex-wrap gap-2">
{#each selectedGarments as g (g.id)}
{@const mediaId = g.mediaIds[0]}
<div
class="group relative overflow-hidden rounded-md border border-border bg-muted"
style:width="72px"
style:height="72px"
>
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
class="h-full w-full object-cover"
/>
{/if}
<button
type="button"
onclick={() => removeGarment(g.id)}
aria-label="Aus Outfit entfernen"
title="Aus Outfit entfernen"
class="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background/90 text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-error group-hover:opacity-100"
>
<X size={12} weight="bold" />
</button>
</div>
{/each}
</div>
{/if}
</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() || selectedIds.length === 0}
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…' : outfit ? 'Änderungen speichern' : 'Outfit anlegen'}
</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>
</section>
</form>

View file

@ -130,9 +130,16 @@ export function garmentPrimaryMediaId(garment: Pick<Garment, 'mediaIds'>): strin
* lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id`
* this pointer exists so the outfit detail view can render the latest
* preview without re-querying.
*
* `imageUrl` is cached here (mana-media URL from the picture.images row)
* so OutfitCard's thumbnail renders without a second Dexie round-trip.
* The source of truth remains picture.images; if the user deletes that
* row the pointer goes stale but the card just falls back to the
* garment-collage render no error.
*/
export interface OutfitTryOn {
imageId: string; // points at picture.images.id
imageId: string; // picture.images.id (UUID)
imageUrl: string; // mana-media URL, cached for cheap card rendering
createdAt: string; // ISO
prompt: string;
model: string;

View file

@ -0,0 +1,281 @@
<!--
Outfit detail page. Left column: the latest try-on preview (falls back
to garment collage when no try-on has been rendered yet — M4 wires up
the actual Try-On button). Right column: metadata, garment list (each
tile links to the garment's own detail page), and the action rail:
Favorite, Edit (→ composer), Archive, Delete.
The Try-On action is a stub in M3 — the Picture-generator reference
endpoint is already wired (M3 of me-images plan), but the composer
logic that auto-fills referenceMediaIds from face+body+garments lives
in M4 of this plan.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Archive, Heart, PencilSimple, Sparkle, Trash } from '@mana/shared-icons';
import { useAllGarments, useOutfit, useOutfitTryOns } from '../queries';
import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
import { garmentPhotoUrl } from '../api/media-url';
import { CATEGORY_LABELS_SINGULAR, OCCASION_LABELS, SEASON_LABELS } from '../constants';
import type { Garment } from '../types';
interface Props {
id: string;
}
let { id }: Props = $props();
// Wrapped in `{#key id}` by the route — captures are intentional.
// svelte-ignore state_referenced_locally
const outfit$ = useOutfit(id);
// svelte-ignore state_referenced_locally
const tryOns$ = useOutfitTryOns(id);
const allGarments$ = useAllGarments();
const outfit = $derived(outfit$.value);
const tryOns = $derived(tryOns$.value ?? []);
const allGarments = $derived(allGarments$.value ?? []);
const garmentsById = $derived.by<Record<string, Garment>>(() => {
const map: Record<string, Garment> = {};
for (const g of allGarments) map[g.id] = g;
return map;
});
const resolvedGarments = $derived.by<Garment[]>(() => {
if (!outfit) return [];
const out: Garment[] = [];
for (const gid of outfit.garmentIds) {
const g = garmentsById[gid];
if (g) out.push(g);
}
return out;
});
async function handleToggleFavorite() {
if (!outfit) return;
await wardrobeOutfitsStore.toggleFavorite(outfit.id);
}
async function handleArchive() {
if (!outfit) return;
await wardrobeOutfitsStore.archiveOutfit(outfit.id, !outfit.isArchived);
}
async function handleDelete() {
if (!outfit) return;
if (!confirm(`Outfit "${outfit.name}" wirklich löschen?`)) return;
await wardrobeOutfitsStore.deleteOutfit(outfit.id);
goto('/wardrobe');
}
</script>
<div class="mx-auto max-w-4xl 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 · Outfits</span>
</nav>
{#if !outfit}
{#if outfit$.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">Outfit nicht gefunden.</p>
<p class="mt-1 text-sm text-muted-foreground">Gelöscht oder in einem anderen Space.</p>
</div>
{/if}
{:else}
<div class="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<!-- Cover (last try-on or collage fallback) -->
<div class="space-y-3">
<div class="overflow-hidden rounded-2xl border border-border bg-muted">
{#if outfit.lastTryOn?.imageUrl}
<img
src={outfit.lastTryOn.imageUrl}
alt="Try-On Vorschau"
class="h-full w-full object-cover"
/>
{:else if resolvedGarments.length > 0}
<div class="grid grid-cols-2 gap-0.5 bg-border">
{#each resolvedGarments.slice(0, 4) as g}
{@const mediaId = g.mediaIds[0]}
<div class="aspect-square overflow-hidden bg-muted">
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'medium')}
alt={g.name}
class="h-full w-full object-cover"
/>
{/if}
</div>
{/each}
</div>
{:else}
<div
class="flex aspect-square items-center justify-center text-sm text-muted-foreground"
>
Keine Kleidungsstücke
</div>
{/if}
</div>
<!-- Try-On action (M4 stub) -->
<button
type="button"
disabled
title="Try-On kommt in M4 — bis dahin ist das hier noch stumm."
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary/50 px-4 py-2.5 text-sm font-medium text-primary-foreground opacity-60"
>
<Sparkle size={16} weight="fill" />
Anprobieren (kommt bald)
</button>
{#if tryOns.length > 0}
<div>
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Try-On Verlauf
</h3>
<div class="flex gap-2 overflow-x-auto">
{#each tryOns as t (t.id)}
{#if t.publicUrl}
<img
src={t.publicUrl}
alt={outfit.name}
class="h-20 w-20 flex-shrink-0 rounded-md border border-border bg-muted object-cover"
loading="lazy"
/>
{/if}
{/each}
</div>
</div>
{/if}
</div>
<!-- Metadata + actions -->
<div class="space-y-4">
<div class="space-y-3 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">{outfit.name}</h1>
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{outfit.garmentIds.length}
{outfit.garmentIds.length === 1 ? 'Stück' : 'Stücke'}
</span>
{#if outfit.occasion}
<span class="text-border">·</span>
<span>{OCCASION_LABELS[outfit.occasion]}</span>
{/if}
{#if outfit.season && outfit.season.length > 0}
<span class="text-border">·</span>
<span>{outfit.season.map((s) => SEASON_LABELS[s]).join(', ')}</span>
{/if}
</div>
</div>
<div class="flex gap-1">
<button
type="button"
onclick={handleToggleFavorite}
aria-label={outfit.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
title={outfit.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {outfit.isFavorite
? 'text-rose-500 hover:bg-rose-500/10'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<Heart size={16} weight={outfit.isFavorite ? 'fill' : 'regular'} />
</button>
<a
href="/wardrobe/compose/{outfit.id}"
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} />
</a>
</div>
</header>
{#if outfit.description}
<p class="whitespace-pre-wrap text-sm text-foreground">{outfit.description}</p>
{/if}
{#if outfit.tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each outfit.tags as tag}
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{tag}
</span>
{/each}
</div>
{/if}
</div>
<!-- Garments in this outfit -->
<div class="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Zusammenstellung
</h2>
{#if resolvedGarments.length > 0}
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
{#each resolvedGarments as g (g.id)}
{@const mediaId = g.mediaIds[0]}
<a
href="/wardrobe/garment/{g.id}"
class="group overflow-hidden rounded-md border border-border bg-muted transition-shadow hover:shadow-md"
>
<div class="aspect-square">
{#if mediaId}
<img
src={garmentPhotoUrl(mediaId, 'thumb')}
alt={g.name}
loading="lazy"
class="h-full w-full object-cover"
/>
{/if}
</div>
<div class="px-1.5 py-1">
<p class="truncate text-xs font-medium text-foreground">{g.name}</p>
<p class="truncate text-[10px] text-muted-foreground">
{CATEGORY_LABELS_SINGULAR[g.category]}
</p>
</div>
</a>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">
Referenzierte Kleidungsstücke wurden entfernt oder gehören zu einem anderen Space.
</p>
{/if}
</div>
<!-- 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} />
{outfit.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>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,78 @@
<!--
Outfits grid — sibling to GridView. The composer lives on a separate
route (`/wardrobe/compose`) because a two-column editor at 400+ lines
shouldn't be inline on the tab root. CTA button opens the empty
composer; existing outfits open their detail view on click.
-->
<script lang="ts">
import { Plus, Sparkle } from '@mana/shared-icons';
import { useAllGarments, useAllOutfits } from '../queries';
import OutfitCard from '../components/OutfitCard.svelte';
import type { Garment } from '../types';
const garments$ = useAllGarments();
const outfits$ = useAllOutfits();
const garments = $derived(garments$.value ?? []);
const outfits = $derived(outfits$.value ?? []);
const garmentsById = $derived.by<Record<string, Garment>>(() => {
const map: Record<string, Garment> = {};
for (const g of garments) map[g.id] = g;
return map;
});
</script>
<div class="space-y-4">
<header class="flex items-center justify-between gap-2">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Outfits</h2>
{#if outfits.length > 0}
<p class="mt-0.5 text-xs text-muted-foreground">
{outfits.length}
{outfits.length === 1 ? 'Zusammenstellung' : 'Zusammenstellungen'}
</p>
{/if}
</div>
<a
href="/wardrobe/compose"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={14} weight="bold" />
Neues Outfit
</a>
</header>
{#if outfits.length > 0}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each outfits as outfit (outfit.id)}
<OutfitCard {outfit} {garmentsById} />
{/each}
</div>
{:else if garments.length === 0}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<Sparkle size={24} weight="fill" class="mx-auto mb-3 text-primary/60" />
<p class="text-sm font-medium text-foreground">Noch keine Outfits.</p>
<p class="mt-1 text-sm text-muted-foreground">
Füge zuerst ein paar Kleidungsstücke im Tab "Kleidung" hinzu — danach lassen sie sich hier
zu Outfits kombinieren.
</p>
</div>
{:else}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<Sparkle size={24} weight="fill" class="mx-auto mb-3 text-primary/60" />
<p class="text-sm font-medium text-foreground">Noch keine Outfits.</p>
<p class="mt-1 text-sm text-muted-foreground">
Kombiniere deine Kleidungsstücke zu Looks, die du dann mit KI an dir selbst anprobieren
kannst.
</p>
<a
href="/wardrobe/compose"
class="mt-4 inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus size={14} weight="bold" />
Erstes Outfit komponieren
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ArrowLeft } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
import OutfitComposer from '$lib/modules/wardrobe/components/OutfitComposer.svelte';
import { useAllGarments, useOutfit } from '$lib/modules/wardrobe/queries';
import { wardrobeOutfitsStore } from '$lib/modules/wardrobe/stores/outfits.svelte';
// `[[outfitId]]` is optional — absent for create, present for edit.
const outfitId = $derived(page.params.outfitId ?? null);
const garments$ = useAllGarments();
const existingOutfit$ = $derived(useOutfit(outfitId));
const garments = $derived(garments$.value ?? []);
const outfit = $derived(existingOutfit$.value);
let saving = $state(false);
type Patch = Parameters<typeof wardrobeOutfitsStore.updateOutfit>[1] & {
garmentIds: string[];
};
async function handleSave(patch: Patch) {
saving = true;
try {
if (outfitId && outfit) {
await wardrobeOutfitsStore.updateOutfit(outfitId, patch);
goto(`/wardrobe/outfit/${outfitId}`);
} else {
const created = await wardrobeOutfitsStore.createOutfit({
name: patch.name!,
description: patch.description ?? null,
garmentIds: patch.garmentIds,
occasion: patch.occasion ?? null,
season: patch.season,
tags: patch.tags ?? [],
});
goto(`/wardrobe/outfit/${created.id}`);
}
} finally {
saving = false;
}
}
function handleCancel() {
if (outfitId) {
goto(`/wardrobe/outfit/${outfitId}`);
} else {
goto('/wardrobe');
}
}
</script>
<svelte:head>
<title>{outfitId ? 'Outfit bearbeiten' : 'Neues Outfit'} · Mana</title>
</svelte:head>
<RoutePage appId="wardrobe" backHref="/wardrobe">
<div class="mx-auto max-w-6xl p-4 sm:p-6">
<header class="mb-5 flex items-center gap-3">
<a
href={outfitId ? `/wardrobe/outfit/${outfitId}` : '/wardrobe'}
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label="Zurück"
>
<ArrowLeft size={16} />
</a>
<h1 class="text-xl font-bold text-foreground">
{outfitId ? 'Outfit bearbeiten' : 'Neues Outfit'}
</h1>
</header>
{#if outfitId && !outfit && !existingOutfit$.loading}
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
<p class="text-sm font-medium text-foreground">Outfit nicht gefunden.</p>
<p class="mt-1 text-sm text-muted-foreground">Gelöscht oder in einem anderen Space.</p>
</div>
{:else}
<!-- Remount when we switch from /compose (new) to /compose/:id (edit)
so the composer's initial state captures the right outfit. -->
{#key outfitId ?? 'new'}
<OutfitComposer
{garments}
outfit={outfitId ? outfit : null}
{saving}
onSave={handleSave}
onCancel={handleCancel}
/>
{/key}
{/if}
</div>
</RoutePage>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { page } from '$app/state';
import { RoutePage } from '$lib/components/shell';
import DetailOutfitView from '$lib/modules/wardrobe/views/DetailOutfitView.svelte';
const id = $derived(page.params.id ?? '');
</script>
<svelte:head>
<title>Outfit · Mana</title>
</svelte:head>
<RoutePage appId="wardrobe" backHref="/wardrobe">
<!-- Force a fresh subtree on :id change so liveQuery and local state
reset cleanly when navigating between /outfit/a → /outfit/b. -->
{#key id}
<DetailOutfitView {id} />
{/key}
</RoutePage>