feat(projects): drag the sidebar into the order you want

Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 15:59:31 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent 3f4af6c0ab
commit c4e9e1519b
8 changed files with 280 additions and 1 deletions
+93 -1
View File
@@ -33,8 +33,54 @@
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
// Sorted here rather than relying on the order the server sent, so a drag
// settles the moment it is dropped instead of after the round trip.
let own = $derived(
app.projects
.filter((p) => !p.is_inbox && !p.shared)
.sort((a, b) => a.sort_order - b.sort_order)
);
let current = $derived($page.url.pathname);
// --- reordering ---------------------------------------------------------------
let draggingId = $state(null);
let overId = $state(null);
let overBelow = $state(false);
function onDragStart(event, project) {
draggingId = project.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', project.id);
}
function onDragOver(event, project) {
if (!draggingId || draggingId === project.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const box = event.currentTarget.getBoundingClientRect();
overId = project.id;
overBelow = event.clientY > box.top + box.height / 2;
}
async function onDrop(event, target) {
event.preventDefault();
const id = draggingId;
const below = overBelow;
draggingId = null;
overId = null;
if (!id || id === target.id) return;
const without = own.filter((p) => p.id !== id);
const at = without.findIndex((p) => p.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
await app.reorderProjects(ordered.map((p) => p.id));
}
</script>
<nav class="sidebar" aria-label="Hovedmenu">
@@ -90,8 +136,21 @@
{#each own as project (project.id)}
<a
href="/projekt/{project.id}"
class="sortable"
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
class:drop-above={overId === project.id && !overBelow}
class:drop-below={overId === project.id && overBelow}
onclick={onnavigate}
draggable="true"
ondragstart={(e) => onDragStart(e, project)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, project)}
ondragleave={() => (overId = null)}
ondrop={(e) => onDrop(e, project)}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
@@ -280,6 +339,39 @@
font-weight: 500;
}
/* Only your own projects reorder. A shared one sits where its owner put it —
sort_order is a column on the project, not a preference per viewer. */
a.sortable {
position: relative;
}
a.sortable.dragging {
opacity: 0.4;
}
/* A line in the gap rather than a highlighted row: the gap is the target. */
a.sortable::before {
content: '';
position: absolute;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
a.sortable.drop-above::before {
top: -1px;
opacity: 1;
}
a.sortable.drop-below::before {
bottom: -1px;
opacity: 1;
}
.dot {
width: 6px;
height: 6px;