Add drag-drop and keyboard link ordering
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -311,11 +319,13 @@ function AdminPage({
|
||||
links,
|
||||
onSave,
|
||||
onDelete,
|
||||
onReorder,
|
||||
showToast,
|
||||
}: {
|
||||
links: LinkItem[];
|
||||
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
onReorder: (items: LinkItem[]) => Promise<void>;
|
||||
showToast: (message: string, tone?: 'success' | 'error') => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<LinkForm>(emptyForm());
|
||||
@@ -323,6 +333,12 @@ function AdminPage({
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [restoreText, setRestoreText] = useState('');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [orderedLinks, setOrderedLinks] = useState<LinkItem[]>(links);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(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 (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<div className="space-y-4">
|
||||
@@ -445,13 +485,24 @@ function AdminPage({
|
||||
|
||||
<div className="panel">
|
||||
<ul className="admin-list">
|
||||
{links.map((link) => (
|
||||
<li key={link.id} className="admin-row">
|
||||
{orderedLinks.map((link, index) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="admin-row"
|
||||
draggable
|
||||
onDragStart={() => setDraggingId(link.id)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={async () => {
|
||||
await handleDropOn(link.id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-slate-100">{link.name}</div>
|
||||
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, -1)} disabled={index === 0}>Up</button>
|
||||
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, 1)} disabled={index === orderedLinks.length - 1}>Down</button>
|
||||
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
|
||||
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ export type LinkItem = {
|
||||
url: string;
|
||||
description: string;
|
||||
category: string;
|
||||
sort_order?: number;
|
||||
enabled: boolean;
|
||||
icon_url: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user