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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue