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:
parent
e64d5450f8
commit
8295a35c94
9 changed files with 361 additions and 102 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue