diff --git a/apps/mana/apps/web/src/lib/modules/cards/queries.ts b/apps/mana/apps/web/src/lib/modules/cards/queries.ts index 49a4851f5..1743c40fd 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/queries.ts @@ -18,7 +18,8 @@ export function toDeck(local: LocalDeck): Deck { title: local.name, description: local.description ?? undefined, color: local.color, - isPublic: local.isPublic, + isPublic: local.isPublic ?? local.visibility === 'public', + visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'), tags: [], cardCount: local.cardCount, createdAt: local.createdAt ?? new Date().toISOString(), diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts index 5775245e7..bcf4d49cb 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts @@ -10,6 +10,10 @@ import { db } from '$lib/data/database'; import { cardDeckTable, cardTable } from '../collections'; import { toDeck } from '../queries'; 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, type VisibilityLevel } from '@mana/shared-privacy'; import { createBlock, updateBlock } from '$lib/data/time-blocks/service'; import type { LocalDeck } from '../types'; import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types'; @@ -24,13 +28,17 @@ export const deckStore = { async createDeck(input: CreateDeckInput): Promise { error = null; try { + const initialPublic = input.isPublic ?? false; const newLocal: LocalDeck = { id: crypto.randomUUID(), name: input.title, description: input.description ?? null, color: '#6366f1', cardCount: 0, - isPublic: input.isPublic ?? false, + isPublic: initialPublic, + // Initialize the unified field too — if the create flow set + // isPublic, mirror it as 'public'; otherwise space-default. + visibility: initialPublic ? 'public' : defaultVisibilityFor(getActiveSpace()?.type), }; const plaintextSnapshot = toDeck(newLocal); @@ -51,7 +59,12 @@ export const deckStore = { const localUpdates: Partial = {}; if (updates.title !== undefined) localUpdates.name = updates.title; if (updates.description !== undefined) localUpdates.description = updates.description; - if (updates.isPublic !== undefined) localUpdates.isPublic = updates.isPublic; + if (updates.isPublic !== undefined) { + // Legacy callers still pass isPublic — mirror to visibility + // so the unified field stays in sync until M6.1 hard-drop. + localUpdates.isPublic = updates.isPublic; + localUpdates.visibility = updates.isPublic ? 'public' : 'space'; + } const diff: Partial = { ...localUpdates, @@ -65,6 +78,34 @@ export const deckStore = { } }, + /** + * Flip a deck's visibility. M6 soft-migration: writes both + * `visibility` and the legacy `isPublic` mirror so the picker + * coexists with the older "public" badge UI until M6.1 hard-drop. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await cardDeckTable.get(id); + if (!existing) throw new Error(`Deck ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space'); + if (before === next) return; + + const stamp = new Date().toISOString(); + await cardDeckTable.update(id, { + visibility: next, + isPublic: next === 'public', + visibilityChangedAt: stamp, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: stamp, + }); + + emitDomainEvent('VisibilityChanged', 'cards', 'cardDecks', id, { + recordId: id, + collection: 'cardDecks', + before, + after: next, + }); + }, + async deleteDeck(id: string) { error = null; try { diff --git a/apps/mana/apps/web/src/lib/modules/cards/types.ts b/apps/mana/apps/web/src/lib/modules/cards/types.ts index dec587a16..afc6829ca 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/types.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/types.ts @@ -3,6 +3,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export interface LocalDeck extends BaseRecord { name: string; @@ -10,7 +11,11 @@ export interface LocalDeck extends BaseRecord { color: string; cardCount: number; lastStudied?: string | null; + /** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */ isPublic: boolean; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; activeStudyBlockId?: string | null; } @@ -31,7 +36,9 @@ export interface Deck { title: string; description?: string; color: string; + /** @deprecated Use `visibility`. */ isPublic: boolean; + visibility: VisibilityLevel; tags: string[]; cardCount: number; createdAt: string; diff --git a/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte index 35fed2c52..9cfe6bb38 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte @@ -9,6 +9,7 @@ import { useDetailEntity } from '$lib/data/detail-entity.svelte'; import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { deckStore } from '../stores/decks.svelte'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; import type { ViewProps } from '$lib/app-registry'; import type { LocalDeck, LocalCard } from '../types'; @@ -18,7 +19,6 @@ let editName = $state(''); let editDescription = $state(''); let editColor = $state('#6366f1'); - let editIsPublic = $state(false); const detail = useDetailEntity({ id: () => deckId, @@ -27,7 +27,6 @@ editName = val.name; editDescription = val.description ?? ''; editColor = val.color ?? '#6366f1'; - editIsPublic = val.isPublic; }, }); @@ -51,7 +50,6 @@ await deckStore.updateDeck(deckId, { title: editName.trim() || detail.entity?.name || 'Unbenannt', description: editDescription.trim() || undefined, - isPublic: editIsPublic, }); // Color is not in UpdateDeckInput, update directly await db.table('decks').update(deckId, { @@ -59,11 +57,6 @@ updatedAt: new Date().toISOString(), }); } - - async function handlePublicToggle() { - editIsPublic = !editIsPublic; - await deckStore.updateDeck(deckId, { isPublic: editIsPublic }); - }
- Öffentlich - + Sichtbarkeit + deckStore.setVisibility(deckId, next)} + disabledLevels={['unlisted']} + />
diff --git a/apps/mana/apps/web/src/lib/modules/memoro/queries.ts b/apps/mana/apps/web/src/lib/modules/memoro/queries.ts index 065ba8878..760208981 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/queries.ts @@ -35,7 +35,12 @@ export function toMemo(local: LocalMemo): Memo { processingStatus: local.processingStatus, isArchived: local.isArchived, isPinned: local.isPinned, - isPublic: local.isPublic, + isPublic: local.isPublic ?? local.visibility === 'public', + // Soft-fallback during M6 soak: legacy rows use isPublic, new + // writes set visibility directly. Keep both in sync via the + // store's setVisibility/setPublic methods until M6.1 drops the + // legacy field. + visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'), language: local.language, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), diff --git a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts index 99cd94277..2c5523fa4 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts @@ -11,6 +11,9 @@ import { createArchiveOps } from '@mana/shared-stores'; import { MemoroEvents } from '@mana/shared-utils/analytics'; 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, type VisibilityLevel } from '@mana/shared-privacy'; import { transcribeAudio } from '$lib/voice/transcribe'; import { llmTaskQueue } from '$lib/llm-queue'; import { generateTitleTask } from '$lib/llm-tasks/generate-title'; @@ -43,6 +46,7 @@ export const memosStore = { isArchived: false, isPinned: false, isPublic: false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), blueprintId: data.blueprintId ?? null, language: data.language ?? null, // createdAt + updatedAt are required by LocalMemo's type but the @@ -168,6 +172,36 @@ export const memosStore = { archive: (id: string) => memoArchive.archive(id), unarchive: (id: string) => memoArchive.unarchive(id), + /** + * Flip a memo's visibility. M6 soft-migration: writes both + * `visibility` and the legacy `isPublic` mirror so older readers + * (search index, server snapshots) keep working until the M6.1 + * hard-drop. Public memos surface in the user's website embed + * once a memoro embed-resolver lands. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await memoTable.get(id); + if (!existing) throw new Error(`Memo ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space'); + if (before === next) return; + + const stamp = new Date().toISOString(); + await memoTable.update(id, { + visibility: next, + isPublic: next === 'public', + visibilityChangedAt: stamp, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: stamp, + }); + + emitDomainEvent('VisibilityChanged', 'memoro', 'memos', id, { + recordId: id, + collection: 'memos', + before, + after: next, + }); + }, + /** Pin a memo. */ async pin(id: string) { await memoTable.update(id, { diff --git a/apps/mana/apps/web/src/lib/modules/memoro/types.ts b/apps/mana/apps/web/src/lib/modules/memoro/types.ts index 8cdc8eae9..9ba9d810d 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/types.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/types.ts @@ -3,6 +3,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; @@ -15,7 +16,17 @@ export interface LocalMemo extends BaseRecord { processingStatus: ProcessingStatus; isArchived: boolean; isPinned: boolean; - isPublic: boolean; + /** + * @deprecated Soft-migrating to unified `visibility`. Kept for the + * soak window so the converter can fall back to `isPublic` for + * legacy rows that haven't been touched since the M6 rollout. + * Hard-drop once `visibility` has propagated to all rows. + */ + isPublic?: boolean; + /** Unified visibility (M6 pilot — replaces isPublic). */ + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; blueprintId: string | null; language: string | null; location?: Record; @@ -84,7 +95,9 @@ export interface Memo { processingStatus: ProcessingStatus; isArchived: boolean; isPinned: boolean; + /** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */ isPublic: boolean; + visibility: VisibilityLevel; language: string | null; createdAt: string; updatedAt: string; diff --git a/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte index 3b24b63cc..4c4a0573c 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte @@ -12,6 +12,7 @@ import type { QueuedTask } from '@mana/shared-llm'; import type { LlmTier } from '@mana/shared-llm'; import { PushPin } from '@mana/shared-icons'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; import type { ViewProps } from '$lib/app-registry'; import type { LocalMemo, ProcessingStatus } from '../types'; @@ -189,6 +190,15 @@ placeholder="z.B. de" />
+ +
+ Sichtbarkeit + memosStore.setVisibility(memoId, next)} + disabledLevels={['unlisted']} + /> +
diff --git a/apps/mana/apps/web/src/lib/modules/presi/queries.ts b/apps/mana/apps/web/src/lib/modules/presi/queries.ts index 9230c87f1..57ec34211 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/presi/queries.ts @@ -19,7 +19,8 @@ export function toDeck(local: LocalDeck): Deck { title: local.title, description: local.description ?? undefined, themeId: local.themeId ?? undefined, - isPublic: local.isPublic, + isPublic: local.isPublic ?? local.visibility === 'public', + visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'), createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts index 876e4a71b..464d4b063 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/presi/stores/decks.svelte.ts @@ -10,6 +10,10 @@ import { presiDeckTable, slideTable } from '../collections'; import { toDeck, toSlide } from '../queries'; import { PresiEvents } from '@mana/shared-utils/analytics'; 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, type VisibilityLevel } from '@mana/shared-privacy'; import { createBlock, updateBlock } from '$lib/data/time-blocks/service'; import type { LocalDeck, @@ -36,6 +40,7 @@ function createDecksStore() { description: dto.description || null, themeId: dto.themeId || null, isPublic: false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const plaintextSnapshot = toDeck(newLocal); await encryptRecord('presiDecks', newLocal); @@ -60,7 +65,11 @@ function createDecksStore() { if (dto.title !== undefined) localUpdates.title = dto.title; if (dto.description !== undefined) localUpdates.description = dto.description; if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId; - if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic; + if (dto.isPublic !== undefined) { + // Mirror to unified visibility during M6 soak. + localUpdates.isPublic = dto.isPublic; + localUpdates.visibility = dto.isPublic ? 'public' : 'space'; + } await encryptRecord('presiDecks', localUpdates); await presiDeckTable.update(id, localUpdates); @@ -72,6 +81,34 @@ function createDecksStore() { } } + async function setVisibility(id: string, next: VisibilityLevel): Promise { + try { + const existing = await presiDeckTable.get(id); + if (!existing) return false; + const before: VisibilityLevel = + existing.visibility ?? (existing.isPublic ? 'public' : 'space'); + if (before === next) return true; + const stamp = new Date().toISOString(); + await presiDeckTable.update(id, { + visibility: next, + isPublic: next === 'public', + visibilityChangedAt: stamp, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: stamp, + }); + emitDomainEvent('VisibilityChanged', 'presi', 'presiDecks', id, { + recordId: id, + collection: 'presiDecks', + before, + after: next, + }); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to set visibility'; + return false; + } + } + async function deleteDeck(id: string): Promise { error = null; try { @@ -219,6 +256,7 @@ function createDecksStore() { }, createDeck, updateDeck, + setVisibility, deleteDeck, createSlide, updateSlide, diff --git a/apps/mana/apps/web/src/lib/modules/presi/types.ts b/apps/mana/apps/web/src/lib/modules/presi/types.ts index 21389c413..20baf1c9c 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/types.ts +++ b/apps/mana/apps/web/src/lib/modules/presi/types.ts @@ -3,12 +3,17 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export interface LocalDeck extends BaseRecord { title: string; description?: string | null; themeId?: string | null; + /** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */ isPublic: boolean; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; activeRehearsalBlockId?: string | null; } @@ -34,7 +39,9 @@ export interface Deck { title: string; description?: string; themeId?: string; + /** @deprecated Use `visibility`. */ isPublic: boolean; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/presi/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/presi/views/DetailView.svelte index 45a09c4f3..f3ce9e82b 100644 --- a/apps/mana/apps/web/src/lib/modules/presi/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/presi/views/DetailView.svelte @@ -9,6 +9,7 @@ import { useDetailEntity } from '$lib/data/detail-entity.svelte'; import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { decksStore } from '../stores/decks.svelte'; + import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; import type { ViewProps } from '$lib/app-registry'; import type { LocalDeck, LocalSlide } from '../types'; @@ -17,7 +18,6 @@ let editTitle = $state(''); let editDescription = $state(''); - let editIsPublic = $state(false); const detail = useDetailEntity({ id: () => deckId, @@ -25,7 +25,6 @@ onLoad: (val) => { editTitle = val.title; editDescription = val.description ?? ''; - editIsPublic = val.isPublic; }, }); @@ -49,14 +48,8 @@ await decksStore.updateDeck(deckId, { title: editTitle.trim() || detail.entity?.title || 'Unbenannt', description: editDescription.trim() || undefined, - isPublic: editIsPublic, }); } - - async function handlePublicToggle() { - editIsPublic = !editIsPublic; - await decksStore.updateDeck(deckId, { isPublic: editIsPublic }); - }
- Öffentlich - + Sichtbarkeit + decksStore.setVisibility(deckId, next)} + disabledLevels={['unlisted']} + />
diff --git a/apps/mana/apps/web/src/lib/modules/uload/collections.ts b/apps/mana/apps/web/src/lib/modules/uload/collections.ts index aead39acb..ff1f893ea 100644 --- a/apps/mana/apps/web/src/lib/modules/uload/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/uload/collections.ts @@ -88,6 +88,7 @@ export const ULOAD_GUEST_SEED = { color: '#8b5cf6', icon: null, isPublic: false, + visibility: 'space', usageCount: 0, }, { @@ -97,6 +98,7 @@ export const ULOAD_GUEST_SEED = { color: '#3b82f6', icon: null, isPublic: false, + visibility: 'space', usageCount: 0, }, { @@ -106,6 +108,7 @@ export const ULOAD_GUEST_SEED = { color: '#10b981', icon: null, isPublic: false, + visibility: 'space', usageCount: 0, }, ] satisfies LocalTag[], diff --git a/apps/mana/apps/web/src/lib/modules/uload/queries.ts b/apps/mana/apps/web/src/lib/modules/uload/queries.ts index 41e3ce360..569704bd6 100644 --- a/apps/mana/apps/web/src/lib/modules/uload/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/uload/queries.ts @@ -42,7 +42,9 @@ export interface Tag { slug: string; color?: string; icon?: string; + /** @deprecated Use `visibility`. */ isPublic: boolean; + visibility: import('@mana/shared-privacy').VisibilityLevel; usageCount: number; createdAt: string; updatedAt: string; @@ -104,7 +106,8 @@ export function toTag(local: LocalTag): Tag { slug: local.slug, color: local.color ?? undefined, icon: local.icon ?? undefined, - isPublic: local.isPublic, + isPublic: local.isPublic ?? local.visibility === 'public', + visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'), usageCount: local.usageCount, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), diff --git a/apps/mana/apps/web/src/lib/modules/uload/types.ts b/apps/mana/apps/web/src/lib/modules/uload/types.ts index 591ba9102..79a390e3d 100644 --- a/apps/mana/apps/web/src/lib/modules/uload/types.ts +++ b/apps/mana/apps/web/src/lib/modules/uload/types.ts @@ -3,6 +3,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; export interface LocalLink extends BaseRecord { shortCode: string; @@ -29,7 +30,13 @@ export interface LocalTag extends BaseRecord { slug: string; color?: string | null; icon?: string | null; + /** + * @deprecated Use `visibility`. Mirror kept for the M6 soak window. + * No active CRUD UI yet — the field is set only by seed data and + * the future tag-management view will write `visibility` directly. + */ isPublic: boolean; + visibility?: VisibilityLevel; usageCount: number; }