feat(wardrobe): M5.c — outfits adopt the unified visibility system

Eighth consumer of @mana/shared-privacy. Wardrobe outfits now carry a
VisibilityLevel flipped via <VisibilityPicker compact> 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: <VisibilityPicker compact> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 14:08:32 +02:00
parent 0e0d48acec
commit 218cf45005
5 changed files with 111 additions and 1 deletions

View file

@ -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<void> {
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<LocalWardrobeOutfit> = {
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,
});
},
};

View file

@ -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 ?? '',
};

View file

@ -13,6 +13,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Archive, Heart, PencilSimple, Trash } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { useAllGarments, useOutfit, useOutfitTryOns } from '../queries';
import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
import { garmentPhotoUrl } from '../api/media-url';
@ -69,6 +70,11 @@
await wardrobeOutfitsStore.deleteOutfit(outfit.id);
goto('/wardrobe');
}
async function handleVisibilityChange(next: VisibilityLevel) {
if (!outfit) return;
await wardrobeOutfitsStore.setVisibility(outfit.id, next);
}
</script>
<div class="mx-auto max-w-4xl space-y-5 p-4 sm:p-6">
@ -172,7 +178,12 @@
{/if}
</div>
</div>
<div class="flex gap-1">
<div class="flex items-center gap-1">
<VisibilityPicker
level={outfit.visibility ?? 'private'}
onChange={handleVisibilityChange}
compact
/>
<button
type="button"
onclick={handleToggleFavorite}

View file

@ -28,6 +28,7 @@ import type { LocalTaskTag } from '$lib/modules/todo/types';
import type { LocalGoal } from '$lib/companion/goals/types';
import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalRecipe } from '$lib/modules/recipes/types';
import type { LocalWardrobeOutfit } from '$lib/modules/wardrobe/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export interface ResolvedEmbed {
@ -63,6 +64,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'recipes.recipes':
items = await resolveRecipes(props);
break;
case 'wardrobe.outfits':
items = await resolveWardrobeOutfits(props);
break;
default:
return {
items: [],
@ -453,3 +457,49 @@ async function resolveRecipes(props: ModuleEmbedProps): Promise<EmbedItem[]> {
};
});
}
/**
* Wardrobe-outfits: style-portfolio use case. Returns outfits flipped
* to 'public' with their most recent try-on preview as the card image.
* Hard-gated on canEmbedOnWebsite.
*
* Whitelist: name + occasion/season line + the `lastTryOn.imageUrl`
* (which is just a mana-media URL pointing at an AI-generated wearing
* shot no facial identifier unless the user chose to share one).
* Individual garments, tags, and description stay out of the snapshot.
*/
async function resolveWardrobeOutfits(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let outfits = await db.table<LocalWardrobeOutfit>('wardrobeOutfits').toArray();
outfits = outfits.filter(
(o) => !o.deletedAt && !o.isArchived && canEmbedOnWebsite(o.visibility ?? 'private')
);
if (props.filter?.isFavorite === true) {
outfits = outfits.filter((o) => o.isFavorite === true);
}
if (props.filter?.tagIds?.length) {
const wanted = new Set(props.filter.tagIds);
outfits = outfits.filter((o) => (o.tags ?? []).some((t) => wanted.has(t)));
}
const decrypted = (await decryptRecords('wardrobeOutfits', outfits)) as LocalWardrobeOutfit[];
// Favourites first, then newest.
decrypted.sort((a, b) => {
const favA = a.isFavorite ? 0 : 1;
const favB = b.isFavorite ? 0 : 1;
if (favA !== favB) return favA - favB;
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
});
return decrypted.map((o) => {
const meta: string[] = [];
if (o.occasion) meta.push(o.occasion);
if (o.season?.length) meta.push(o.season.join(', '));
return {
title: o.name,
subtitle: meta.length > 0 ? meta.join(' · ') : undefined,
imageUrl: o.lastTryOn?.imageUrl ?? undefined,
};
});
}

View file

@ -34,6 +34,7 @@ export const EmbedSourceSchema = z.enum([
'goals.goals',
'places.places',
'recipes.recipes',
'wardrobe.outfits',
]);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;