feat(library,places): M8.4 — extend unlisted-share to two more modules

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 12:13:36 +02:00
parent bad935c258
commit 167d616cf7
13 changed files with 684 additions and 25 deletions

View file

@ -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<Record<string, unknown>
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<Record<string, unknown>> {
const raw = await db.table<LocalLibraryEntry>('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<Record<string, unknown>> {
const raw = await db.table<LocalPlace>('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',
};
}

View file

@ -110,6 +110,7 @@
? { kind: 'series', watched: [] }
: { kind: 'comic' },
visibility: 'private',
unlistedToken: '',
createdAt: '',
updatedAt: '',
id: '',

View file

@ -0,0 +1,155 @@
<!--
Shared-Library-Entry view — public render of a book / film / series /
comic behind an unlisted share link.
Whitelist (set by buildLibraryEntryBlob): title, kind, creators, year,
coverUrl, rating. Review, status, tags, progress all stay private.
-->
<script lang="ts">
interface LibraryEntryBlob {
title: string;
kind: 'book' | 'movie' | 'series' | 'comic';
creators: string[];
year: number | null;
coverUrl: string | null;
rating: number | null;
}
let {
blob,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const entry = $derived(blob as unknown as LibraryEntryBlob);
const KIND_LABELS: Record<LibraryEntryBlob['kind'], string> = {
book: 'Buch',
movie: 'Film',
series: 'Serie',
comic: 'Comic',
};
const KIND_EMOJI: Record<LibraryEntryBlob['kind'], string> = {
book: '📖',
movie: '🎬',
series: '📺',
comic: '💥',
};
const creatorsLine = $derived(entry.creators?.join(' · ') ?? '');
const meta = $derived(
[creatorsLine, entry.year ? String(entry.year) : ''].filter(Boolean).join(' · ')
);
const ratingStars = $derived(
typeof entry.rating === 'number' ? '★'.repeat(entry.rating) + '☆'.repeat(5 - entry.rating) : ''
);
const ogDescription = $derived(meta || KIND_LABELS[entry.kind]);
</script>
<svelte:head>
<title>{entry.title} · Mana</title>
<meta name="robots" content="noindex, nofollow" />
<meta property="og:title" content={entry.title} />
<meta property="og:description" content={ogDescription} />
{#if entry.coverUrl}<meta property="og:image" content={entry.coverUrl} />{/if}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<article class="entry">
<span class="entry__kind">{KIND_EMOJI[entry.kind]} {KIND_LABELS[entry.kind]}</span>
<div class="entry__hero">
{#if entry.coverUrl}
<img class="entry__cover" src={entry.coverUrl} alt={entry.title} />
{:else}
<div class="entry__cover entry__cover--placeholder">
<span class="entry__cover-emoji">{KIND_EMOJI[entry.kind]}</span>
</div>
{/if}
<div class="entry__head">
<h1 class="entry__title">{entry.title}</h1>
{#if meta}
<p class="entry__meta">{meta}</p>
{/if}
{#if ratingStars}
<p class="entry__rating" title={`${entry.rating}/5`}>{ratingStars}</p>
{/if}
</div>
</div>
</article>
<style>
.entry {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.entry__kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
font-weight: 600;
}
.entry__hero {
display: grid;
grid-template-columns: minmax(160px, 200px) 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 480px) {
.entry__hero {
grid-template-columns: 1fr;
}
}
.entry__cover {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 0.625rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.entry__cover--placeholder {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.04);
}
.entry__cover-emoji {
font-size: 4rem;
}
.entry__title {
margin: 0;
font-size: 1.85rem;
line-height: 1.15;
font-weight: 700;
}
.entry__meta {
margin: 0.5rem 0 0;
color: #6b7280;
font-size: 0.9375rem;
}
.entry__rating {
margin: 0.75rem 0 0;
color: #f59e0b;
font-size: 1.25rem;
letter-spacing: 0.1em;
}
@media (prefers-color-scheme: dark) {
.entry__kind,
.entry__meta {
color: #9ca3af;
}
.entry__cover--placeholder {
background: rgba(255, 255, 255, 0.06);
}
}
</style>

View file

@ -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<LocalLibraryEntry, string>(
'library',
'libraryEntries'

View file

@ -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);
}
},
};

View file

@ -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;
}

View file

@ -1,6 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
import CoverImage from '../components/CoverImage.svelte';
import RatingStars from '../components/RatingStars.svelte';
import EntryForm from '../components/EntryForm.svelte';
@ -15,6 +20,20 @@
await libraryEntriesStore.setVisibility(entry.id, next);
}
async function onRegenerate() {
await libraryEntriesStore.regenerateUnlistedToken(entry.id);
}
async function onRevoke() {
await libraryEntriesStore.setVisibility(entry.id, 'space');
}
const shareUrl = $derived.by(() => {
if (!entry.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, entry.unlistedToken);
});
let editing = $state(false);
const STATUS_ORDER: LibraryStatus[] = ['planned', 'active', 'completed', 'paused', 'dropped'];
@ -142,6 +161,17 @@
<dd>
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
</dd>
{#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl}
<dt>Link</dt>
<dd>
<SharedLinkControls
token={entry.unlistedToken}
url={shareUrl}
{onRegenerate}
{onRevoke}
/>
</dd>
{/if}
{#if entry.details.kind === 'book'}
{#if entry.details.pages}
<dt>Seiten</dt>

View file

@ -0,0 +1,139 @@
<!--
Shared-Place view — public render of a place behind an unlisted
share link.
Whitelist (set by buildPlaceBlob): name, address, category. Lat/lng
intentionally NOT inlined — the v1 share page renders no map.
v1.1 may add an opt-in toggle.
-->
<script lang="ts">
type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
interface PlaceBlob {
name: string;
address: string | null;
category: PlaceCategory;
}
let {
blob,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const place = $derived(blob as unknown as PlaceBlob);
const CATEGORY_LABELS: Record<PlaceCategory, string> = {
home: 'Zuhause',
work: 'Arbeit',
food: 'Essen & Trinken',
shopping: 'Einkaufen',
transit: 'Transit',
leisure: 'Freizeit',
other: 'Ort',
};
const CATEGORY_EMOJI: Record<PlaceCategory, string> = {
home: '🏠',
work: '🏢',
food: '🍽️',
shopping: '🛍️',
transit: '🚆',
leisure: '🎨',
other: '📍',
};
const ogDescription = $derived(place.address ?? CATEGORY_LABELS[place.category]);
// Map-search link — generic geo-URL, browser opens whatever map app
// the user has set as default. Falls back to OpenStreetMap search.
const mapUrl = $derived.by(() => {
const q = encodeURIComponent(`${place.name}${place.address ? ` ${place.address}` : ''}`);
return `https://www.openstreetmap.org/search?query=${q}`;
});
</script>
<svelte:head>
<title>{place.name} · Mana</title>
<meta name="robots" content="noindex, nofollow" />
<meta property="og:title" content={place.name} />
<meta property="og:description" content={ogDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<article class="place">
<span class="place__kind">
{CATEGORY_EMOJI[place.category]}
{CATEGORY_LABELS[place.category]}
</span>
<h1 class="place__title">{place.name}</h1>
{#if place.address}
<p class="place__address">{place.address}</p>
{/if}
<a class="place__map" href={mapUrl} target="_blank" rel="noopener noreferrer">
🗺️ Auf OpenStreetMap suchen
</a>
</article>
<style>
.place {
display: flex;
flex-direction: column;
gap: 1rem;
}
.place__kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
font-weight: 600;
}
.place__title {
margin: 0;
font-size: 2rem;
line-height: 1.15;
font-weight: 700;
}
.place__address {
margin: 0;
font-size: 1.0625rem;
color: #374151;
white-space: pre-line;
}
.place__map {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
background: #4f46e5;
color: white;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
align-self: flex-start;
}
.place__map:hover {
background: #4338ca;
}
@media (prefers-color-scheme: dark) {
.place__kind {
color: #9ca3af;
}
.place__address {
color: #d1d5db;
}
.place__map {
background: #818cf8;
}
.place__map:hover {
background: #6366f1;
}
}
</style>

View file

@ -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<LocalPlace, string>('places', 'places').toArray();
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords<LocalPlace>('places', visible);
@ -56,7 +57,7 @@ export function useAllPlaces() {
}
export function useLocationLogs(placeId?: string) {
return useLiveQueryWithDefault(async () => {
return useScopedLiveQuery(async () => {
let query = db.table<LocalLocationLog>('locationLogs').orderBy('timestamp').reverse();
const locals = await query.toArray();
const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals;

View file

@ -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);
}
},
};

View file

@ -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;
}

View file

@ -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 @@
<VisibilityPicker level={place.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
{#if place.visibility === 'unlisted' && place.unlistedToken && shareUrl}
<div class="field-row field-row--share">
<span class="field-label">Link</span>
<SharedLinkControls
token={place.unlistedToken}
url={shareUrl}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
/>
</div>
{/if}
<div class="field-row">
<span class="field-label">Kategorie</span>
<select class="field-select" value={editCategory} onchange={onCategoryChange}>

View file

@ -5,6 +5,8 @@
-->
<script lang="ts">
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
import SharedLibraryEntryView from '$lib/modules/library/SharedLibraryEntryView.svelte';
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@ -12,6 +14,10 @@
{#if data.collection === 'events'}
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'libraryEntries'}
<SharedLibraryEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'places'}
<SharedPlaceView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>