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);
+}
@@ -64,7 +81,15 @@ function closeEditor() {
@@ -72,13 +97,29 @@ function closeEditor() {
Others
-
+
-
+
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