mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
bad935c258
commit
167d616cf7
13 changed files with 684 additions and 25 deletions
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
? { kind: 'series', watched: [] }
|
||||
: { kind: 'comic' },
|
||||
visibility: 'private',
|
||||
unlistedToken: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
id: '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
139
apps/mana/apps/web/src/lib/modules/places/SharedPlaceView.svelte
Normal file
139
apps/mana/apps/web/src/lib/modules/places/SharedPlaceView.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue