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

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