From 58f7702074d3af6fa00388b6d6976cd20813797e Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 21 May 2026 19:45:51 +0000 Subject: [PATCH] Add drag-drop and keyboard link ordering --- backend/main.py | 35 ++++++++++++++++++++++++++ frontend/src/app.tsx | 55 +++++++++++++++++++++++++++++++++++++++-- frontend/src/lib/api.ts | 1 + 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 42fd53e..2fbb8e3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -198,6 +198,15 @@ class RestoreIn(BaseModel): confirm: bool = False +class ReorderItem(BaseModel): + id: int + sort_order: int + + +class ReorderIn(BaseModel): + items: list[ReorderItem] + + def utc_now_iso() -> str: return datetime.utcnow().isoformat() @@ -637,6 +646,32 @@ def update_link( return {"ok": True} +@app.patch("/api/links/order") +def reorder_links(request: Request, inp: ReorderIn): + require_admin(request) + require_csrf(request) + if not inp.items: + raise HTTPException(422, "items must not be empty") + seen: set[int] = set() + with db() as c: + c.begin() + try: + with c.cursor() as cur: + for item in inp.items: + if item.id in seen: + raise HTTPException(422, "duplicate link id in reorder payload") + seen.add(item.id) + cur.execute("update links set sort_order=%s,updated_at=%s where id=%s", (item.sort_order, utc_now_db(), item.id)) + if cur.rowcount == 0: + raise HTTPException(404, f"link {item.id} not found") + c.commit() + except Exception: + c.rollback() + raise + log_event(request, "links.reorder", count=len(inp.items)) + return {"ok": True} + + @app.get("/api/admin/backup") def backup(request: Request): require_admin(request) diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 83080a8..099881b 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -84,6 +84,14 @@ export default function App() { showToast('Link deleted.'); await refresh(); }} + onReorder={async (items) => { + await api.request('/api/links/order', { + method: 'PATCH', + body: JSON.stringify({ items: items.map((item, index) => ({ id: item.id, sort_order: index })) }), + }); + showToast('Order saved.'); + await refresh(); + }} showToast={showToast} /> @@ -311,11 +319,13 @@ function AdminPage({ links, onSave, onDelete, + onReorder, showToast, }: { links: LinkItem[]; onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise; onDelete: (id: number) => Promise; + onReorder: (items: LinkItem[]) => Promise; showToast: (message: string, tone?: 'success' | 'error') => void; }) { const [form, setForm] = useState(emptyForm()); @@ -323,6 +333,12 @@ function AdminPage({ const [editingId, setEditingId] = useState(null); const [restoreText, setRestoreText] = useState(''); const [submitError, setSubmitError] = useState(null); + const [orderedLinks, setOrderedLinks] = useState(links); + const [draggingId, setDraggingId] = useState(null); + + useEffect(() => { + setOrderedLinks(links); + }, [links]); const startEdit = (link: LinkItem) => { setEditingId(link.id); @@ -343,6 +359,30 @@ function AdminPage({ setForm(emptyForm()); }; + const moveBy = async (id: number, delta: number) => { + const index = orderedLinks.findIndex((link) => link.id === id); + const nextIndex = index + delta; + if (index < 0 || nextIndex < 0 || nextIndex >= orderedLinks.length) return; + const next = [...orderedLinks]; + const [item] = next.splice(index, 1); + next.splice(nextIndex, 0, item); + setOrderedLinks(next); + await onReorder(next); + }; + + const handleDropOn = async (targetId: number) => { + if (draggingId === null || draggingId === targetId) return; + const from = orderedLinks.findIndex((link) => link.id === draggingId); + const to = orderedLinks.findIndex((link) => link.id === targetId); + if (from < 0 || to < 0) return; + const next = [...orderedLinks]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + setOrderedLinks(next); + setDraggingId(null); + await onReorder(next); + }; + return (
@@ -445,13 +485,24 @@ function AdminPage({
    - {links.map((link) => ( -
  • + {orderedLinks.map((link, index) => ( +
  • setDraggingId(link.id)} + onDragOver={(event) => event.preventDefault()} + onDrop={async () => { + await handleDropOn(link.id); + }} + >
    {link.name}
    {link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}
    + +
    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 22097ff..e993b50 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,7 @@ export type LinkItem = { url: string; description: string; category: string; + sort_order?: number; enabled: boolean; icon_url: string | null; };