From f0ce5e9680d4783892acf31cecb08565fe2e652e Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 14:11:58 +0200 Subject: [PATCH] Bookmarks on public/semi activities, surfaced on /home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logged-in users can star a public or semi activity to save it for later. Bookmarked rows float to the top of the user's dashboard in a dedicated "Bokmerker" section. The same row still appears in its visibility section below — bookmarking doesn't remove anything; it just adds a fast lane. Schema: - bookmarks(user_id, activity_id, created_at) with composite PK - Both FKs CASCADE so user deletion or activity deletion sweeps bookmarks automatically Wire/types: ActivityPublic/Semi/Private all gain `viewer_bookmarked: boolean` for type uniformity. Private rows always carry false (the owner already has direct access; bookmarking own private items would be redundant), and the bookmark endpoints reject visibility='private' with cannot_bookmark_private. Anonymous viewers (public-list endpoint) get false too. Server: - viewerBookmarked() helper next to heartsFor() — same shape - serialize() includes the field - POST/DELETE /api/activities/:id/bookmark, idempotent via INSERT OR IGNORE / DELETE; mirrors the heart endpoints Frontend: - ActivityRow gets an "☆ Bokmerk" / "★ Bokmerket" toggle next to the heart button. Uses the same optimistic local-override pattern so the UI feels instant. - Home renders a "Bokmerker" section at the top when bookmarked rows exist. publicOnly mode (the "/" landing) skips it — the field is always false there. 26 tests still pass; typecheck clean. --- frontend/src/components/ActivityRow.svelte | 32 +++++++++++++- frontend/src/components/Home.svelte | 26 ++++++++++- frontend/src/lib/api.ts | 4 ++ server/activities.ts | 50 +++++++++++++++++++++- server/db.ts | 10 +++++ server/users.ts | 3 +- shared/types.ts | 10 +++-- 7 files changed, 127 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 740fd3e..47f8a61 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -133,13 +133,33 @@ localOverride = updated; onChanged?.(updated); } catch { - // Snap back. localOverride = null; } finally { heartBusy = false; } } + // --- Bookmarks ----------------------------------------------------------- + let bookmarkBusy = $state(false); + async function toggleBookmark() { + if (!session.user || bookmarkBusy) return; + if (view.visibility === 'private') return; + bookmarkBusy = true; + const wasBookmarked = view.viewer_bookmarked; + localOverride = { ...view, viewer_bookmarked: !wasBookmarked }; + try { + const updated = wasBookmarked + ? await api.unbookmarkActivity(view.id) + : await api.bookmarkActivity(view.id); + localOverride = updated; + onChanged?.(updated); + } catch { + localOverride = null; + } finally { + bookmarkBusy = false; + } + } + /** * Share-link: copy /a/ (absolute URL) to the clipboard. Private rows * are shareable in principle but only the owner can decrypt — the receiver @@ -242,6 +262,16 @@ > {view.viewer_hearted ? '♥' : '♡'} {view.heart_count} + {:else if view.heart_count > 0} ♡ {view.heart_count} {/if} diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index 870e3de..0210226 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -102,8 +102,17 @@ .filter((a) => matchesQuery(a, query)), ); - // Split into the three sections defined in the spec — "mine privat" first, - // then anonyme, then offentlige. + // Split into sections. Bookmarks float to the top (only for logged-in users + // looking at their full dashboard — public landing skips them since the + // viewer_bookmarked field is always false there). The bookmark itself + // doesn't remove the activity from its visibility section — the same row + // appears once in "Bokmerker" AND once in its semi/public section so you + // can still find it where you'd expect. + const bookmarked = $derived( + publicOnly || !session.user + ? [] + : filtered.filter((a) => a.visibility !== 'private' && a.viewer_bookmarked), + ); const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private')); const semi = $derived(filtered.filter((a) => a.visibility === 'semi')); const pub = $derived(filtered.filter((a) => a.visibility === 'public')); @@ -160,6 +169,19 @@ {:else if error}

{error}

{:else} + {#if bookmarked.length} +

Bokmerker

+ {#each bookmarked as a (a.id)} + (editing = act)} + onChanged={onChanged} + /> + {/each} + {/if} + {#if myPrivate.length}

Dine private

{#each myPrivate as a (a.id)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4094993..3fb4e0a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -78,6 +78,10 @@ export const api = { http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }), unheartActivity: (id: string) => http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }), + bookmarkActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }), + unbookmarkActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'DELETE' }), // --- tags ----------------------------------------------------------------- tagSuggestions: (q: string, limit = 20) => diff --git a/server/activities.ts b/server/activities.ts index 754e254..bf41b7b 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -67,6 +67,14 @@ function heartsFor(activityId: string, viewerId: string | null): { count: number return { count, hearted }; } +/** Does the viewer have a bookmark on this activity? False for anonymous viewers. */ +function viewerBookmarked(activityId: string, viewerId: string | null): boolean { + if (!viewerId) return false; + return !!getDb() + .prepare('SELECT 1 FROM bookmarks WHERE activity_id = ? AND user_id = ?') + .get(activityId, viewerId); +} + /** * Build the public-facing attribution for an owner. Prefer the user's chosen * `display_name`; fall back to their `username` slug if set (also user-chosen); @@ -111,9 +119,10 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { owner_id: row.owner_id, ciphertext: b64(row.ciphertext) ?? '', nonce: b64(row.nonce) ?? '', - // Private rows don't surface hearts — nobody else sees them. + // Private rows don't surface hearts/bookmarks — only the owner sees them. heart_count: 0, viewer_hearted: false, + viewer_bookmarked: false, created_at: row.created_at, updated_at: row.updated_at, }; @@ -121,6 +130,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { } const tags = tagsFor(row.id); const hearts = heartsFor(row.id, viewerId); + const bookmarked = viewerBookmarked(row.id, viewerId); if (row.visibility === 'semi') { // owner_id is included ONLY when the viewer IS the owner — that lets the // client render Edit/Delete on the user's own semi rows without leaking @@ -137,6 +147,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { scheduled_at: row.scheduled_at, heart_count: hearts.count, viewer_hearted: hearts.hearted, + viewer_bookmarked: bookmarked, created_at: row.created_at, updated_at: row.updated_at, }; @@ -159,6 +170,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { scheduled_at: row.scheduled_at, heart_count: hearts.count, viewer_hearted: hearts.hearted, + viewer_bookmarked: bookmarked, created_at: row.created_at, updated_at: row.updated_at, }; @@ -367,6 +379,42 @@ activitiesRoutes.delete('/:id/heart', requireAuth, (c) => { return c.json(serialize(refreshed, userId)); }); +// --- POST /api/activities/:id/bookmark ------------------------------------- +// Idempotent. Refuses on private rows (the owner already has direct access). +activitiesRoutes.post('/:id/bookmark', requireAuth, (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = getDb(); + + const row = db + .prepare('SELECT visibility FROM activities WHERE id = ?') + .get(id) as { visibility: Visibility } | null; + if (!row) return c.json({ error: 'not_found' }, 404); + if (row.visibility === 'private') return c.json({ error: 'cannot_bookmark_private' }, 400); + + db.prepare( + 'INSERT OR IGNORE INTO bookmarks (user_id, activity_id, created_at) VALUES (?, ?, ?)', + ).run(userId, id, Date.now()); + + const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(refreshed, userId)); +}); + +// --- DELETE /api/activities/:id/bookmark ----------------------------------- +activitiesRoutes.delete('/:id/bookmark', requireAuth, (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = getDb(); + + const row = db.prepare('SELECT 1 FROM activities WHERE id = ?').get(id); + if (!row) return c.json({ error: 'not_found' }, 404); + + db.prepare('DELETE FROM bookmarks WHERE user_id = ? AND activity_id = ?').run(userId, id); + + const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(refreshed, userId)); +}); + // --- DELETE /api/activities/:id --------------------------------------------- // Authz: // - private: owner only. Other users can't even see private rows, so diff --git a/server/db.ts b/server/db.ts index 5df5f61..100aa86 100644 --- a/server/db.ts +++ b/server/db.ts @@ -112,6 +112,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [ PRIMARY KEY (activity_id, user_id) )`, `CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`, + // Bookmarks: logged-in users can save public/semi activities to their own + // dashboard. Same shape as hearts: composite PK on (user, activity), one + // row per bookmark. CASCADE on the activity so deletes clean up. + `CREATE TABLE IF NOT EXISTS bookmarks ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, activity_id) + )`, + `CREATE INDEX IF NOT EXISTS bookmarks_user_idx ON bookmarks(user_id, created_at DESC)`, // Global settings (key/value). Currently used for self_registry_enabled // but kept generic so future toggles don't need their own table. `CREATE TABLE IF NOT EXISTS settings ( diff --git a/server/users.ts b/server/users.ts index cecd5a6..4fa410a 100644 --- a/server/users.ts +++ b/server/users.ts @@ -73,8 +73,9 @@ usersRoutes.get('/:username/list', (c) => { scheduled_at: r.scheduled_at, heart_count: count, // The public-list endpoint is unauthenticated; we don't know who the - // viewer is to fill viewer_hearted truthfully. Always false here. + // viewer is to fill viewer_hearted/bookmarked truthfully. Always false. viewer_hearted: false, + viewer_bookmarked: false, created_at: r.created_at, updated_at: r.updated_at, }; diff --git a/shared/types.ts b/shared/types.ts index ae75f7d..e402b0f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -195,6 +195,8 @@ export interface ActivityPublic { heart_count: number; /** True when the authenticated viewer has hearted this activity. */ viewer_hearted: boolean; + /** True when the authenticated viewer has bookmarked this activity. */ + viewer_bookmarked: boolean; created_at: number; updated_at: number; } @@ -215,6 +217,7 @@ export interface ActivitySemi { scheduled_at: number | null; heart_count: number; viewer_hearted: boolean; + viewer_bookmarked: boolean; created_at: number; updated_at: number; } @@ -225,11 +228,12 @@ export interface ActivityPrivate { owner_id: string; // always you — server only returns your private rows ciphertext: string; // base64 nonce: string; // base64 - // Always 0 / false for private rows — hearts don't apply (nobody else sees - // them). Kept in the type so the client doesn't need a discriminator check - // before reading the field. + // Always 0 / false for private rows — hearts and bookmarks don't apply + // (nobody else sees them; the owner already has direct access). Kept in + // the type so the client doesn't need a discriminator check before reading. heart_count: number; viewer_hearted: boolean; + viewer_bookmarked: boolean; created_at: number; updated_at: number; }