feat(unlisted-sharing): QR code + per-link expiry picker (M8.5)

SharedLinkControls now renders a lazy QR code (qrcode npm) and a
datetime-local "Läuft ab" picker. Both stay in sync with the active
URL — regenerating the link rebuilds the QR; clearing the expiry
re-publishes with no `expiresAt`.

Wired across all three unlisted collections:
- Calendar: LocalEvent.unlistedExpiresAt + setUnlistedExpiry +
  preserve-on-refresh + clear-on-flip; both Workbench DetailView and
  EventDetailModal pass expiresAt+onExpiryChange to SharedLinkControls.
- Library: same pattern in libraryEntriesStore + DetailView.
- Places: same pattern in placesStore + DetailView.

setVisibility clears any prior expiry so a flip-away-flip-back gets
a fresh "never expires" link. refreshUnlistedSnapshot and
regenerateUnlistedToken preserve the existing expiry so a content
edit or token rotation never silently extends a link's lifetime.

The qrcode dep ships as a regular `dependencies` entry on
@mana/shared-privacy so any consuming app picks it up via the
workspace.

Note: an unrelated svelte-check error in writing/components/DraftCard
("draft" not assignable to DragType) exists from a parallel session
and is not introduced by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 12:29:53 +02:00
parent 85fca7ccdc
commit b7a54ccd10
16 changed files with 379 additions and 215 deletions

View file

@ -46,6 +46,10 @@
await eventsStore.setVisibility(event.id, 'space');
}
async function handleExpiryChange(expiresAt: Date | null) {
await eventsStore.setUnlistedExpiry(event.id, expiresAt);
}
const shareUrl = $derived.by(() => {
if (!event.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
@ -276,8 +280,10 @@
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
expiresAt={event.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
</div>

View file

@ -436,6 +436,7 @@ export const eventsStore = {
tagIds: data.tagIds || [],
visibility: data.visibility ?? 'private',
unlistedToken: data.unlistedToken ?? '',
unlistedExpiresAt: data.unlistedExpiresAt ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
blockType: 'event',
@ -523,6 +524,7 @@ export const eventsStore = {
blob,
});
patch.unlistedToken = token;
patch.unlistedExpiresAt = undefined;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
@ -534,6 +536,7 @@ export const eventsStore = {
});
}
patch.unlistedToken = undefined;
patch.unlistedExpiresAt = undefined;
}
await db.table('events').update(id, patch);
@ -596,6 +599,39 @@ export const eventsStore = {
}
},
/**
* Set or clear the unlisted-share expiry. Re-publishes the snapshot
* with the new `expiresAt`; mirrors the value locally so the
* SharedLinkControls picker shows the right state without a server
* round-trip. No-op if the event isn't currently 'unlisted'.
*/
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await db.table<LocalEvent>('events').get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
try {
const blob = await buildUnlistedBlob('events', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'events',
recordId: id,
spaceId,
blob,
expiresAt,
});
await db.table('events').update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
updatedAt: new Date().toISOString(),
});
} catch (e) {
console.error('[calendar/events] setUnlistedExpiry failed', e);
}
},
/**
* Re-publish the unlisted snapshot for an event. Called by
* updateEvent/updateSingleInstance/etc. when the owning record is
@ -622,6 +658,9 @@ export const eventsStore = {
recordId: id,
spaceId,
blob,
// Preserve any existing expiry so a content edit doesn't
// silently extend the link's lifetime.
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
} catch (e) {
console.error('[calendar/events] refreshUnlistedSnapshot failed', e);

View file

@ -30,6 +30,8 @@ export interface LocalEvent extends BaseRecord {
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
/** Local mirror of the server-side unlisted-snapshot expiry. */
unlistedExpiresAt?: string;
}
export type CalendarViewType = 'week' | 'month' | 'agenda';
@ -60,6 +62,8 @@ export interface CalendarEvent {
* to know whether to render the share-link controls).
*/
unlistedToken: string;
/** ISO expiry for the active unlisted-share, null when never expires. */
unlistedExpiresAt: string | null;
createdAt: string;
updatedAt: string;
// TimeBlock metadata (for universal calendar view)
@ -114,6 +118,7 @@ export function timeBlockToCalendarEvent(
// they stay invisible on the website (public requires explicit opt-in).
visibility: eventData?.visibility ?? 'space',
unlistedToken: eventData?.unlistedToken ?? '',
unlistedExpiresAt: eventData?.unlistedExpiresAt ?? null,
createdAt: block.createdAt,
updatedAt: block.updatedAt,
blockType: block.type,

View file

@ -111,6 +111,10 @@
await eventsStore.setVisibility(eventId, 'space');
}
async function handleExpiryChange(expiresAt: Date | null) {
await eventsStore.setUnlistedExpiry(eventId, expiresAt);
}
const shareUrl = $derived.by(() => {
const token = detail.entity?.unlistedToken;
if (!token) return '';
@ -158,8 +162,10 @@
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
expiresAt={event.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
{/if}

View file

@ -111,6 +111,7 @@
: { kind: 'comic' },
visibility: 'private',
unlistedToken: '',
unlistedExpiresAt: null,
createdAt: '',
updatedAt: '',
id: '',

View file

@ -37,6 +37,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry {
// space-type-aware default at create time in entries.svelte.ts.
visibility: local.visibility ?? 'space',
unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};

View file

@ -259,6 +259,7 @@ export const libraryEntriesStore = {
blob,
});
patch.unlistedToken = token;
patch.unlistedExpiresAt = undefined;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
@ -270,6 +271,7 @@ export const libraryEntriesStore = {
});
}
patch.unlistedToken = undefined;
patch.unlistedExpiresAt = undefined;
}
await libraryEntryTable.update(id, patch);
@ -309,6 +311,9 @@ export const libraryEntriesStore = {
recordId: id,
spaceId,
blob,
// Preserve any existing expiry — regenerate is about leaking
// the URL, not extending the lifetime.
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
await libraryEntryTable.update(id, {
unlistedToken: token,
@ -321,6 +326,38 @@ export const libraryEntriesStore = {
}
},
/**
* Set or clear the unlisted-share expiry. Mirrors
* eventsStore.setUnlistedExpiry re-publishes with the new expiry
* and stores it locally so the picker stays in sync.
*/
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await libraryEntryTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
try {
const blob = await buildUnlistedBlob('libraryEntries', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'libraryEntries',
recordId: id,
spaceId,
blob,
expiresAt,
});
await libraryEntryTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
updatedAt: new Date().toISOString(),
});
} catch (e) {
console.error('[library] setUnlistedExpiry failed', e);
}
},
/**
* Re-publish unlisted snapshot when whitelist fields change. Called
* fire-and-forget after updateEntry/setStatus/rate. No-op if the
@ -342,6 +379,9 @@ export const libraryEntriesStore = {
recordId: id,
spaceId,
blob,
// Preserve any existing expiry so a content edit doesn't
// silently extend the link's lifetime.
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
} catch (e) {
console.error('[library] refreshUnlistedSnapshot failed', e);

View file

@ -102,6 +102,8 @@ export interface LocalLibraryEntry extends BaseRecord {
* when visibility moves back to anything else.
*/
unlistedToken?: string;
/** ISO timestamp when the unlisted snapshot expires; absent = never. */
unlistedExpiresAt?: string;
}
// ─── Domain Type (plaintext, for UI) ─────────────────────
@ -129,6 +131,8 @@ export interface LibraryEntry {
visibility: VisibilityLevel;
/** Server-issued share token. Empty when not 'unlisted'. */
unlistedToken: string;
/** ISO timestamp when the unlisted snapshot expires, or null = never. */
unlistedExpiresAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -28,6 +28,10 @@
await libraryEntriesStore.setVisibility(entry.id, 'space');
}
async function onExpiryChange(expiresAt: Date | null) {
await libraryEntriesStore.setUnlistedExpiry(entry.id, expiresAt);
}
const shareUrl = $derived.by(() => {
if (!entry.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
@ -167,8 +171,10 @@
<SharedLinkControls
token={entry.unlistedToken}
url={shareUrl}
expiresAt={entry.unlistedExpiresAt}
{onRegenerate}
{onRevoke}
{onExpiryChange}
/>
</dd>
{/if}

View file

@ -26,6 +26,7 @@ export function toPlace(local: LocalPlace): Place {
tagIds: local.tagIds ?? [],
visibility: local.visibility ?? 'space',
unlistedToken: local.unlistedToken ?? '',
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -201,6 +201,7 @@ export const placesStore = {
blob,
});
patch.unlistedToken = token;
patch.unlistedExpiresAt = undefined;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
@ -212,6 +213,7 @@ export const placesStore = {
});
}
patch.unlistedToken = undefined;
patch.unlistedExpiresAt = undefined;
}
await placeTable.update(id, patch);
@ -246,6 +248,7 @@ export const placesStore = {
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
await placeTable.update(id, {
unlistedToken: token,
@ -258,6 +261,33 @@ export const placesStore = {
}
},
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await placeTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
try {
const blob = await buildUnlistedBlob('places', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'places',
recordId: id,
spaceId,
blob,
expiresAt,
});
await placeTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
updatedAt: new Date().toISOString(),
});
} catch (e) {
console.error('[places] setUnlistedExpiry failed', e);
}
},
async refreshUnlistedSnapshot(id: string) {
const existing = await placeTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
@ -274,6 +304,7 @@ export const placesStore = {
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
} catch (e) {
console.error('[places] refreshUnlistedSnapshot failed', e);

View file

@ -23,6 +23,8 @@ export interface LocalPlace extends BaseRecord {
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
/** ISO timestamp when the unlisted snapshot expires; absent = never. */
unlistedExpiresAt?: string;
}
export interface LocalLocationLog extends BaseRecord {
@ -54,6 +56,8 @@ export interface Place {
visibility: VisibilityLevel;
/** Server-issued share token. Empty when not 'unlisted'. */
unlistedToken: string;
/** ISO timestamp when the unlisted snapshot expires, or null = never. */
unlistedExpiresAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -171,6 +171,10 @@
await placesStore.setVisibility(placeId, 'space');
}
async function handleExpiryChange(expiresAt: Date | null) {
await placesStore.setUnlistedExpiry(placeId, expiresAt);
}
const shareUrl = $derived.by(() => {
const token = detail.entity?.unlistedToken;
if (!token) return '';
@ -261,8 +265,10 @@
<SharedLinkControls
token={place.unlistedToken}
url={shareUrl}
expiresAt={place.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
{/if}