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:
parent
3215917b7a
commit
f0ce5e9680
7 changed files with 127 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
server/db.ts
10
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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue