From 218cf450058607c87105617291c1d2876e66239e Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 14:08:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(wardrobe):=20M5.c=20=E2=80=94=20outfits=20?= =?UTF-8?q?adopt=20the=20unified=20visibility=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth consumer of @mana/shared-privacy. Wardrobe outfits now carry a VisibilityLevel flipped via in the outfit detail page; the wardrobe.outfits embed powers the style-portfolio use-case on the owner's website. Scope: outfits only, not individual garments. Outfits are the composite unit users curate for public presentation (an outfit is an intentional composition; a single garment rarely is). Garments inherit their outfit visibility implicitly — a public outfit reveals the look, the garment pieces behind it stay private at the record level. Changes: - wardrobe/types: visibility + unlistedToken + visibilityChangedAt + visibilityChangedBy on LocalWardrobeOutfit; Outfit (UI) requires visibility; toOutfit converter forwards with 'space' fallback - wardrobe/stores/outfits: createOutfit stamps defaultVisibilityFor(activeSpace.type); new setVisibility(id, level) mints/clears the unlisted token on the transition boundary and emits cross-module VisibilityChanged - wardrobe/views/DetailOutfitView: in the metadata header row, left of the favourite/edit icons — keeps the action rail tight while making exposure state glanceable website embed: - website-blocks/moduleEmbed/schema: 'wardrobe.outfits' added to EmbedSourceSchema - website/embeds: resolveWardrobeOutfits gates hard on canEmbedOnWebsite, filters archived + deleted, optional isFavorite / tagIds filters, favourites-first then newest. Inlines title + occasion/season meta + the lastTryOn.imageUrl (the AI-generated wearing shot). Description, garment details, and internal tag labels stay out of the public snapshot Verified: - pnpm check (web): 7450 files, 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/wardrobe/stores/outfits.svelte.ts | 41 +++++++++++++++ .../web/src/lib/modules/wardrobe/types.ts | 7 +++ .../wardrobe/views/DetailOutfitView.svelte | 13 ++++- .../web/src/lib/modules/website/embeds.ts | 50 +++++++++++++++++++ .../website-blocks/src/moduleEmbed/schema.ts | 1 + 5 files changed, 111 insertions(+), 1 deletion(-) diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts index 4ebf28d6e..5ccb9efe1 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/stores/outfits.svelte.ts @@ -9,6 +9,13 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import { wardrobeOutfitsTable } from '../collections'; import { toOutfit } from '../types'; import type { @@ -43,6 +50,7 @@ export const wardrobeOutfitsStore = { season: input.season, tags: input.tags ?? [], isFavorite: input.isFavorite ?? false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const snapshot = toOutfit({ ...newLocal }); await encryptRecord('wardrobeOutfits', newLocal); @@ -122,4 +130,37 @@ export const wardrobeOutfitsStore = { outfitId: id, }); }, + + /** + * Flip an outfit's visibility. Enables the style-portfolio use + * case — mark curated outfits 'public' so they appear in the + * wardrobe.outfits embed on the owner's website. + */ + async setVisibility(id: string, next: VisibilityLevel): Promise { + const existing = await wardrobeOutfitsTable.get(id); + if (!existing) throw new Error(`Outfit ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await wardrobeOutfitsTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'wardrobe', 'wardrobeOutfits', id, { + recordId: id, + collection: 'wardrobeOutfits', + before, + after: next, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts index c8387a948..feb0d635c 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/types.ts @@ -16,6 +16,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; // ─── Garment ────────────────────────────────────────────────────── @@ -173,6 +174,10 @@ export interface LocalWardrobeOutfit extends BaseRecord { isArchived?: boolean; lastTryOn?: OutfitTryOn | null; lastWornAt?: string | null; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; } export interface Outfit { @@ -187,6 +192,7 @@ export interface Outfit { isArchived?: boolean; lastTryOn?: OutfitTryOn; lastWornAt?: string; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } @@ -204,6 +210,7 @@ export function toOutfit(local: LocalWardrobeOutfit): Outfit { isArchived: local.isArchived, lastTryOn: local.lastTryOn ?? undefined, lastWornAt: local.lastWornAt ?? undefined, + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? '', updatedAt: local.updatedAt ?? '', }; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte index 589af39ea..c109d5d21 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte @@ -13,6 +13,7 @@
@@ -172,7 +178,12 @@ {/if}
-
+
+