mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(picture): M3 — boards adopt the unified visibility system (soft)
Second consumer of @mana/shared-privacy. Picture boards now carry a VisibilityLevel the owner flips from the board detail page via <VisibilityPicker>; the website embed resolver gates hard on canEmbedOnWebsite. This unblocks the picture.board embed — it had been effectively dead because the legacy `isPublic` bool had no UI toggle and thus stayed false for every row in practice. Soft migration (per the repo's soft-first/hard-follow-up rule). The legacy `isPublic` field is marked @deprecated on both LocalBoard and LocalImage but kept on the record so any reader that slipped through the grep still sees sane data. Converters fall back to `isPublic === true ? 'public' : 'private'` when visibility is missing, so legacy rows (pre-M3) route through the new gate with the same intent. Hard follow-up drops the field in a later PR once callers are clean. Changes: - picture/types: visibility + unlistedToken + visibilityChangedAt + visibilityChangedBy on LocalImage and LocalBoard; Image and Board (plaintext UI types) expose `visibility: VisibilityLevel` as a required field - picture/queries: toImage + toBoard forward visibility with the legacy-isPublic fallback described above - picture/stores/boards: createBoard stamps defaultVisibilityFor(activeSpace.type) instead of isPublic: false; duplicateBoard resets the clone to the space default (a copy of a public board does NOT auto-publish); new setVisibility(id, level) mints/clears the unlisted token on the transition boundary and emits the cross-module VisibilityChanged event - picture/collections: PICTURE_GUEST_SEED demo board starts with visibility: 'private' - picture/ListView + routes/picture/generate + wardrobe/try-on: constructed LocalImage seeds set `visibility: 'private'` instead of `isPublic: false` - website/embeds: resolvePictureBoard replaces the hard-coded isPublic check with canEmbedOnWebsite, reading visibility with the legacy fallback. Error message points users at the picture module's new picker - routes/picture/board/[id]: VisibilityPicker mounted in the header toolbar, left of the edit/delete buttons, wired through handleVisibilityChange → boardsStore.setVisibility Not in this PR: - Image-level visibility picker UI (record field is ready; no UI control yet — boards currently govern public exposure, per-image visibility is a later refinement if anyone asks) - Hard drop of the legacy isPublic column (M3.1 follow-up once a soak confirms nothing reads the old field) Verified: - pnpm check (web): 7450 files, 0 errors, 0 warnings - pnpm test picture + website + library: 23/23 - pnpm run validate:all: theme-tokens, theme-parity, crypto-registry, encrypted-tools all green Next: M4 — Calendar + Todo + Goals. New embed resolvers + new moduleEmbed source values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d5ae2f19b4
commit
0e9f574dfb
9 changed files with 100 additions and 17 deletions
|
|
@ -194,7 +194,7 @@
|
|||
fileSize: uf.file.size,
|
||||
width: dims?.width ?? null,
|
||||
height: dims?.height ?? null,
|
||||
isPublic: false,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
createdAt: nowIso,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const PICTURE_GUEST_SEED = {
|
|||
canvasWidth: 2000,
|
||||
canvasHeight: 1500,
|
||||
backgroundColor: '#1e1e2e',
|
||||
isPublic: false,
|
||||
visibility: 'private',
|
||||
},
|
||||
] satisfies LocalBoard[],
|
||||
boardItems: [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ export function toImage(local: LocalImage): Image {
|
|||
height: local.height ?? undefined,
|
||||
fileSize: local.fileSize ?? undefined,
|
||||
blurhash: local.blurhash ?? undefined,
|
||||
isPublic: local.isPublic,
|
||||
// Soft-migration fallback: rows written before M3 only have the
|
||||
// legacy `isPublic` flag; map it to the nearest visibility level.
|
||||
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
|
||||
isFavorite: local.isFavorite,
|
||||
downloadCount: local.downloadCount,
|
||||
rating: local.rating ?? undefined,
|
||||
|
|
@ -62,7 +64,7 @@ export function toBoard(local: LocalBoard): Board {
|
|||
canvasWidth: local.canvasWidth,
|
||||
canvasHeight: local.canvasHeight,
|
||||
backgroundColor: local.backgroundColor,
|
||||
isPublic: local.isPublic,
|
||||
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@
|
|||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord, decryptRecord } 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 type { LocalBoard, LocalBoardItem } from '../types';
|
||||
import { toBoard } from '../queries';
|
||||
|
||||
|
|
@ -39,7 +47,7 @@ export const boardsStore = {
|
|||
canvasWidth: 2000,
|
||||
canvasHeight: 1500,
|
||||
backgroundColor: input.backgroundColor || '#ffffff',
|
||||
isPublic: false,
|
||||
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
|
@ -125,6 +133,9 @@ export const boardsStore = {
|
|||
const newId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Duplicate inherits the same default-for-space visibility, not
|
||||
// the original's. A copy of a public board should NOT auto-
|
||||
// publish — the user has to explicitly flip visibility again.
|
||||
const duplicated: LocalBoard = {
|
||||
id: newId,
|
||||
name: `${original.name} (Kopie)`,
|
||||
|
|
@ -132,7 +143,7 @@ export const boardsStore = {
|
|||
canvasWidth: original.canvasWidth,
|
||||
canvasHeight: original.canvasHeight,
|
||||
backgroundColor: original.backgroundColor,
|
||||
isPublic: false,
|
||||
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
|
@ -168,4 +179,45 @@ export const boardsStore = {
|
|||
return { success: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the board's visibility. Mints/clears an unlisted token on the
|
||||
* transition boundary and emits the cross-module VisibilityChanged
|
||||
* event. Caller passes the raw level; no-op if it already matches.
|
||||
*/
|
||||
async setVisibility(id: string, next: VisibilityLevel) {
|
||||
error = null;
|
||||
try {
|
||||
const existing = await db.table<LocalBoard>('boards').get(id);
|
||||
if (!existing) return { success: false, error: 'Board not found' };
|
||||
const before: VisibilityLevel =
|
||||
existing.visibility ?? (existing.isPublic === true ? 'public' : 'private');
|
||||
if (before === next) return { success: true };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const patch: Partial<LocalBoard> = {
|
||||
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 db.table('boards').update(id, patch);
|
||||
|
||||
emitDomainEvent('VisibilityChanged', 'picture', 'boards', id, {
|
||||
recordId: id,
|
||||
collection: 'boards',
|
||||
before,
|
||||
after: next,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to set visibility';
|
||||
return { success: false, error };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
/**
|
||||
* How the image was created. 'text' is the classic prompt-only
|
||||
|
|
@ -25,7 +26,16 @@ export interface LocalImage extends BaseRecord {
|
|||
height?: number | null;
|
||||
fileSize?: number | null;
|
||||
blurhash?: string | null;
|
||||
isPublic: boolean;
|
||||
/**
|
||||
* @deprecated Use `visibility` instead. Kept for the soft-migration
|
||||
* window — will be dropped in the hard follow-up once no reader
|
||||
* references it. See docs/plans/visibility-system.md §M3.
|
||||
*/
|
||||
isPublic?: boolean;
|
||||
visibility?: VisibilityLevel;
|
||||
visibilityChangedAt?: string;
|
||||
visibilityChangedBy?: string;
|
||||
unlistedToken?: string;
|
||||
isFavorite: boolean;
|
||||
downloadCount: number;
|
||||
rating?: number | null;
|
||||
|
|
@ -51,7 +61,15 @@ export interface LocalBoard extends BaseRecord {
|
|||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
backgroundColor: string;
|
||||
isPublic: boolean;
|
||||
/**
|
||||
* @deprecated Use `visibility` instead. Kept during the M3 soft
|
||||
* migration — dropped in the hard follow-up.
|
||||
*/
|
||||
isPublic?: boolean;
|
||||
visibility?: VisibilityLevel;
|
||||
visibilityChangedAt?: string;
|
||||
visibilityChangedBy?: string;
|
||||
unlistedToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalBoardItem extends BaseRecord {
|
||||
|
|
@ -94,7 +112,7 @@ export interface Image {
|
|||
height?: number;
|
||||
fileSize?: number;
|
||||
blurhash?: string;
|
||||
isPublic: boolean;
|
||||
visibility: VisibilityLevel;
|
||||
isFavorite: boolean;
|
||||
downloadCount: number;
|
||||
rating?: number;
|
||||
|
|
@ -116,7 +134,7 @@ export interface Board {
|
|||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
backgroundColor: string;
|
||||
isPublic: boolean;
|
||||
visibility: VisibilityLevel;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
|
|||
format: 'png',
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
isPublic: false,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
|
|
@ -289,7 +289,7 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<Ru
|
|||
format: 'png',
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
isPublic: false,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
|
|||
}
|
||||
|
||||
/**
|
||||
* Picture-board: returns image items for a board that the owner marked
|
||||
* `isPublic=true`. Private boards return an error.
|
||||
* Picture-board: returns image items for a board whose owner flipped
|
||||
* its visibility to 'public' via the VisibilityPicker. `canEmbedOnWebsite`
|
||||
* is the hard gate; the soft-migration fallback maps legacy `isPublic`
|
||||
* rows (pre-M3) to the right level.
|
||||
*/
|
||||
async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> {
|
||||
if (!props.sourceId) {
|
||||
|
|
@ -72,8 +74,10 @@ async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]
|
|||
if (!rawBoard || rawBoard.deletedAt) {
|
||||
throw new Error('Board nicht gefunden');
|
||||
}
|
||||
if (!rawBoard.isPublic) {
|
||||
throw new Error('Board ist nicht öffentlich — setze "Öffentlich" im Picture-Modul');
|
||||
const boardVisibility =
|
||||
rawBoard.visibility ?? (rawBoard.isPublic === true ? 'public' : 'private');
|
||||
if (!canEmbedOnWebsite(boardVisibility)) {
|
||||
throw new Error('Board ist nicht öffentlich — setze es im Picture-Modul auf "Öffentlich"');
|
||||
}
|
||||
|
||||
const items = await db
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import type { BoardWithCount } from '$lib/modules/picture/types';
|
||||
import { CaretLeft, Trash, PencilSimple, Image } from '@mana/shared-icons';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
|
||||
|
||||
|
|
@ -40,6 +41,11 @@
|
|||
await boardsStore.deleteBoard(board.id);
|
||||
goto('/picture/board');
|
||||
}
|
||||
|
||||
async function handleVisibilityChange(next: VisibilityLevel) {
|
||||
if (!board) return;
|
||||
await boardsStore.setVisibility(board.id, next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -74,6 +80,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<VisibilityPicker level={board.visibility} onChange={handleVisibilityChange} />
|
||||
<button
|
||||
onclick={startEditing}
|
||||
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@
|
|||
format: 'png',
|
||||
width: currentAspect.width,
|
||||
height: currentAspect.height,
|
||||
isPublic: false,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: isReferenceMode ? 'reference' : 'text',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue