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