diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte
index 51c7fdd78..98fdeb981 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte
+++ b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte
@@ -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 @@
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
index b5dbb4723..490d4d0c9 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
@@ -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('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);
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/types.ts b/apps/mana/apps/web/src/lib/modules/calendar/types.ts
index 474902f22..6529227b4 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/types.ts
+++ b/apps/mana/apps/web/src/lib/modules/calendar/types.ts
@@ -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,
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte
index 8705c5e82..244fd80ce 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte
@@ -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 @@
{/if}
diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte
index f6d57dceb..8d8f9c86e 100644
--- a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte
@@ -111,6 +111,7 @@
: { kind: 'comic' },
visibility: 'private',
unlistedToken: '',
+ unlistedExpiresAt: null,
createdAt: '',
updatedAt: '',
id: '',
diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts
index 7107f1d4f..43c560d40 100644
--- a/apps/mana/apps/web/src/lib/modules/library/queries.ts
+++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts
@@ -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,
};
diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts
index 171277999..182f00c2a 100644
--- a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts
@@ -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);
diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts
index 79b9e52df..3e7a253f8 100644
--- a/apps/mana/apps/web/src/lib/modules/library/types.ts
+++ b/apps/mana/apps/web/src/lib/modules/library/types.ts
@@ -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;
}
diff --git a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte
index 48d29f1e4..e860704f2 100644
--- a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte
@@ -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 @@
{/if}
diff --git a/apps/mana/apps/web/src/lib/modules/places/queries.ts b/apps/mana/apps/web/src/lib/modules/places/queries.ts
index e66eaa013..b1e4dd9f6 100644
--- a/apps/mana/apps/web/src/lib/modules/places/queries.ts
+++ b/apps/mana/apps/web/src/lib/modules/places/queries.ts
@@ -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(),
};
diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
index 9cdbe4124..499f866e7 100644
--- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts
@@ -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);
diff --git a/apps/mana/apps/web/src/lib/modules/places/types.ts b/apps/mana/apps/web/src/lib/modules/places/types.ts
index e23e32368..de29dc41d 100644
--- a/apps/mana/apps/web/src/lib/modules/places/types.ts
+++ b/apps/mana/apps/web/src/lib/modules/places/types.ts
@@ -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;
}
diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte
index 8e6dbec4c..0cf7e241d 100644
--- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte
@@ -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 @@
{/if}
diff --git a/packages/shared-privacy/package.json b/packages/shared-privacy/package.json
index f91b61584..2ee3144b1 100644
--- a/packages/shared-privacy/package.json
+++ b/packages/shared-privacy/package.json
@@ -26,9 +26,11 @@
},
"dependencies": {
"@mana/shared-icons": "workspace:*",
+ "qrcode": "^1.5.4",
"zod": "^3.25.76"
},
"devDependencies": {
+ "@types/qrcode": "^1.5.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3",
diff --git a/packages/shared-privacy/src/SharedLinkControls.svelte b/packages/shared-privacy/src/SharedLinkControls.svelte
index 0cac6fdfb..d9e6e89e5 100644
--- a/packages/shared-privacy/src/SharedLinkControls.svelte
+++ b/packages/shared-privacy/src/SharedLinkControls.svelte
@@ -4,16 +4,14 @@
Shown below the VisibilityPicker in a module's DetailView when
`visibility === 'unlisted'` and a token exists. Dumb — owner (the
module DetailView) passes the token + handlers; this component only
- renders the URL, manages copy-to-clipboard, and dispatches
- regenerate/revoke/expiry actions.
-
- QR-code rendering is a later polish (M8.5) — currently stubbed as
- a button that disables itself. Expiry-picker is present but
- minimal — datetime-local input, no fancy picker.
+ renders the URL, manages copy-to-clipboard, QR-code display, and
+ dispatches regenerate/revoke/expiry actions.
See docs/plans/unlisted-sharing.md §7.
-->