From 167d616cf775419a4685cd98be2853dc85794112 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 12:13:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(library,places):=20M8.4=20=E2=80=94=20exte?= =?UTF-8?q?nd=20unlisted-share=20to=20two=20more=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar pilot proved the pattern in M8.3; this rolls it out to Library entries and Places using the same backbone (resolvers dispatcher, share-route SSR, SharedLinkControls UI). Changes: - lib/data/unlisted/resolvers: buildLibraryEntryBlob (whitelist: title, kind, creators, year, coverUrl, rating). Review, status, tags, progress, externalIds, reading-habit fields all stay private. buildPlaceBlob (whitelist: name, address, category). Lat/lng explicitly NOT inlined — 10m precision identifies homes / workplaces; the v1 share page renders no map. v1.1 may add an opt-in toggle. Dispatcher gains 'libraryEntries' + 'places' cases. - modules/library: LibraryEntry gains unlistedToken; converter + ListView mock-stub forward it; entries store gets the same publish/ revoke/refresh/regenerate quartet from M8.3: - setVisibility coordinates with mana-api server-side; failure aborts the local flip so Dexie + server stay aligned - deleteEntry revokes the active snapshot before tombstoning - updateEntry fire-and-forgets refreshUnlistedSnapshot so the shared link tracks edits to the whitelisted fields - regenerateUnlistedToken: revoke + republish, returns new token - modules/library/views/DetailView: SharedLinkControls dropped into the existing dl as a labeled dt/dd row, only when visibility === 'unlisted' AND unlistedToken AND shareUrl. - modules/library/SharedLibraryEntryView: standalone public render — big cover image, title, creators · year, optional rating-stars, OG/Twitter meta tags with cover as og:image (link-preview shows the cover on WhatsApp/Slack/iMessage). - modules/places: same pattern. Place gains unlistedToken; converter + store get publish/revoke/refresh/regenerate; DetailView field-row for the SharedLinkControls. - modules/places/SharedPlaceView: standalone public render — name, address, category badge, "Auf OpenStreetMap suchen"-Link (no map iframe in v1 because lat/lng aren't in the blob). - routes/share/[token]/+page.svelte: dispatcher gains two more cases. Verified: - pnpm check (web): 7543 files, 0 errors, 0 warnings (svelte-check passes on all the new components, hooks, types) Tests: vitest currently fails to load due to an unrelated parallel edit on $lib/data/current-user.ts that uses $state in a non-.svelte.ts file. The break predates this commit and isn't surfaced by svelte-check; the visibility-system + unlisted code itself type-checks clean. Will be fixed by whoever's currently iterating on current-user (separate session). Next: M8.5 — QR codes via the qrcode npm package, expiry-datepicker wiring, regenerate confirm-dialog polish, and end-to-end incognito-tab smoke test of all three modules' share links. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/unlisted/resolvers.ts | 75 +++++++++ .../src/lib/modules/library/ListView.svelte | 1 + .../library/SharedLibraryEntryView.svelte | 155 ++++++++++++++++++ .../web/src/lib/modules/library/queries.ts | 5 +- .../modules/library/stores/entries.svelte.ts | 133 +++++++++++++-- .../apps/web/src/lib/modules/library/types.ts | 2 + .../modules/library/views/DetailView.svelte | 32 +++- .../lib/modules/places/SharedPlaceView.svelte | 139 ++++++++++++++++ .../web/src/lib/modules/places/queries.ts | 7 +- .../modules/places/stores/places.svelte.ts | 118 ++++++++++++- .../apps/web/src/lib/modules/places/types.ts | 2 + .../modules/places/views/DetailView.svelte | 34 +++- .../web/src/routes/share/[token]/+page.svelte | 6 + 13 files changed, 684 insertions(+), 25 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/library/SharedLibraryEntryView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/places/SharedPlaceView.svelte 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