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; }