From 339cc5c2d24e04d69f7ef23bde93faaa19f07c02 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 22:28:43 -0400 Subject: [PATCH] M2 drag-reorder: notes.position + reorder API + native DnD - 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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0008_note_position.py | 21 ++++++++++++ frontend/src/components/NoteCard.vue | 12 +++++-- frontend/src/stores/notes.ts | 15 ++++++++ frontend/src/views/BoardView.vue | 47 ++++++++++++++++++++++++-- src/thoughtsync/models/note.py | 5 ++- src/thoughtsync/notes.py | 36 +++++++++++++++++++- tests/test_notes.py | 6 ++++ 7 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/0008_note_position.py diff --git a/alembic/versions/0008_note_position.py b/alembic/versions/0008_note_position.py new file mode 100644 index 0000000..ec0c743 --- /dev/null +++ b/alembic/versions/0008_note_position.py @@ -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") diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index d0f1b5f..d704d90 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -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 {
{ 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(`/api/notes/${id}/attachments/${attId}`)); } + async function reorder(orderedIds: string[]): Promise { + // 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 { reconcile(await api.post(`/api/notes/${id}/trash`)); } @@ -171,6 +185,7 @@ export const useNotesStore = defineStore("notes", () => { deleteItem, uploadAttachment, deleteAttachment, + reorder, trash, restore, deleteForever, diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index f09e525..819aa5b 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -46,6 +46,23 @@ function openEditor(note: Note) { function closeEditor() { editing.value = null; } + +const draggingId = ref(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); +}
- +
diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py index 9a52741..39a5a33 100644 --- a/src/thoughtsync/models/note.py +++ b/src/thoughtsync/models/note.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid 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.orm import Mapped, mapped_column @@ -42,6 +42,8 @@ class Note(Base): color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default") # 'text' (freeform body) or 'list' (a checklist of note_items). 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()) archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # Soft delete: non-null => in Trash. Restore sets it back to null. @@ -58,6 +60,7 @@ class Note(Base): "body": self.body, "color": self.color, "kind": self.kind, + "position": self.position, "pinned": self.pinned, "archived": self.archived, "trashed": self.deleted_at is not None, diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index 87df1a5..6fc6afa 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -146,7 +146,7 @@ async def list_notes(): except (ValueError, TypeError): return jsonify({"error": "invalid label"}), 400 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() return jsonify({"notes": await _serialize_notes(db, notes)}) @@ -175,6 +175,33 @@ async def search_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("") @login_required async def create_note(): @@ -184,11 +211,18 @@ async def create_note(): if is_empty_note(title, body): return jsonify({"error": "note is empty"}), 400 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( owner_id=g.user_id, title=title.strip() or None, body=body, color=normalize_color(data.get("color")), + position=int(max_pos) + 1, ) db.add(note) await db.commit() diff --git a/tests/test_notes.py b/tests/test_notes.py index a26cf89..6ca3875 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -68,3 +68,9 @@ async def test_upload_attachment_requires_auth(app): client = app.test_client() resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/attachments") 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