mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 06:39:41 +02:00
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:
parent
441f95697b
commit
2b89bf7955
8 changed files with 997 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue