mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 18:46:42 +02:00
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:
parent
85fca7ccdc
commit
b7a54ccd10
16 changed files with 379 additions and 215 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
: { kind: 'comic' },
|
||||
visibility: 'private',
|
||||
unlistedToken: '',
|
||||
unlistedExpiresAt: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
id: '',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue