mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23: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,
|
fileSize: uf.file.size,
|
||||||
width: dims?.width ?? null,
|
width: dims?.width ?? null,
|
||||||
height: dims?.height ?? null,
|
height: dims?.height ?? null,
|
||||||
isPublic: false,
|
visibility: 'private',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
createdAt: nowIso,
|
createdAt: nowIso,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const PICTURE_GUEST_SEED = {
|
||||||
canvasWidth: 2000,
|
canvasWidth: 2000,
|
||||||
canvasHeight: 1500,
|
canvasHeight: 1500,
|
||||||
backgroundColor: '#1e1e2e',
|
backgroundColor: '#1e1e2e',
|
||||||
isPublic: false,
|
visibility: 'private',
|
||||||
},
|
},
|
||||||
] satisfies LocalBoard[],
|
] satisfies LocalBoard[],
|
||||||
boardItems: [
|
boardItems: [
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ export function toImage(local: LocalImage): Image {
|
||||||
height: local.height ?? undefined,
|
height: local.height ?? undefined,
|
||||||
fileSize: local.fileSize ?? undefined,
|
fileSize: local.fileSize ?? undefined,
|
||||||
blurhash: local.blurhash ?? 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,
|
isFavorite: local.isFavorite,
|
||||||
downloadCount: local.downloadCount,
|
downloadCount: local.downloadCount,
|
||||||
rating: local.rating ?? undefined,
|
rating: local.rating ?? undefined,
|
||||||
|
|
@ -62,7 +64,7 @@ export function toBoard(local: LocalBoard): Board {
|
||||||
canvasWidth: local.canvasWidth,
|
canvasWidth: local.canvasWidth,
|
||||||
canvasHeight: local.canvasHeight,
|
canvasHeight: local.canvasHeight,
|
||||||
backgroundColor: local.backgroundColor,
|
backgroundColor: local.backgroundColor,
|
||||||
isPublic: local.isPublic,
|
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@
|
||||||
|
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
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 type { LocalBoard, LocalBoardItem } from '../types';
|
||||||
import { toBoard } from '../queries';
|
import { toBoard } from '../queries';
|
||||||
|
|
||||||
|
|
@ -39,7 +47,7 @@ export const boardsStore = {
|
||||||
canvasWidth: 2000,
|
canvasWidth: 2000,
|
||||||
canvasHeight: 1500,
|
canvasHeight: 1500,
|
||||||
backgroundColor: input.backgroundColor || '#ffffff',
|
backgroundColor: input.backgroundColor || '#ffffff',
|
||||||
isPublic: false,
|
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -125,6 +133,9 @@ export const boardsStore = {
|
||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
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 = {
|
const duplicated: LocalBoard = {
|
||||||
id: newId,
|
id: newId,
|
||||||
name: `${original.name} (Kopie)`,
|
name: `${original.name} (Kopie)`,
|
||||||
|
|
@ -132,7 +143,7 @@ export const boardsStore = {
|
||||||
canvasWidth: original.canvasWidth,
|
canvasWidth: original.canvasWidth,
|
||||||
canvasHeight: original.canvasHeight,
|
canvasHeight: original.canvasHeight,
|
||||||
backgroundColor: original.backgroundColor,
|
backgroundColor: original.backgroundColor,
|
||||||
isPublic: false,
|
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
@ -168,4 +179,45 @@ export const boardsStore = {
|
||||||
return { success: false, error };
|
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 { BaseRecord } from '@mana/local-store';
|
||||||
|
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the image was created. 'text' is the classic prompt-only
|
* How the image was created. 'text' is the classic prompt-only
|
||||||
|
|
@ -25,7 +26,16 @@ export interface LocalImage extends BaseRecord {
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
fileSize?: number | null;
|
fileSize?: number | null;
|
||||||
blurhash?: string | 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;
|
isFavorite: boolean;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
rating?: number | null;
|
rating?: number | null;
|
||||||
|
|
@ -51,7 +61,15 @@ export interface LocalBoard extends BaseRecord {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
backgroundColor: string;
|
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 {
|
export interface LocalBoardItem extends BaseRecord {
|
||||||
|
|
@ -94,7 +112,7 @@ export interface Image {
|
||||||
height?: number;
|
height?: number;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
blurhash?: string;
|
blurhash?: string;
|
||||||
isPublic: boolean;
|
visibility: VisibilityLevel;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
|
@ -116,7 +134,7 @@ export interface Board {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
isPublic: boolean;
|
visibility: VisibilityLevel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
|
||||||
format: 'png',
|
format: 'png',
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
isPublic: false,
|
visibility: 'private',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
generationMode: 'reference',
|
generationMode: 'reference',
|
||||||
|
|
@ -289,7 +289,7 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<Ru
|
||||||
format: 'png',
|
format: 'png',
|
||||||
width: dims.width,
|
width: dims.width,
|
||||||
height: dims.height,
|
height: dims.height,
|
||||||
isPublic: false,
|
visibility: 'private',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
generationMode: 'reference',
|
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
|
* Picture-board: returns image items for a board whose owner flipped
|
||||||
* `isPublic=true`. Private boards return an error.
|
* 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[]> {
|
async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> {
|
||||||
if (!props.sourceId) {
|
if (!props.sourceId) {
|
||||||
|
|
@ -72,8 +74,10 @@ async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]
|
||||||
if (!rawBoard || rawBoard.deletedAt) {
|
if (!rawBoard || rawBoard.deletedAt) {
|
||||||
throw new Error('Board nicht gefunden');
|
throw new Error('Board nicht gefunden');
|
||||||
}
|
}
|
||||||
if (!rawBoard.isPublic) {
|
const boardVisibility =
|
||||||
throw new Error('Board ist nicht öffentlich — setze "Öffentlich" im Picture-Modul');
|
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
|
const items = await db
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import type { BoardWithCount } from '$lib/modules/picture/types';
|
import type { BoardWithCount } from '$lib/modules/picture/types';
|
||||||
import { CaretLeft, Trash, PencilSimple, Image } from '@mana/shared-icons';
|
import { CaretLeft, Trash, PencilSimple, Image } from '@mana/shared-icons';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
||||||
|
|
||||||
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
|
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
|
||||||
|
|
||||||
|
|
@ -40,6 +41,11 @@
|
||||||
await boardsStore.deleteBoard(board.id);
|
await boardsStore.deleteBoard(board.id);
|
||||||
goto('/picture/board');
|
goto('/picture/board');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleVisibilityChange(next: VisibilityLevel) {
|
||||||
|
if (!board) return;
|
||||||
|
await boardsStore.setVisibility(board.id, next);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -74,6 +80,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<VisibilityPicker level={board.visibility} onChange={handleVisibilityChange} />
|
||||||
<button
|
<button
|
||||||
onclick={startEditing}
|
onclick={startEditing}
|
||||||
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
format: 'png',
|
format: 'png',
|
||||||
width: currentAspect.width,
|
width: currentAspect.width,
|
||||||
height: currentAspect.height,
|
height: currentAspect.height,
|
||||||
isPublic: false,
|
visibility: 'private',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
generationMode: isReferenceMode ? 'reference' : 'text',
|
generationMode: isReferenceMode ? 'reference' : 'text',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue