mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(wardrobe,picture): symmetric wardrobeGarmentId FK + garment try-on strips
Solo-garment Try-Ons had no back-reference into Wardrobe — the
generated image landed in Picture.images with wardrobeOutfitId=null
and was unfindable from the garment detail page. The M4.1 comment
called it "deliberate — a standalone preview, not an outfit"; in
practice users open the garment detail expecting to see past tries.
Drop the asymmetry. Picture stays the single source of truth for
every AI-generated image; Wardrobe references by FK, not heuristic.
- Dexie v42: index `images.wardrobeOutfitId` (was unindexed, the
existing outfit query fell back to a scan) and add the symmetric
`images.wardrobeGarmentId` column + index. Fresh start — not live,
no migration of old rows, undefined on existing rows is the correct
"no back-ref" semantics.
- LocalImage / Image gain `wardrobeGarmentId?: string | null` in the
picture module's types + converter. Invariant: at most one of
`wardrobeOutfitId` / `wardrobeGarmentId` set per row (solo try-ons
write the garment id, outfit try-ons write the outfit id).
- runGarmentTryOn stamps `wardrobeGarmentId: garment.id` on the new
Picture row. runOutfitTryOn unchanged — it already wrote
wardrobeOutfitId for the symmetric outfit path.
- New queries in wardrobe/queries.ts:
- `useGarmentSoloTryOns(garmentId)` — live-queries Picture for
rows tagged with this garment's id.
- `useOutfitsContainingGarment(garmentId)` — live-queries
wardrobeOutfits whose `garmentIds[]` includes the garment, for
the cross-outfit context strip.
- DetailGarmentView gets two new sections under the existing meta/
action stack:
1. "Anproben" — solo-try-on thumbnails (click → full image in a
new tab; proper lightbox can reuse Picture's modal later).
2. "In Outfits" — outfits containing this garment, each rendering
its own `lastTryOn` snapshot as the thumb, click → outfit
detail. Reuses the outfit row's cached snapshot so no extra
image lookup.
Crypto registry unchanged: `images` only encrypts prompt +
negativePrompt; the new FK stays plaintext like wardrobeOutfitId.
mana-sync is field-level-generic — no schema work needed, the new
column syncs as a plain field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
218cf45005
commit
9fbdc14869
6 changed files with 176 additions and 5 deletions
|
|
@ -992,6 +992,25 @@ db.version(41).stores({
|
|||
wardrobeOutfits: 'id, createdAt, isFavorite, isArchived',
|
||||
});
|
||||
|
||||
// v42 — Index `images.wardrobeOutfitId` + add `images.wardrobeGarmentId`
|
||||
// index for fast Wardrobe back-reference queries. Until now Try-On
|
||||
// results in `picture.images` carried a back-ref to `wardrobeOutfits`
|
||||
// but no index — `useOutfitTryOns` fell back to a linear filter across
|
||||
// every image in the active space. Solo-Garment Try-Ons (M4.1) didn't
|
||||
// have a back-ref at all; the result ended up in the Picture gallery
|
||||
// and was unfindable from the garment detail page.
|
||||
//
|
||||
// Both fields are plaintext FKs to Wardrobe rows in the same space.
|
||||
// Index both together so outfit-detail and garment-detail queries are
|
||||
// single-field equality lookups (O(log n)) instead of table scans, and
|
||||
// add the symmetric `wardrobeGarmentId` so Solo-Try-Ons have the same
|
||||
// first-class back-reference as Outfit-Try-Ons. Invariant (enforced in
|
||||
// the write path): at most one of the two is set per row.
|
||||
db.version(42).stores({
|
||||
images:
|
||||
'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function toImage(local: LocalImage): Image {
|
|||
referenceImageIds: local.referenceImageIds ?? undefined,
|
||||
generationMode: local.generationMode ?? undefined,
|
||||
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
|
||||
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ export interface LocalImage extends BaseRecord {
|
|||
* an extra table. Plaintext — it's an FK.
|
||||
*/
|
||||
wardrobeOutfitId?: string | null;
|
||||
/**
|
||||
* Back-reference to `wardrobeGarments.id` when this image was produced
|
||||
* by the solo-garment try-on flow (M4.1). Symmetric to
|
||||
* wardrobeOutfitId — pictures are at most one of the two. Lets the
|
||||
* garment detail view list its own try-ons without scanning by
|
||||
* `referenceImageIds` containment.
|
||||
*/
|
||||
wardrobeGarmentId?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalBoard extends BaseRecord {
|
||||
|
|
@ -122,6 +130,7 @@ export interface Image {
|
|||
referenceImageIds?: string[];
|
||||
generationMode?: ImageGenerationMode;
|
||||
wardrobeOutfitId?: string;
|
||||
wardrobeGarmentId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,11 +303,13 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<Ru
|
|||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
referenceImageIds: referenceMediaIds,
|
||||
// Deliberately null — this is a standalone preview, not an outfit.
|
||||
// If users later compose outfits from these, the outfit-level
|
||||
// try-on writes its own picture.images row with the wardrobeOutfitId
|
||||
// set; no retroactive linking from the garment row.
|
||||
// Symmetric back-ref to wardrobeOutfitId: a solo try-on belongs
|
||||
// to exactly one garment, so the garment detail page can
|
||||
// liveQuery `where wardrobeGarmentId === id`. Outfit and
|
||||
// garment back-refs are mutually exclusive — this row is a
|
||||
// garment try-on, not an outfit one.
|
||||
wardrobeOutfitId: null,
|
||||
wardrobeGarmentId: garment.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,3 +137,48 @@ export function useOutfitTryOns(outfitId: string | null) {
|
|||
return decrypted.map(toImage);
|
||||
}, [] as Image[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every solo try-on rendered for a single garment, newest first.
|
||||
* Symmetric to `useOutfitTryOns`: filters `picture.images` by the
|
||||
* `wardrobeGarmentId` FK that `runGarmentTryOn` stamps on write. Does
|
||||
* NOT include outfit try-ons the garment participates in — see
|
||||
* `useOutfitsContainingGarment` for the cross-outfit view.
|
||||
*/
|
||||
export function useGarmentSoloTryOns(garmentId: string | null) {
|
||||
return useLiveQueryWithDefault<Image[]>(async () => {
|
||||
if (!garmentId) return [];
|
||||
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
|
||||
.and((row) => row.wardrobeGarmentId === garmentId)
|
||||
.toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('images', visible);
|
||||
return decrypted.map(toImage);
|
||||
}, [] as Image[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* All outfits in the active space that include the given garment,
|
||||
* newest first. Used on the garment detail page to render a "Teil
|
||||
* dieser Outfits"-Strip so the user can jump back into any outfit
|
||||
* they've built around the item — each outfit's own `lastTryOn`
|
||||
* snapshot provides the thumbnail without another image lookup.
|
||||
*/
|
||||
export function useOutfitsContainingGarment(garmentId: string | null) {
|
||||
return useLiveQueryWithDefault<Outfit[]>(async () => {
|
||||
if (!garmentId) return [];
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
|
||||
'wardrobe',
|
||||
'wardrobeOutfits'
|
||||
).toArray();
|
||||
const visible = locals
|
||||
.filter(
|
||||
(row) => !row.deletedAt && !row.isArchived && (row.garmentIds ?? []).includes(garmentId)
|
||||
)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('wardrobeOutfits', visible);
|
||||
return decrypted.map(toOutfit);
|
||||
}, [] as Outfit[]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
|
||||
import { useGarment } from '../queries';
|
||||
import { useGarment, useGarmentSoloTryOns, useOutfitsContainingGarment } from '../queries';
|
||||
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
|
||||
import { garmentPhotoUrl } from '../api/media-url';
|
||||
import { CATEGORY_LABELS } from '../constants';
|
||||
|
|
@ -26,6 +26,16 @@
|
|||
const garment$ = useGarment(id);
|
||||
const garment = $derived(garment$.value);
|
||||
|
||||
// Back-refs into Picture + sibling outfits. Both live-queries so
|
||||
// a fresh try-on (solo or via a linked outfit) pushes into the
|
||||
// strips without a manual reload.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const soloTryOns$ = useGarmentSoloTryOns(id);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const outfits$ = useOutfitsContainingGarment(id);
|
||||
const soloTryOns = $derived(soloTryOns$.value);
|
||||
const outfits = $derived(outfits$.value);
|
||||
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let markingWorn = $state(false);
|
||||
|
|
@ -221,5 +231,90 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solo try-on history — every image produced by
|
||||
runGarmentTryOn carrying this garment's wardrobeGarmentId FK.
|
||||
Clicking a thumb opens the full image in the Picture gallery
|
||||
so favoriting / download / delete etc. live in their canonical
|
||||
home. -->
|
||||
{#if soloTryOns.length > 0}
|
||||
<section class="space-y-2">
|
||||
<header class="flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Anproben · {soloTryOns.length}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">Einzelstück auf dir gerendert</span>
|
||||
</header>
|
||||
<div class="flex gap-3 overflow-x-auto pb-1">
|
||||
{#each soloTryOns as image (image.id)}
|
||||
<!-- Picture module doesn't have a /picture/image/[id] route;
|
||||
it opens generations inline via a modal in its ListView.
|
||||
Linking to the full publicUrl in a new tab gives the user
|
||||
the full-resolution view without a routing detour. A proper
|
||||
lightbox can come later when we reuse Picture's modal. -->
|
||||
<a
|
||||
href={image.publicUrl ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group block flex-shrink-0 overflow-hidden rounded-xl border border-border bg-muted transition-all hover:border-primary/50"
|
||||
title={image.prompt}
|
||||
>
|
||||
{#if image.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt={image.prompt}
|
||||
loading="lazy"
|
||||
class="h-40 w-28 object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Outfits containing this garment — reverse of DetailOutfitView's
|
||||
garment list. Each card links to /wardrobe/outfit/[id]; the
|
||||
lastTryOn snapshot (cached on the outfit row) gives an instant
|
||||
thumb without another image round-trip. -->
|
||||
{#if outfits.length > 0}
|
||||
<section class="space-y-2">
|
||||
<header class="flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
In Outfits · {outfits.length}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">Komposition öffnen</span>
|
||||
</header>
|
||||
<div class="flex gap-3 overflow-x-auto pb-1">
|
||||
{#each outfits as outfit (outfit.id)}
|
||||
<a
|
||||
href={`/wardrobe/outfit/${outfit.id}`}
|
||||
class="group block w-32 flex-shrink-0 space-y-1.5"
|
||||
title={outfit.name}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/3] overflow-hidden rounded-xl border border-border bg-muted transition-all group-hover:border-primary/50"
|
||||
>
|
||||
{#if outfit.lastTryOn?.imageUrl}
|
||||
<img
|
||||
src={outfit.lastTryOn.imageUrl}
|
||||
alt={outfit.name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
Noch keine Anprobe
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="truncate text-xs font-medium text-foreground">{outfit.name}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue