Drag-and-drop unified activity list with per-user sort order

The home dashboard's five sections (Bokmerker / Dine private /
Venner / Anonyme / Offentlige) collapse into one ordered list.
Each row is identified by its visibility badge plus an optional
"★ Bokmerket" badge — the meaning stays clear, the layout gets
much tighter, and reordering across visibility levels becomes a
single drag.

Per-viewer ordering, sparsely stored:

Schema (additive):
  - user_activity_sort(user_id, activity_id, position REAL)
    composite PK; ON DELETE CASCADE both ways

Sort math: the list query LEFT JOINs the table per-viewer and
orders by COALESCE(custom_position, -activity.created_at). Rows
without a custom position sort by -created_at (very negative for
recent activity, very less-negative for old) so new and untouched
activities float to the top in newest-first order. Once dragged,
the row carries a real float position that the listing query uses
instead.

Sort endpoint:
  PATCH /api/activities/:id/sort  body: { position: number }
  ON CONFLICT UPDATE so re-dragging the same row is cheap and
  doesn't accumulate rows.

Wire: every activity variant now carries `sort_position: number`
— the effective position the server used (custom or -created_at).
The client uses it to compute midpoint positions on drop without
needing to know the formula.

Frontend:
  - Home.svelte renders one list ordered by sort_position. Search
    filter still works across the unified list.
  - ActivityRow.svelte gains a drag-handle button (only rendered
    when the parent passes draggable=true; off on the public
    landing and on /a/:id, /<username>/list, /tags/:tag).
  - ActivityRow.svelte gains a "★ Bokmerket" vis-badge alongside
    the visibility badge so the marker is consistent with the
    other status pills.
  - Home computes the drop's new position as the midpoint between
    neighbours (or top/bottom + 1.0 at the edges), updates the
    local list optimistically, then PATCHes the server. Snapping
    back on failure.

Touch DnD is currently not supported — HTML5 native DnD doesn't
work on touch. Adding a polyfill is a separate concern (the user
explicitly asked for drag-and-drop; can revisit for mobile later).

Regression test in tests/activities.test.ts covers:
  - default order is newest-first
  - a custom position via PATCH /sort moves a row
  - ordering is per-viewer (A's drag doesn't affect B's list)
  - a fresh activity created after a custom position floats above
    the user's custom-positioned rows (because -created_at is much
    more negative than any reasonable custom position float)
  - 401 without auth
  - 400 on missing or non-finite position

96 tests pass total; typecheck clean; build ok.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:47:55 +02:00
commit 8295a35c94
9 changed files with 361 additions and 102 deletions

View file

@ -18,8 +18,18 @@
/** 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;
/** Whether to render a drag handle that initiates HTML5 DnD on this row.
* Parent owns the actual dragstart/dragend wiring — the handle just
* forwards events upward. */
draggable?: boolean;
onDragHandleStart?: (e: DragEvent) => void;
onDragHandleEnd?: () => void;
}
let { activity, privateCleartext = null, onDeleted, onEdit, onChanged }: Props = $props();
let {
activity, privateCleartext = null,
onDeleted, onEdit, onChanged,
draggable = false, onDragHandleStart, onDragHandleEnd,
}: 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
@ -192,7 +202,23 @@
</p>
{/snippet}
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
<article class="card" aria-labelledby={`act-${activity.id}-h`}
style={draggable ? 'display: flex; align-items: flex-start; gap: 0.75rem;' : ''}>
{#if draggable}
<!-- Drag handle. Only the handle is `draggable` so clicking the title
or buttons doesn't initiate a drag. Native HTML5 DnD; touch users
currently can't reorder (separate UI concern). -->
<button
type="button"
draggable="true"
class="drag-handle"
ondragstart={onDragHandleStart}
ondragend={onDragHandleEnd}
aria-label="Dra for å endre rekkefølge"
title="Dra for å endre rekkefølge"
>⋮⋮</button>
{/if}
<div style={draggable ? 'flex: 1; min-width: 0;' : ''}>
{#if activity.visibility === 'private'}
{#if decrypted}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
@ -200,6 +226,11 @@
{decrypted.title}
</a>
<span class="vis-badge private">Privat</span>
<!-- Bookmark marker — private rows never have one (always false), but
keep the slot consistent so all rows look the same shape. -->
{#if activity.viewer_bookmarked}
<span class="vis-badge bookmarked" title="Bokmerket">★ Bokmerket</span>
{/if}
</h3>
{#if decrypted.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{decrypted.description}</p>
@ -233,6 +264,9 @@
Offentlig
{/if}
</span>
{#if activity.viewer_bookmarked}
<span class="vis-badge bookmarked" title="Bokmerket">★ Bokmerket</span>
{/if}
</h3>
{#if activity.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{activity.description}</p>
@ -299,4 +333,5 @@
{copiedAt ? 'Kopiert!' : 'Del'}
</button>
</div>
</div>
</article>

View file

@ -47,8 +47,6 @@
}
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));
}
@ -56,11 +54,7 @@
activities = activities.filter((a) => a.id !== id);
}
/**
* Pre-decrypt private payloads once per activity so search can match against
* the cleartext. We can't avoid this — search has to see what the user sees.
* The cache lives in JS memory alongside the DEK and is dropped on logout.
*/
/** Pre-decrypt private payloads once per render so search can match. */
const privateCleartext = $derived.by(() => {
const map = new Map<string, PrivatePayload>();
if (!session.dek) return map;
@ -71,9 +65,7 @@
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
session.dek,
));
} catch {
// skip — the row will show an error inline
}
} catch { /* skip */ }
}
return map;
});
@ -96,27 +88,100 @@
].some((s) => s.toLowerCase().includes(needle));
}
// The list is now unified — one column, sorted by sort_position. The
// server's COALESCE(custom, -created_at) handles both manually-positioned
// and not-yet-touched rows. Bookmark/friends/private/semi/public are all
// intermingled; their vis-badges tell the viewer which is which.
const filtered = $derived(
activities
.filter((a) => !publicOnly || a.visibility !== 'private')
.filter((a) => matchesQuery(a, query)),
.filter((a) => matchesQuery(a, query))
.slice()
.sort((a, b) => a.sort_position - b.sort_position),
);
// 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 friends = $derived(filtered.filter((a) => a.visibility === 'friends'));
const semi = $derived(filtered.filter((a) => a.visibility === 'semi'));
const pub = $derived(filtered.filter((a) => a.visibility === 'public'));
// --- Drag-and-drop reordering --------------------------------------------
// HTML5 native DnD. Drag handle is on each row; only logged-in users can
// drag (anonymous list is read-only). Touch users currently can't drag
// — adding a touch polyfill is a separate concern.
let dragId: string | null = $state(null);
let dragOverId: string | null = $state(null);
let dragOverHalf: 'above' | 'below' | null = $state(null);
function onDragStart(id: string, e: DragEvent) {
if (!session.user) { e.preventDefault(); return; }
dragId = id;
e.dataTransfer?.setData('text/plain', id);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDragOver(targetId: string, e: DragEvent) {
if (!dragId || dragId === targetId) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const t = e.currentTarget as HTMLElement;
const rect = t.getBoundingClientRect();
dragOverId = targetId;
dragOverHalf = e.clientY - rect.top < rect.height / 2 ? 'above' : 'below';
}
function onDragLeaveRow(targetId: string) {
if (dragOverId === targetId) {
dragOverId = null;
dragOverHalf = null;
}
}
function onDragEnd() {
dragId = null;
dragOverId = null;
dragOverHalf = null;
}
async function onDrop(targetId: string, e: DragEvent) {
e.preventDefault();
const src = dragId;
const half = dragOverHalf;
onDragEnd();
if (!src || src === targetId || !half) return;
// Compute the new position via midpoint between neighbours of the drop
// slot. The current `filtered` is the on-screen order; find the target's
// index and adjust.
const idx = filtered.findIndex((a) => a.id === targetId);
if (idx < 0) return;
const target = filtered[idx]!;
let leftPos: number | null = null;
let rightPos: number | null = null;
if (half === 'above') {
// Drop above target → between (idx - 1) and target. Skip the source
// itself if it was at idx-1 (would compute relative to itself).
let prev = idx - 1;
if (prev >= 0 && filtered[prev]!.id === src) prev -= 1;
leftPos = prev >= 0 ? filtered[prev]!.sort_position : null;
rightPos = target.sort_position;
} else {
// Drop below target → between target and (idx + 1).
let next = idx + 1;
if (next < filtered.length && filtered[next]!.id === src) next += 1;
leftPos = target.sort_position;
rightPos = next < filtered.length ? filtered[next]!.sort_position : null;
}
const newPos =
leftPos !== null && rightPos !== null ? (leftPos + rightPos) / 2 :
leftPos !== null ? leftPos + 1.0 :
rightPos !== null ? rightPos - 1.0 :
0;
// Optimistic: update the local row immediately.
const before = activities;
activities = activities.map((a) =>
a.id === src ? { ...a, sort_position: newPos } as Activity : a,
);
try {
await api.sortActivity(src, newPos);
} catch {
activities = before;
error = 'Kunne ikke endre rekkefølge.';
}
}
</script>
<section aria-label="Aktiviteter">
@ -177,78 +242,41 @@
<p class="muted">Laster …</p>
{:else if error}
<p class="error">{error}</p>
{:else if filtered.length === 0}
{#if query}
<p class="muted">Ingen treff på «{query}».</p>
{:else}
<p class="muted">
{publicOnly ? 'Ingen offentlige aktiviteter ennå.' : 'Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.'}
</p>
{/if}
{: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}
/>
<div role="list" aria-label="Aktivitetsliste">
{#each filtered as a (a.id)}
<div role="listitem"
ondragover={(e) => onDragOver(a.id, e)}
ondragleave={() => onDragLeaveRow(a.id)}
ondrop={(e) => onDrop(a.id, e)}
style={`position: relative;${dragId === a.id ? ' opacity: 0.4;' : ''}`}
>
{#if dragOverId === a.id && dragOverHalf === 'above'}
<div aria-hidden="true" style="position: absolute; top: -2px; left: 0; right: 0; height: 3px; background: var(--accent); border-radius: 2px;"></div>
{/if}
<ActivityRow
activity={a}
privateCleartext={a.visibility === 'private' ? (privateCleartext.get(a.id) ?? null) : null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
draggable={!!session.user && !publicOnly}
onDragHandleStart={(e) => onDragStart(a.id, e)}
onDragHandleEnd={onDragEnd}
/>
{#if dragOverId === a.id && dragOverHalf === 'below'}
<div aria-hidden="true" style="position: absolute; bottom: -2px; left: 0; right: 0; height: 3px; background: var(--accent); border-radius: 2px;"></div>
{/if}
</div>
{/each}
{/if}
{#if myPrivate.length}
<h2>Dine private</h2>
{#each myPrivate as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={privateCleartext.get(a.id) ?? null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if friends.length}
<h2>Venner</h2>
{#each friends as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if semi.length}
<h2>Anonyme</h2>
{#each semi as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if pub.length}
<h2>Offentlige</h2>
{#each pub as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if !myPrivate.length && !semi.length && !pub.length}
{#if query}
<p class="muted">Ingen treff på «{query}».</p>
{:else}
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
{/if}
{/if}
</div>
{/if}
</section>

View file

@ -83,6 +83,10 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'DELETE' }),
sortActivity: (id: string, position: number) =>
http<{ ok: true }>(`/activities/${encodeURIComponent(id)}/sort`, {
method: 'PATCH', body: JSON.stringify({ position }),
}),
// --- tags -----------------------------------------------------------------
tagSuggestions: (q: string, limit = 20) =>

View file

@ -286,6 +286,38 @@ nav.top h1::after {
.vis-badge.semi { background: color-mix(in srgb, var(--vis-semi) 18%, transparent); color: var(--vis-semi); }
.vis-badge.public { background: color-mix(in srgb, var(--vis-public) 18%, transparent); color: var(--vis-public); }
.vis-badge.friends { background: color-mix(in srgb, var(--vis-friends) 20%, transparent); color: var(--vis-friends); }
.vis-badge.bookmarked {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
/* Bookmarked badge already contains the glyph in its text content, so we
override the ::before-emoji default that .vis-badge applies otherwise. */
.vis-badge.bookmarked::before { content: none; }
/* Drag handle on each activity row in the unified list view. The button
itself is what `draggable="true"` is bound to so users have a clear
target; the rest of the card stays selectable. */
.drag-handle {
min-height: 24px !important;
min-width: 28px !important;
padding: 0 0.3rem !important;
cursor: grab;
background: transparent !important;
border: 1px solid transparent !important;
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
user-select: none;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
}
.drag-handle:hover {
background: var(--accent-soft) !important;
border-color: var(--border) !important;
}
.drag-handle:active { cursor: grabbing; }
.error { color: var(--danger); margin-top: 0.5rem; }