mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
0e0d48acec
commit
218cf45005
5 changed files with 111 additions and 1 deletions
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const EmbedSourceSchema = z.enum([
|
|||
'goals.goals',
|
||||
'places.places',
|
||||
'recipes.recipes',
|
||||
'wardrobe.outfits',
|
||||
]);
|
||||
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue