Bookmarks on public/semi activities, surfaced on /home

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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:11:58 +02:00
commit f0ce5e9680
7 changed files with 127 additions and 8 deletions

View file

@ -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/<id> (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}
</button>
<button
type="button"
onclick={toggleBookmark}
disabled={bookmarkBusy}
aria-pressed={view.viewer_bookmarked}
aria-label={view.viewer_bookmarked ? 'Fjern bokmerke' : 'Bokmerk'}
title={view.viewer_bookmarked ? 'Fjern bokmerke' : 'Bokmerk'}
>
{view.viewer_bookmarked ? '★ Bokmerket' : '☆ Bokmerk'}
</button>
{:else if view.heart_count > 0}
<span class="muted" aria-label="Antall hjerter">{view.heart_count}</span>
{/if}

View file

@ -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}
<p class="error">{error}</p>
{:else}
{#if bookmarked.length}
<h2>Bokmerker</h2>
{#each bookmarked as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if myPrivate.length}
<h2>Dine private</h2>
{#each myPrivate as a (a.id)}

View file

@ -78,6 +78,10 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'DELETE' }),
// --- tags -----------------------------------------------------------------
tagSuggestions: (q: string, limit = 20) =>

View file

@ -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

View file

@ -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 (

View file

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

View file

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