M2 drag-reorder: notes.position + reorder API + native DnD
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s

- Migration 0008: notes.position (int). Board orders pinned -> position ->
  updated_at; new notes created at top (max position + 1). POST /api/notes/reorder
  assigns positions from the given order (owner-scoped).
- notes store: position on Note, position-aware sort, optimistic reorder().
- NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves
  the dragged note before the drop target and persists.

Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns
reflow); order persists correctly. Candidate for a polish pass / layout tweak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 22:28:43 -04:00
co-authored by Claude Opus 4.8
parent e4c898cd1b
commit 339cc5c2d2
7 changed files with 135 additions and 7 deletions
+10 -2
View File
@@ -5,8 +5,12 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import NoteChecklist from "./NoteChecklist.vue";
defineProps<{ note: Note }>();
const emit = defineEmits<{ (e: "open", note: Note): void }>();
defineProps<{ note: Note; reorderable?: boolean }>();
const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
(e: "drop", note: Note): void;
}>();
const notes = useNotesStore();
function cardClass(color: NoteColor): string {
@@ -18,6 +22,10 @@ function cardClass(color: NoteColor): string {
<div
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="cardClass(note.color)"
:draggable="reorderable && !note.trashed"
@dragstart="emit('dragstart', note)"
@dragover.prevent
@drop="emit('drop', note)"
>
<img
v-if="note.attachments.length"
+15
View File
@@ -30,6 +30,7 @@ export interface Note {
body: string;
color: NoteColor;
kind: NoteKind;
position: number;
pinned: boolean;
archived: boolean;
trashed: boolean;
@@ -49,6 +50,7 @@ export const useNotesStore = defineStore("notes", () => {
function sortItems(): void {
items.value.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.position !== b.position) return b.position - a.position;
return (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
});
}
@@ -139,6 +141,18 @@ export const useNotesStore = defineStore("notes", () => {
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
}
async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort,
// then persist.
const total = orderedIds.length;
orderedIds.forEach((id, index) => {
const n = items.value.find((x) => x.id === id);
if (n) n.position = total - index;
});
sortItems();
await api.post("/api/notes/reorder", { ids: orderedIds });
}
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
}
@@ -171,6 +185,7 @@ export const useNotesStore = defineStore("notes", () => {
deleteItem,
uploadAttachment,
deleteAttachment,
reorder,
trash,
restore,
deleteForever,
+44 -3
View File
@@ -46,6 +46,23 @@ function openEditor(note: Note) {
function closeEditor() {
editing.value = null;
}
const draggingId = ref<string | null>(null);
function onDragStart(note: Note) {
draggingId.value = note.id;
}
async function onDrop(target: Note) {
const from = draggingId.value;
draggingId.value = null;
if (!from || from === target.id) return;
const order = notes.items.map((n) => n.id);
const fromIdx = order.indexOf(from);
const toIdx = order.indexOf(target.id);
if (fromIdx < 0 || toIdx < 0) return;
order.splice(fromIdx, 1);
order.splice(toIdx, 0, from);
await notes.reorder(order);
}
</script>
<template>
@@ -64,7 +81,15 @@ function closeEditor() {
<section v-if="pinnedNotes.length">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard v-for="n in pinnedNotes" :key="n.id" :note="n" @open="openEditor" />
<NoteCard
v-for="n in pinnedNotes"
:key="n.id"
:note="n"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@drop="onDrop"
/>
</div>
</section>
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
@@ -72,13 +97,29 @@ function closeEditor() {
Others
</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard v-for="n in otherNotes" :key="n.id" :note="n" @open="openEditor" />
<NoteCard
v-for="n in otherNotes"
:key="n.id"
:note="n"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@drop="onDrop"
/>
</div>
</section>
</template>
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
<NoteCard
v-for="n in notes.items"
:key="n.id"
:note="n"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@drop="onDrop"
/>
</div>
</template>
</div>