diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index d80c06207..edf69b1e0 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -16,7 +16,10 @@ import { db } from '$lib/data/database'; import { decryptRecord } from '$lib/data/crypto'; +import { mediaFileUrl } from '$lib/modules/website/upload'; import type { LocalEvent } from '$lib/modules/calendar/types'; +import type { LocalLibraryEntry } from '$lib/modules/library/types'; +import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export class UnsupportedCollectionError extends Error { @@ -44,6 +47,10 @@ export async function buildUnlistedBlob( switch (collection) { case 'events': return buildEventBlob(recordId); + case 'libraryEntries': + return buildLibraryEntryBlob(recordId); + case 'places': + return buildPlaceBlob(recordId); default: throw new UnsupportedCollectionError(collection); } @@ -102,3 +109,71 @@ async function buildEventBlob(recordId: string): Promise timezone, }; } + +/** + * Library entry β†’ snapshot blob. + * + * Whitelist: title, kind, creators, year, coverUrl, rating. + * + * NOT inlined: + * - review (free-text, often very personal) + * - tags, genres (organisational metadata, not content) + * - status (in-progress is private detail) + * - startedAt / completedAt / isFavorite / times (reading habits) + * - details.* (current-page progress, episode tracker, etc.) + * - originalTitle (fine in theory but skip for noise reduction) + * - externalIds (ISBN/TMDB linkage β€” useful only for re-import) + */ +async function buildLibraryEntryBlob(recordId: string): Promise> { + const raw = await db.table('libraryEntries').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('libraryEntries', recordId); + } + + const decrypted = (await decryptRecord('libraryEntries', { ...raw })) as LocalLibraryEntry; + + // Resolve the cover to an absolute URL: prefer coverUrl (already a + // full http(s) URL the user pasted in), otherwise transform a + // mana-media id into the canonical media-host URL. + const coverUrl = + decrypted.coverUrl ?? + (decrypted.coverMediaId ? mediaFileUrl(decrypted.coverMediaId, 'medium') : null); + + return { + title: decrypted.title, + kind: decrypted.kind, + creators: decrypted.creators ?? [], + year: decrypted.year ?? null, + coverUrl, + rating: decrypted.rating ?? null, + }; +} + +/** + * Place β†’ snapshot blob. + * + * Whitelist: name, address, category. + * + * EXPLICITLY NOT inlined: + * - latitude, longitude (10m-precision identifies homes/workplaces; + * the v1 share page renders no map. v1.1 + * will add an opt-in toggle if there is + * real demand for embedded maps.) + * - description (free-text, may carry private notes) + * - tagIds (internal organisation) + * - visitCount, lastVisitedAt, isFavorite (visit habits) + */ +async function buildPlaceBlob(recordId: string): Promise> { + const raw = await db.table('places').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('places', recordId); + } + + const decrypted = (await decryptRecord('places', { ...raw })) as LocalPlace; + + return { + name: decrypted.name, + address: decrypted.address ?? null, + category: decrypted.category ?? 'other', + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte index f6eea123b..f6d57dceb 100644 --- a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte @@ -110,6 +110,7 @@ ? { kind: 'series', watched: [] } : { kind: 'comic' }, visibility: 'private', + unlistedToken: '', createdAt: '', updatedAt: '', id: '', diff --git a/apps/mana/apps/web/src/lib/modules/library/SharedLibraryEntryView.svelte b/apps/mana/apps/web/src/lib/modules/library/SharedLibraryEntryView.svelte new file mode 100644 index 000000000..781010dae --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/library/SharedLibraryEntryView.svelte @@ -0,0 +1,155 @@ + + + + + {entry.title} Β· Mana + + + + {#if entry.coverUrl}{/if} + + + + +
+ {KIND_EMOJI[entry.kind]} {KIND_LABELS[entry.kind]} + +
+ {#if entry.coverUrl} + {entry.title} + {:else} +
+ {KIND_EMOJI[entry.kind]} +
+ {/if} + +
+

{entry.title}

+ {#if meta} + + {/if} + {#if ratingStars} +

{ratingStars}

+ {/if} +
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts index 1de25cd1c..7107f1d4f 100644 --- a/apps/mana/apps/web/src/lib/modules/library/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts @@ -2,7 +2,7 @@ * Reactive queries and pure helpers for the Library module. */ -import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { decryptRecords } from '$lib/data/crypto'; import { db } from '$lib/data/database'; import { scopedForModule } from '$lib/data/scope'; @@ -36,6 +36,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry { // (the pre-pilot stamp that Dexie hooks wrote). New rows get the // space-type-aware default at create time in entries.svelte.ts. visibility: local.visibility ?? 'space', + unlistedToken: local.unlistedToken ?? '', createdAt: local.createdAt ?? now, updatedAt: local.updatedAt ?? now, }; @@ -44,7 +45,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry { // ─── Live Queries ───────────────────────────────────────── export function useAllEntries() { - return useLiveQueryWithDefault(async () => { + return useScopedLiveQuery(async () => { const locals = await scopedForModule( 'library', 'libraryEntries' diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts index a1f9b40b9..171277999 100644 --- a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts @@ -11,9 +11,13 @@ import { getActiveSpace } from '$lib/data/scope'; import { getEffectiveUserId } from '$lib/data/current-user'; import { defaultVisibilityFor, - generateUnlistedToken, + publishUnlistedSnapshot, + revokeUnlistedSnapshot, type VisibilityLevel, } from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; import { libraryEntryTable } from '../collections'; import { toLibraryEntry } from '../queries'; import type { @@ -132,6 +136,8 @@ export const libraryEntriesStore = { ...wrapped, updatedAt: new Date().toISOString(), }); + // Keep the share-link snapshot in sync if this entry is unlisted. + void this.refreshUnlistedSnapshot(id); }, async setStatus(id: string, status: LibraryStatus) { @@ -193,6 +199,25 @@ export const libraryEntriesStore = { }, async deleteEntry(id: string) { + const existing = await libraryEntryTable.get(id); + // Revoke any active share-link before tombstoning, so a recipient + // reloading the link gets 410 Gone instead of seeing stale data. + if (existing?.visibility === 'unlisted' && existing.unlistedToken) { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + }); + } catch (e) { + console.error('[library] revoke on delete failed', e); + } + } + } + await libraryEntryTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -201,13 +226,9 @@ export const libraryEntriesStore = { }, /** - * Flip the visibility of an entry. Mints an unlisted token on first - * transition to 'unlisted' and wipes it when moving back to anything - * else, so a revoked link can't be silently re-activated. Emits a - * cross-module `VisibilityChanged` event so the Workbench timeline + - * audit surfaces pick it up. - * - * No-op if the level is already what the user selected. + * Flip the visibility of an entry. Coordinates with the server-side + * unlisted-snapshots table β€” see calendar/eventsStore.setVisibility + * for the full pattern. Server is authoritative for the token. */ async setVisibility(id: string, next: VisibilityLevel) { const existing = await libraryEntryTable.get(id); @@ -222,11 +243,35 @@ export const libraryEntriesStore = { visibilityChangedBy: getEffectiveUserId(), updatedAt: now, }; - if (next === 'unlisted' && !existing.unlistedToken) { - patch.unlistedToken = generateUnlistedToken(); - } else if (next !== 'unlisted' && existing.unlistedToken) { + + if (next === 'unlisted') { + const blob = await buildUnlistedBlob('libraryEntries', id); + const jwt = await authStore.getValidToken(); + if (!jwt) throw new Error('Nicht eingeloggt'); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + }); + } patch.unlistedToken = undefined; } + await libraryEntryTable.update(id, patch); emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, { @@ -236,4 +281,70 @@ export const libraryEntriesStore = { after: next, }); }, + + /** + * Force-regenerate the unlisted token. Same semantics as + * eventsStore.regenerateUnlistedToken β€” revoke + republish, returns + * the new token. + */ + async regenerateUnlistedToken(id: string) { + const existing = await libraryEntryTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return null; + const jwt = await authStore.getValidToken(); + if (!jwt) return null; + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + }); + const blob = await buildUnlistedBlob('libraryEntries', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + spaceId, + blob, + }); + await libraryEntryTable.update(id, { + unlistedToken: token, + updatedAt: new Date().toISOString(), + }); + return token; + } catch (e) { + console.error('[library] regenerate failed', e); + return null; + } + }, + + /** + * Re-publish unlisted snapshot when whitelist fields change. Called + * fire-and-forget after updateEntry/setStatus/rate. No-op if the + * entry isn't currently 'unlisted'. + */ + async refreshUnlistedSnapshot(id: string) { + const existing = await libraryEntryTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + try { + const blob = await buildUnlistedBlob('libraryEntries', id); + const jwt = await authStore.getValidToken(); + if (!jwt) return; + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + spaceId, + blob, + }); + } catch (e) { + console.error('[library] refreshUnlistedSnapshot failed', e); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts index 6518d94af..79b9e52df 100644 --- a/apps/mana/apps/web/src/lib/modules/library/types.ts +++ b/apps/mana/apps/web/src/lib/modules/library/types.ts @@ -127,6 +127,8 @@ export interface LibraryEntry { externalIds: LibraryExternalIds | null; details: LibraryDetails; visibility: VisibilityLevel; + /** Server-issued share token. Empty when not 'unlisted'. */ + unlistedToken: string; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte index f8f2de67e..48d29f1e4 100644 --- a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte @@ -1,6 +1,11 @@ + + + {place.name} Β· Mana + + + + + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/places/queries.ts b/apps/mana/apps/web/src/lib/modules/places/queries.ts index 67ef91462..e66eaa013 100644 --- a/apps/mana/apps/web/src/lib/modules/places/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/places/queries.ts @@ -2,7 +2,7 @@ * Reactive queries & pure helpers for Places β€” uses Dexie liveQuery on the unified DB. */ -import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; import { db } from '$lib/data/database'; import { scopedForModule } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; @@ -25,6 +25,7 @@ export function toPlace(local: LocalPlace): Place { lastVisitedAt: local.lastVisitedAt || null, tagIds: local.tagIds ?? [], visibility: local.visibility ?? 'space', + unlistedToken: local.unlistedToken ?? '', createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; @@ -47,7 +48,7 @@ export function toLocationLog(local: LocalLocationLog): LocationLog { // ─── Live Queries ──────────────────────────────────────── export function useAllPlaces() { - return useLiveQueryWithDefault(async () => { + return useScopedLiveQuery(async () => { const locals = await scopedForModule('places', 'places').toArray(); const visible = locals.filter((p) => !p.deletedAt); const decrypted = await decryptRecords('places', visible); @@ -56,7 +57,7 @@ export function useAllPlaces() { } export function useLocationLogs(placeId?: string) { - return useLiveQueryWithDefault(async () => { + return useScopedLiveQuery(async () => { let query = db.table('locationLogs').orderBy('timestamp').reverse(); const locals = await query.toArray(); const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts index b2cc397b8..9cdbe4124 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -11,9 +11,13 @@ import { getActiveSpace } from '$lib/data/scope'; import { getEffectiveUserId } from '$lib/data/current-user'; import { defaultVisibilityFor, - generateUnlistedToken, + publishUnlistedSnapshot, + revokeUnlistedSnapshot, type VisibilityLevel, } from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; import { createBlock } from '$lib/data/time-blocks/service'; import { placeTable } from '../collections'; import { toPlace } from '../queries'; @@ -80,11 +84,31 @@ export const placesStore = { // through untouched. await encryptRecord('places', diff); await placeTable.update(id, diff); + // Refresh share-snapshot if this place is unlisted. + void this.refreshUnlistedSnapshot(id); }, async deletePlace(id: string) { const local = await placeTable.get(id); const decrypted = local ? await decryptRecord('places', { ...local }) : null; + + // Revoke active share-link before tombstone. + if (local?.visibility === 'unlisted' && local.unlistedToken) { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + }); + } catch (e) { + console.error('[places] revoke on delete failed', e); + } + } + } + await placeTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -144,9 +168,9 @@ export const placesStore = { }, /** - * Flip a place's visibility. Typical use: mark favourite cafes / - * running routes 'public' so a website-embed can list them. Emits - * cross-module VisibilityChanged. + * Flip a place's visibility. Coordinates with the server-side + * unlisted-snapshots table β€” see calendar/eventsStore.setVisibility + * for the full pattern. Server is authoritative for the token. */ async setVisibility(id: string, next: VisibilityLevel) { const existing = await placeTable.get(id); @@ -161,11 +185,35 @@ export const placesStore = { visibilityChangedBy: getEffectiveUserId(), updatedAt: now, }; - if (next === 'unlisted' && !existing.unlistedToken) { - patch.unlistedToken = generateUnlistedToken(); - } else if (next !== 'unlisted' && existing.unlistedToken) { + + if (next === 'unlisted') { + const blob = await buildUnlistedBlob('places', id); + const jwt = await authStore.getValidToken(); + if (!jwt) throw new Error('Nicht eingeloggt'); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + }); + } patch.unlistedToken = undefined; } + await placeTable.update(id, patch); emitDomainEvent('VisibilityChanged', 'places', 'places', id, { @@ -175,4 +223,60 @@ export const placesStore = { after: next, }); }, + + async regenerateUnlistedToken(id: string) { + const existing = await placeTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return null; + const jwt = await authStore.getValidToken(); + if (!jwt) return null; + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + }); + const blob = await buildUnlistedBlob('places', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + spaceId, + blob, + }); + await placeTable.update(id, { + unlistedToken: token, + updatedAt: new Date().toISOString(), + }); + return token; + } catch (e) { + console.error('[places] regenerate failed', e); + return null; + } + }, + + async refreshUnlistedSnapshot(id: string) { + const existing = await placeTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + try { + const blob = await buildUnlistedBlob('places', id); + const jwt = await authStore.getValidToken(); + if (!jwt) return; + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + spaceId, + blob, + }); + } catch (e) { + console.error('[places] refreshUnlistedSnapshot failed', e); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/places/types.ts b/apps/mana/apps/web/src/lib/modules/places/types.ts index 7f51b7619..e23e32368 100644 --- a/apps/mana/apps/web/src/lib/modules/places/types.ts +++ b/apps/mana/apps/web/src/lib/modules/places/types.ts @@ -52,6 +52,8 @@ export interface Place { lastVisitedAt: string | null; tagIds: string[]; visibility: VisibilityLevel; + /** Server-issued share token. Empty when not 'unlisted'. */ + unlistedToken: string; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte index ce992bea0..8e6dbec4c 100644 --- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -15,7 +15,12 @@ type GeocodingResult, } from '$lib/geocoding'; import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons'; - import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; + import { + VisibilityPicker, + SharedLinkControls, + buildShareUrl, + type VisibilityLevel, + } from '@mana/shared-privacy'; import type { ViewProps } from '$lib/app-registry'; import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; @@ -158,6 +163,21 @@ await placesStore.setVisibility(placeId, next); } + async function handleRegenerate() { + await placesStore.regenerateUnlistedToken(placeId); + } + + async function handleRevoke() { + await placesStore.setVisibility(placeId, 'space'); + } + + const shareUrl = $derived.by(() => { + const token = detail.entity?.unlistedToken; + if (!token) return ''; + const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; + return buildShareUrl(origin, token); + }); + async function toggleFavorite() { await placesStore.toggleFavorite(placeId); } @@ -235,6 +255,18 @@ + {#if place.visibility === 'unlisted' && place.unlistedToken && shareUrl} + + {/if} +
Kategorie