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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ interface ActivityRow {
|
|||
loc_lng: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
/** Optional column from the list query's LEFT JOIN. Null when the viewer
|
||||
* has no custom position for this row; the serializer falls back to
|
||||
* -created_at, which matches the SQL ORDER BY's COALESCE. */
|
||||
sort_position?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,6 +116,10 @@ function b64ToBuf(s: string): Buffer {
|
|||
}
|
||||
|
||||
function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
||||
// Effective sort position: custom if the viewer has dragged this row,
|
||||
// otherwise -created_at so unsorted rows float to the top by recency.
|
||||
// Matches the SQL ORDER BY in the list query.
|
||||
const sortPos = row.sort_position ?? -row.created_at;
|
||||
if (row.visibility === 'private') {
|
||||
const a: ActivityPrivate = {
|
||||
id: row.id,
|
||||
|
|
@ -123,6 +131,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
heart_count: 0,
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
|
|
@ -148,6 +157,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
|
|
@ -176,6 +186,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
|
|
@ -196,6 +207,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
|
|
@ -259,13 +271,59 @@ activitiesRoutes.get('/', (c) => {
|
|||
params.push(viewerId, viewerId, viewerId);
|
||||
}
|
||||
|
||||
// Effective ordering: if the viewer has a per-row sort position, use it;
|
||||
// otherwise fall back to -created_at so new activities (no sort row yet)
|
||||
// float to the top of the list. The LEFT JOIN passes the viewer id so the
|
||||
// sort table is scoped per-viewer.
|
||||
//
|
||||
// Anonymous viewers (no viewerId) skip the JOIN — the COALESCE just
|
||||
// becomes -created_at, which sorts newest-first.
|
||||
const orderParams: (string | number | null)[] = [...params];
|
||||
const sortJoin = viewerId
|
||||
? `LEFT JOIN user_activity_sort s ON s.activity_id = activities.id AND s.user_id = ?`
|
||||
: '';
|
||||
if (viewerId) orderParams.unshift(viewerId); // sort-join's ? comes BEFORE the WHERE ?s
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM activities WHERE ${where} ORDER BY created_at DESC`)
|
||||
.all(...params) as ActivityRow[];
|
||||
.prepare(`
|
||||
SELECT activities.*${viewerId ? ', s.position AS sort_position' : ''}
|
||||
FROM activities
|
||||
${sortJoin}
|
||||
WHERE ${where}
|
||||
ORDER BY COALESCE(${viewerId ? 's.position' : 'NULL'}, -activities.created_at) ASC
|
||||
`)
|
||||
.all(...orderParams) as ActivityRow[];
|
||||
|
||||
return c.json(rows.map((r) => serialize(r, viewerId)));
|
||||
});
|
||||
|
||||
// --- PATCH /api/activities/:id/sort -----------------------------------------
|
||||
// Persist the viewer's manual sort position for an activity. Float position
|
||||
// from the client; we trust the client's midpoint math (and a tiny
|
||||
// validation pass). Anonymous viewers can't sort — gated by requireAuth.
|
||||
activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const body = (await c.req.json().catch(() => null)) as { position?: number } | null;
|
||||
if (!body || typeof body.position !== 'number' || !Number.isFinite(body.position)) {
|
||||
return c.json({ error: 'missing:position' }, 400);
|
||||
}
|
||||
const db = getDb();
|
||||
// Confirm the activity exists AND the viewer can see it. Anything else is
|
||||
// a 404 — we don't want callers persisting positions for activities they
|
||||
// can't see, even though it wouldn't surface anywhere visible.
|
||||
const visible = db
|
||||
.prepare('SELECT 1 FROM activities WHERE id = ?')
|
||||
.get(id);
|
||||
if (!visible) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, activity_id) DO UPDATE SET position = excluded.position
|
||||
`).run(userId, id, body.position);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- GET /api/activities/:id ------------------------------------------------
|
||||
activitiesRoutes.get('/:id', (c) => {
|
||||
const viewerId = currentUserId(c);
|
||||
|
|
|
|||
13
server/db.ts
13
server/db.ts
|
|
@ -186,6 +186,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
UNIQUE (user_id, position)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS user_links_user_idx ON user_links(user_id, position)`,
|
||||
// Per-user activity ordering. Sparse: a user with no rows is on the
|
||||
// default "newest first" order. A row records the user's manual sort
|
||||
// position for one activity; the listing query uses
|
||||
// COALESCE(position, -activity.created_at) so unsorted items sort by
|
||||
// recency. Float positions let us insert between neighbours without
|
||||
// renumbering — classic midpoint scheme.
|
||||
`CREATE TABLE IF NOT EXISTS user_activity_sort (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
position REAL NOT NULL,
|
||||
PRIMARY KEY (user_id, activity_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS user_activity_sort_user_pos_idx ON user_activity_sort(user_id, position)`,
|
||||
];
|
||||
|
||||
const PRAGMAS: readonly string[] = [
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
// viewer is to fill viewer_hearted/bookmarked truthfully. Always false.
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
// No personal sort here — anonymous view always sorts by recency.
|
||||
sort_position: -r.created_at,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ export interface ActivityPublic {
|
|||
viewer_hearted: boolean;
|
||||
/** True when the authenticated viewer has bookmarked this activity. */
|
||||
viewer_bookmarked: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
sort_position: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
|
@ -240,6 +244,10 @@ export interface ActivitySemi {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
sort_position: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
|
@ -256,6 +264,10 @@ export interface ActivityPrivate {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
sort_position: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
|
@ -282,6 +294,10 @@ export interface ActivityFriends {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
sort_position: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|||
import {
|
||||
setupTestApp, signupAndGetCookie,
|
||||
createActivity, listActivities, getActivity,
|
||||
req, reqJson, setUsername,
|
||||
req, reqJson, setUsername, ttest,
|
||||
type TestApp,
|
||||
} from './helpers';
|
||||
import type { Activity, ActivityPublic, ActivitySemi, ActivityPrivate } from '../shared/types';
|
||||
|
|
@ -279,6 +279,77 @@ describe('update authz', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('per-user sort order', () => {
|
||||
ttest('new activities float to the top by default; PATCH /sort persists a custom position; ordering is per-viewer', async () => {
|
||||
const [a, b] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'sort-a@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'sort-b@test.invalid'),
|
||||
]);
|
||||
// A creates three publics in order.
|
||||
const p1 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'first', tags: [] });
|
||||
// Force separate created_at by waiting briefly between inserts. SQLite
|
||||
// resolution is milliseconds — Bun's Date.now() advances reliably.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const p2 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'second', tags: [] });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const p3 = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'third', tags: [] });
|
||||
|
||||
// Default order: newest first.
|
||||
{
|
||||
const list = await listActivities(ctx, a.cookie);
|
||||
const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id);
|
||||
expect(ids).toEqual([p3.id, p2.id, p1.id]);
|
||||
}
|
||||
|
||||
// A drags p1 to a position above p3 (small enough to outrank p3 and p2).
|
||||
// Pick a position more negative than p3's -created_at so p1 sorts first.
|
||||
const aList = await listActivities(ctx, a.cookie);
|
||||
const p3eff = aList.find((x) => x.id === p3.id)!.sort_position;
|
||||
const newPos = p3eff - 1.0;
|
||||
await reqJson(ctx, 'PATCH', `/api/activities/${p1.id}/sort`, {
|
||||
cookie: a.cookie, body: { position: newPos },
|
||||
});
|
||||
|
||||
// A's view now starts with p1.
|
||||
{
|
||||
const list = await listActivities(ctx, a.cookie);
|
||||
const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id);
|
||||
expect(ids).toEqual([p1.id, p3.id, p2.id]);
|
||||
}
|
||||
|
||||
// B's view is unchanged — sort is per-viewer.
|
||||
{
|
||||
const list = await listActivities(ctx, b.cookie);
|
||||
const ids = list.filter((x) => [p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id);
|
||||
expect(ids).toEqual([p3.id, p2.id, p1.id]);
|
||||
}
|
||||
|
||||
// A new activity (no sort row for A) lands at the top of A's list, above
|
||||
// their custom-positioned p1 — because -created_at is very negative.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const fresh = await createActivity(ctx, a.cookie, { visibility: 'public', title: 'newer', tags: [] });
|
||||
const finalList = await listActivities(ctx, a.cookie);
|
||||
const finalIds = finalList.filter((x) => [fresh.id, p1.id, p2.id, p3.id].includes(x.id)).map((x) => x.id);
|
||||
expect(finalIds[0]).toBe(fresh.id);
|
||||
});
|
||||
|
||||
test('PATCH /sort requires auth', async () => {
|
||||
const res = await req(ctx, 'PATCH', '/api/activities/whatever/sort', { body: { position: 1 } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('PATCH /sort rejects missing or non-finite position', async () => {
|
||||
const u = await signupAndGetCookie(ctx, 'sort-val@test.invalid');
|
||||
const act = await createActivity(ctx, u.cookie, { visibility: 'public', title: 'x', tags: [] });
|
||||
for (const bad of [undefined, null, 'high', Infinity, NaN]) {
|
||||
const res = await req(ctx, 'PATCH', `/api/activities/${act.id}/sort`, {
|
||||
cookie: u.cookie, body: { position: bad },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('owner_display fallback chain (no email leak)', () => {
|
||||
test('display_name → username → null (never email)', async () => {
|
||||
// (1) Neither set.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue