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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -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")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user