Hearts on activities, feedback triage by admins, click-to-permalink

Three small features and one UX bug.

1. **Hearts.** New `activity_hearts(activity_id, user_id, created_at)`
   table with composite PK. POST/DELETE /api/activities/:id/heart for
   logged-in users on any non-private activity. Idempotent — re-posting
   is a no-op rather than a 409.

   Activity serialisation now includes `heart_count` and
   `viewer_hearted` on all three visibility variants (private is always
   0/false; hearts make no sense there). ActivityRow renders a toggle
   button with optimistic updates and a local override that snaps back
   on network error, then propagates the server's authoritative state
   to the parent list via a new `onChanged` callback.

2. **Admin can triage feedback.** Added `done_at` + `done_by` columns
   to feedback (migrated via ensureColumn for older scaffold DBs). New
   admin-only endpoints:
     PATCH  /api/feedback/:id    — { done: boolean }; sets/clears done_at
     DELETE /api/feedback/:id    — drops the entry
   The Feedback list view sorts open requests above done ones, and
   admins see "Marker som ferdig" / "Marker som åpen" / "Slett" buttons
   per entry. Open/done badges visible to everyone with read access
   (i.e., moderators).

3. **Clicking an activity opens its permalink.** Activity titles in
   ActivityRow are now anchor links to /a/:id, so clicking the title
   navigates to the permalink view. Action buttons (heart, edit,
   delete, del/copy-link) stay inside the card; the anchor only wraps
   the title text, so taps elsewhere don't navigate.

Bundle gained 1 KB gzipped (30.2 → 31.2 KB). 26 tests still pass,
typecheck clean.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:33:51 +02:00
commit 5c9455c3f3
9 changed files with 305 additions and 31 deletions

View file

@ -15,8 +15,11 @@
privateCleartext?: PrivatePayload | null;
onDeleted: (id: string) => void;
onEdit?: (a: Activity) => void;
/** Called when the row updates its own activity (e.g. after a heart
* toggle). The parent should patch its list so the change persists. */
onChanged?: (a: Activity) => void;
}
let { activity, privateCleartext = null, onDeleted, onEdit }: Props = $props();
let { activity, privateCleartext = null, onDeleted, onEdit, onChanged }: Props = $props();
// Fallback decrypt for the case where Home hasn't pre-computed yet (e.g.
// immediately after a create) — we tolerate redundant work to keep the
@ -104,6 +107,39 @@
onDeleted(activity.id);
}
// --- Hearts ----------------------------------------------------------------
// Optimistic toggle via a local override that wins over the prop. On
// success we both update the local override AND let the parent know, so
// a re-render (parent's list refreshing) still shows the right state.
let localOverride: Activity | null = $state(null);
const view = $derived(localOverride ?? activity);
let heartBusy = $state(false);
async function toggleHeart() {
if (!session.user || heartBusy) return;
if (view.visibility === 'private') return;
heartBusy = true;
const wasHearted = view.viewer_hearted;
const optimistic: Activity = {
...view,
viewer_hearted: !wasHearted,
heart_count: view.heart_count + (wasHearted ? -1 : 1),
};
localOverride = optimistic;
try {
const updated = wasHearted
? await api.unheartActivity(view.id)
: await api.heartActivity(view.id);
localOverride = updated;
onChanged?.(updated);
} catch {
// Snap back.
localOverride = null;
} finally {
heartBusy = 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
@ -140,7 +176,9 @@
{#if activity.visibility === 'private'}
{#if decrypted}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
{decrypted.title}
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
{decrypted.title}
</a>
<span class="vis-badge private">Privat</span>
</h3>
{#if decrypted.tags.length}
@ -157,7 +195,9 @@
{/if}
{:else}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
{activity.title}
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
{activity.title}
</a>
<span class="vis-badge {activity.visibility}">
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
</span>
@ -184,6 +224,22 @@
{/if}
<div class="row" style="margin-top: 0.5rem;">
{#if view.visibility !== 'private'}
{#if session.user}
<button
type="button"
onclick={toggleHeart}
disabled={heartBusy}
aria-pressed={view.viewer_hearted}
aria-label={view.viewer_hearted ? 'Fjern hjerte' : 'Sett hjerte'}
class={view.viewer_hearted ? 'primary' : ''}
>
{view.viewer_hearted ? '♥' : '♡'} {view.heart_count}
</button>
{:else if view.heart_count > 0}
<span class="muted" aria-label="Antall hjerter">{view.heart_count}</span>
{/if}
{/if}
{#if canEdit && onEdit}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}

View file

@ -57,11 +57,34 @@
}
}
// Auto-load for moderators when component mounts.
// Auto-load for moderators (and admins, who imply moderator).
$effect(() => {
if (session.user?.is_moderator) refreshList();
if (session.user?.is_moderator || session.user?.is_admin) refreshList();
});
async function toggleDone(entry: FeedbackEntry, done: boolean) {
try {
const updated = await api.updateFeedback(entry.id, { done });
entries = entries.map((e) => (e.id === updated.id ? updated : e));
} catch (err) {
listError = err instanceof ApiError && err.status === 403
? 'Bare administratorer kan markere som ferdig.'
: 'Endring feilet.';
}
}
async function removeEntry(entry: FeedbackEntry) {
if (!confirm('Slett denne tilbakemeldingen?')) return;
try {
await api.deleteFeedback(entry.id);
entries = entries.filter((e) => e.id !== entry.id);
} catch (err) {
listError = err instanceof ApiError && err.status === 403
? 'Bare administratorer kan slette.'
: 'Sletting feilet.';
}
}
function formatDate(epochMs: number): string {
return new Date(epochMs).toLocaleString('nb-NO', {
year: 'numeric', month: '2-digit', day: '2-digit',
@ -114,15 +137,32 @@
<p class="muted">Ingen tilbakemeldinger ennå.</p>
{/if}
{#each entries as e (e.id)}
<article class="card" style="margin-top: 0.5rem;">
<article class="card" style="margin-top: 0.5rem; {e.done_at ? 'opacity: 0.7;' : ''}">
<div class="row" style="justify-content: space-between;">
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
<div class="row" style="gap: 0.5rem;">
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
{#if e.done_at}
<span class="vis-badge semi" style="background: rgba(46,160,67,0.18); color: #2ea043;">Ferdig</span>
{:else}
<span class="vis-badge semi">Åpen</span>
{/if}
</div>
<span class="muted">{formatDate(e.created_at)}</span>
</div>
<p style="white-space: pre-wrap; margin: 0.5rem 0;">{e.body}</p>
<p class="muted" style="font-size: 0.85rem;">
Fra {e.user_display?.trim() || e.user_email}
</p>
{#if session.user?.is_admin}
<div class="row" style="margin-top: 0.5rem;">
{#if e.done_at}
<button type="button" onclick={() => toggleDone(e, false)}>Marker som åpen</button>
{:else}
<button class="primary" type="button" onclick={() => toggleDone(e, true)}>Marker som ferdig</button>
{/if}
<button class="danger" type="button" onclick={() => removeEntry(e)}>Slett</button>
</div>
{/if}
</article>
{/each}
</section>

View file

@ -46,6 +46,12 @@
editing = null;
}
function onChanged(a: Activity) {
// In-place patch (no editing context change). Used by row-level actions
// like hearts.
activities = activities.map((x) => (x.id === a.id ? a : x));
}
function onDeleted(id: string) {
activities = activities.filter((a) => a.id !== id);
}
@ -158,6 +164,7 @@
privateCleartext={privateCleartext.get(a.id) ?? null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
@ -170,6 +177,7 @@
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
@ -182,6 +190,7 @@
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}

View file

@ -3,7 +3,7 @@ import type {
RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest,
MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest,
TagSuggestion, ProfileUpdateRequest,
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry,
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest,
AdminUser, AdminRoleUpdate,
} from '../../../shared/types';
@ -73,6 +73,10 @@ export const api = {
}),
deleteActivity: (id: string) =>
http<{ ok: true }>(`/activities/${encodeURIComponent(id)}`, { method: 'DELETE' }),
heartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
unheartActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
// --- tags -----------------------------------------------------------------
tagSuggestions: (q: string, limit = 20) =>
@ -86,6 +90,12 @@ export const api = {
submitFeedback: (body: FeedbackSubmitRequest) =>
http<FeedbackEntry>('/feedback', { method: 'POST', body: JSON.stringify(body) }),
listFeedback: () => http<FeedbackEntry[]>('/feedback'),
updateFeedback: (id: string, body: FeedbackUpdateRequest) =>
http<FeedbackEntry>(`/feedback/${encodeURIComponent(id)}`, {
method: 'PATCH', body: JSON.stringify(body),
}),
deleteFeedback: (id: string) =>
http<{ ok: true }>(`/feedback/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// --- admin (admin role only) ----------------------------------------------
adminListUsers: () => http<AdminUser[]>('/admin/users'),