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
+21
View File
@@ -0,0 +1,21 @@
"""notes.position for manual drag-reorder
Revision ID: 0008
Revises: 0007
Create Date: 2026-07-20
"""
from alembic import op
import sqlalchemy as sa
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("position", sa.Integer(), nullable=False, server_default="0"))
def downgrade() -> None:
op.drop_column("notes", "position")
+10 -2
View File
@@ -5,8 +5,12 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import NoteChecklist from "./NoteChecklist.vue"; import NoteChecklist from "./NoteChecklist.vue";
defineProps<{ note: Note }>(); defineProps<{ note: Note; reorderable?: boolean }>();
const emit = defineEmits<{ (e: "open", note: Note): void }>(); const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
(e: "drop", note: Note): void;
}>();
const notes = useNotesStore(); const notes = useNotesStore();
function cardClass(color: NoteColor): string { function cardClass(color: NoteColor): string {
@@ -18,6 +22,10 @@ function cardClass(color: NoteColor): string {
<div <div
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md" class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="cardClass(note.color)" :class="cardClass(note.color)"
:draggable="reorderable && !note.trashed"
@dragstart="emit('dragstart', note)"
@dragover.prevent
@drop="emit('drop', note)"
> >
<img <img
v-if="note.attachments.length" v-if="note.attachments.length"
+15
View File
@@ -30,6 +30,7 @@ export interface Note {
body: string; body: string;
color: NoteColor; color: NoteColor;
kind: NoteKind; kind: NoteKind;
position: number;
pinned: boolean; pinned: boolean;
archived: boolean; archived: boolean;
trashed: boolean; trashed: boolean;
@@ -49,6 +50,7 @@ export const useNotesStore = defineStore("notes", () => {
function sortItems(): void { function sortItems(): void {
items.value.sort((a, b) => { items.value.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; 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 ?? ""); 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}`)); 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> { async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`)); reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
} }
@@ -171,6 +185,7 @@ export const useNotesStore = defineStore("notes", () => {
deleteItem, deleteItem,
uploadAttachment, uploadAttachment,
deleteAttachment, deleteAttachment,
reorder,
trash, trash,
restore, restore,
deleteForever, deleteForever,
+44 -3
View File
@@ -46,6 +46,23 @@ function openEditor(note: Note) {
function closeEditor() { function closeEditor() {
editing.value = null; 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> </script>
<template> <template>
@@ -64,7 +81,15 @@ function closeEditor() {
<section v-if="pinnedNotes.length"> <section v-if="pinnedNotes.length">
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2> <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"> <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> </div>
</section> </section>
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''"> <section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
@@ -72,13 +97,29 @@ function closeEditor() {
Others Others
</h2> </h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"> <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> </div>
</section> </section>
</template> </template>
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"> <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> </div>
</template> </template>
</div> </div>
+4 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -42,6 +42,8 @@ class Note(Base):
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default") color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# 'text' (freeform body) or 'list' (a checklist of note_items). # 'text' (freeform body) or 'list' (a checklist of note_items).
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text") kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
# Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
# Soft delete: non-null => in Trash. Restore sets it back to null. # Soft delete: non-null => in Trash. Restore sets it back to null.
@@ -58,6 +60,7 @@ class Note(Base):
"body": self.body, "body": self.body,
"color": self.color, "color": self.color,
"kind": self.kind, "kind": self.kind,
"position": self.position,
"pinned": self.pinned, "pinned": self.pinned,
"archived": self.archived, "archived": self.archived,
"trashed": self.deleted_at is not None, "trashed": self.deleted_at is not None,
+35 -1
View File
@@ -146,7 +146,7 @@ async def list_notes():
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({"error": "invalid label"}), 400 return jsonify({"error": "invalid label"}), 400
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid))) stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc()) stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all() notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)}) return jsonify({"notes": await _serialize_notes(db, notes)})
@@ -175,6 +175,33 @@ async def search_notes():
return jsonify({"notes": await _serialize_notes(db, notes)}) return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.post("/reorder")
@login_required
async def reorder_notes():
data = await request.get_json(silent=True) or {}
ids = data.get("ids")
if not isinstance(ids, list):
return jsonify({"error": "ids must be a list"}), 400
parsed: list = []
for rid in ids:
try:
parsed.append(uuid.UUID(str(rid)))
except (ValueError, TypeError):
return jsonify({"error": "invalid id"}), 400
async with session_scope() as db:
owned = {
n.id: n
for n in (await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.id.in_(parsed)))).all()
}
total = len(parsed)
for index, nid in enumerate(parsed):
note = owned.get(nid)
if note is not None:
note.position = total - index
await db.commit()
return jsonify({"ok": True})
@bp.post("") @bp.post("")
@login_required @login_required
async def create_note(): async def create_note():
@@ -184,11 +211,18 @@ async def create_note():
if is_empty_note(title, body): if is_empty_note(title, body):
return jsonify({"error": "note is empty"}), 400 return jsonify({"error": "note is empty"}), 400
async with session_scope() as db: async with session_scope() as db:
# New notes go to the top of the manual order.
max_pos = await db.scalar(
select(func.coalesce(func.max(Note.position), 0)).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
note = Note( note = Note(
owner_id=g.user_id, owner_id=g.user_id,
title=title.strip() or None, title=title.strip() or None,
body=body, body=body,
color=normalize_color(data.get("color")), color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
) )
db.add(note) db.add(note)
await db.commit() await db.commit()
+6
View File
@@ -68,3 +68,9 @@ async def test_upload_attachment_requires_auth(app):
client = app.test_client() client = app.test_client()
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments") resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments")
assert resp.status_code == 401 assert resp.status_code == 401
async def test_reorder_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/reorder", json={"ids": []})
assert resp.status_code == 401