Add drag-and-drop sorting for categories, subcategories, and links via SortableJS

This commit is contained in:
Einar 2026-05-11 14:30:29 +02:00
parent f9deb0f850
commit 2f0aca09e2
4 changed files with 115 additions and 19 deletions

View file

@ -1,7 +1,7 @@
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
import aiosqlite
import shutil
import os
@ -17,7 +17,6 @@ app.add_middleware(
DB_PATH = "/data/startpage.db"
FAVICON_DIR = "/data/favicons"
os.makedirs(FAVICON_DIR, exist_ok=True)
class CategoryIn(BaseModel):
@ -37,6 +36,10 @@ class LinkIn(BaseModel):
favicon: Optional[str] = None
position: Optional[int] = 0
class ReorderIn(BaseModel):
type: str # "categories", "subcategories", "links"
ids: List[int]
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
@ -187,6 +190,25 @@ async def delete_link(link_id: int):
await db.commit()
return {"deleted": link_id}
@app.put("/reorder")
async def reorder(data: ReorderIn):
table_map = {
"categories": "categories",
"subcategories": "subcategories",
"links": "links",
}
if data.type not in table_map:
raise HTTPException(status_code=400, detail="Invalid type")
table = table_map[data.type]
async with aiosqlite.connect(DB_PATH) as db:
for position, item_id in enumerate(data.ids):
await db.execute(
f"UPDATE {table} SET position=? WHERE id=?",
(position, item_id)
)
await db.commit()
return {"reordered": data.type, "count": len(data.ids)}
@app.post("/favicons/upload")
async def upload_favicon(file: UploadFile = File(...)):
filename = file.filename.replace(" ", "_").lower()

View file

@ -15,6 +15,11 @@ const globeSVG = `<svg class="globe-icon" viewBox="0 0 20 20" fill="none" xmlns=
<line x1="10" y1="1" x2="10" y2="19" stroke="#e8722a" stroke-width="1"/>
</svg>`;
// Sortable instances — kept so we can destroy/recreate on re-render
let categorySortable = null;
let subcategorySortable = null;
let linkSortable = null;
async function api(method, path, body) {
const opts = {
method,
@ -31,12 +36,18 @@ const post = (path, body) => api('POST', path, body);
const put = (path, body) => api('PUT', path, body);
const del = (path) => api('DELETE', path);
async function reorder(type, ids) {
await put('/reorder', { type, ids });
}
async function loadCategories() {
state.categories = await get('/categories');
renderCategoryNav();
if (state.categories.length > 0) {
await selectCategory(
state.activeCategoryId || state.categories[0].id
state.activeCategoryId && state.categories.find(c => c.id === state.activeCategoryId)
? state.activeCategoryId
: state.categories[0].id
);
}
}
@ -44,17 +55,33 @@ async function loadCategories() {
function renderCategoryNav() {
const nav = document.getElementById('category-nav');
nav.innerHTML = '';
state.categories.forEach(cat => {
const btn = document.createElement('button');
btn.className = 'cat-tab' + (cat.id === state.activeCategoryId ? ' active' : '');
btn.dataset.id = cat.id;
btn.innerHTML = `<i class="ti ${cat.icon || 'ti-folder'}"></i>${cat.name}`;
btn.onclick = () => selectCategory(cat.id);
btn.oncontextmenu = (e) => {
e.preventDefault();
openEditCategoryModal(cat);
};
btn.oncontextmenu = (e) => { e.preventDefault(); openEditCategoryModal(cat); };
nav.appendChild(btn);
});
// Destroy old instance before creating new one
if (categorySortable) categorySortable.destroy();
categorySortable = Sortable.create(nav, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onEnd: async (evt) => {
const ids = [...nav.querySelectorAll('button[data-id]')]
.map(el => parseInt(el.dataset.id));
state.categories = ids.map((id, i) => {
const cat = state.categories.find(c => c.id === id);
return { ...cat, position: i };
});
await reorder('categories', ids);
}
});
}
async function selectCategory(id) {
@ -78,20 +105,36 @@ function renderSubcategoryBar() {
state.subcategories.forEach(sub => {
const btn = document.createElement('button');
btn.className = 'sub-tab' + (sub.id === state.activeSubcategoryId ? ' active' : '');
btn.dataset.id = sub.id;
btn.textContent = sub.name;
btn.onclick = () => selectSubcategory(sub.id);
btn.oncontextmenu = (e) => {
e.preventDefault();
openEditSubcategoryModal(sub);
};
btn.oncontextmenu = (e) => { e.preventDefault(); openEditSubcategoryModal(sub); };
bar.appendChild(btn);
});
const addBtn = document.createElement('button');
addBtn.className = 'btn-add-sub';
addBtn.id = 'add-sub-btn';
addBtn.innerHTML = '<i class="ti ti-plus"></i> Add subcategory';
addBtn.onclick = () => openAddSubcategoryModal();
bar.appendChild(addBtn);
if (subcategorySortable) subcategorySortable.destroy();
subcategorySortable = Sortable.create(bar, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
filter: '#add-sub-btn',
onEnd: async (evt) => {
const ids = [...bar.querySelectorAll('button[data-id]')]
.map(el => parseInt(el.dataset.id));
state.subcategories = ids.map((id, i) => {
const sub = state.subcategories.find(s => s.id === id);
return { ...sub, position: i };
});
await reorder('subcategories', ids);
}
});
}
async function selectSubcategory(id) {
@ -111,6 +154,7 @@ function renderLinkGrid() {
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.dataset.id = link.id;
const favicon = document.createElement('div');
favicon.className = 'link-favicon';
@ -148,9 +192,28 @@ function renderLinkGrid() {
const addCard = document.createElement('button');
addCard.className = 'add-link-card';
addCard.id = 'add-link-card';
addCard.innerHTML = '<i class="ti ti-plus"></i> Add link';
addCard.onclick = () => openAddLinkModal();
grid.appendChild(addCard);
if (linkSortable) linkSortable.destroy();
linkSortable = Sortable.create(grid, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
filter: '#add-link-card',
draggable: 'a.link-card',
onEnd: async (evt) => {
const ids = [...grid.querySelectorAll('a.link-card[data-id]')]
.map(el => parseInt(el.dataset.id));
state.links = ids.map((id, i) => {
const link = state.links.find(l => l.id === id);
return { ...link, position: i };
});
await reorder('links', ids);
}
});
}
function openModal(title, bodyHTML, onConfirm) {

View file

@ -19,12 +19,10 @@
<i class="ti ti-plus"></i> Add category
</button>
</div>
<nav id="category-nav"></nav>
<div id="subcategory-bar"></div>
<main id="link-grid"></main>
</div>
<div id="modal-overlay" class="hidden">
<div id="modal">
<div id="modal-header">
@ -34,7 +32,7 @@
<div id="modal-body"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -407,3 +407,16 @@ body {
height: 20px;
flex-shrink: 0;
}
/* Sortable drag states */
.sortable-ghost {
opacity: 0.3;
}
.sortable-chosen {
cursor: grabbing;
}
.cat-tab, .sub-tab, .link-card {
cursor: grab;
}
.link-card.sortable-chosen {
cursor: grabbing;
}