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 import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
import aiosqlite import aiosqlite
import shutil import shutil
import os import os
@ -17,7 +17,6 @@ app.add_middleware(
DB_PATH = "/data/startpage.db" DB_PATH = "/data/startpage.db"
FAVICON_DIR = "/data/favicons" FAVICON_DIR = "/data/favicons"
os.makedirs(FAVICON_DIR, exist_ok=True) os.makedirs(FAVICON_DIR, exist_ok=True)
class CategoryIn(BaseModel): class CategoryIn(BaseModel):
@ -37,6 +36,10 @@ class LinkIn(BaseModel):
favicon: Optional[str] = None favicon: Optional[str] = None
position: Optional[int] = 0 position: Optional[int] = 0
class ReorderIn(BaseModel):
type: str # "categories", "subcategories", "links"
ids: List[int]
async def init_db(): async def init_db():
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute(""" await db.execute("""
@ -187,6 +190,25 @@ async def delete_link(link_id: int):
await db.commit() await db.commit()
return {"deleted": link_id} 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") @app.post("/favicons/upload")
async def upload_favicon(file: UploadFile = File(...)): async def upload_favicon(file: UploadFile = File(...)):
filename = file.filename.replace(" ", "_").lower() 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"/> <line x1="10" y1="1" x2="10" y2="19" stroke="#e8722a" stroke-width="1"/>
</svg>`; </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) { async function api(method, path, body) {
const opts = { const opts = {
method, method,
@ -31,12 +36,18 @@ const post = (path, body) => api('POST', path, body);
const put = (path, body) => api('PUT', path, body); const put = (path, body) => api('PUT', path, body);
const del = (path) => api('DELETE', path); const del = (path) => api('DELETE', path);
async function reorder(type, ids) {
await put('/reorder', { type, ids });
}
async function loadCategories() { async function loadCategories() {
state.categories = await get('/categories'); state.categories = await get('/categories');
renderCategoryNav(); renderCategoryNav();
if (state.categories.length > 0) { if (state.categories.length > 0) {
await selectCategory( 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() { function renderCategoryNav() {
const nav = document.getElementById('category-nav'); const nav = document.getElementById('category-nav');
nav.innerHTML = ''; nav.innerHTML = '';
state.categories.forEach(cat => { state.categories.forEach(cat => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'cat-tab' + (cat.id === state.activeCategoryId ? ' active' : ''); 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.innerHTML = `<i class="ti ${cat.icon || 'ti-folder'}"></i>${cat.name}`;
btn.onclick = () => selectCategory(cat.id); btn.onclick = () => selectCategory(cat.id);
btn.oncontextmenu = (e) => { btn.oncontextmenu = (e) => { e.preventDefault(); openEditCategoryModal(cat); };
e.preventDefault();
openEditCategoryModal(cat);
};
nav.appendChild(btn); 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) { async function selectCategory(id) {
@ -78,20 +105,36 @@ function renderSubcategoryBar() {
state.subcategories.forEach(sub => { state.subcategories.forEach(sub => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'sub-tab' + (sub.id === state.activeSubcategoryId ? ' active' : ''); btn.className = 'sub-tab' + (sub.id === state.activeSubcategoryId ? ' active' : '');
btn.dataset.id = sub.id;
btn.textContent = sub.name; btn.textContent = sub.name;
btn.onclick = () => selectSubcategory(sub.id); btn.onclick = () => selectSubcategory(sub.id);
btn.oncontextmenu = (e) => { btn.oncontextmenu = (e) => { e.preventDefault(); openEditSubcategoryModal(sub); };
e.preventDefault();
openEditSubcategoryModal(sub);
};
bar.appendChild(btn); bar.appendChild(btn);
}); });
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');
addBtn.className = 'btn-add-sub'; addBtn.className = 'btn-add-sub';
addBtn.id = 'add-sub-btn';
addBtn.innerHTML = '<i class="ti ti-plus"></i> Add subcategory'; addBtn.innerHTML = '<i class="ti ti-plus"></i> Add subcategory';
addBtn.onclick = () => openAddSubcategoryModal(); addBtn.onclick = () => openAddSubcategoryModal();
bar.appendChild(addBtn); 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) { async function selectSubcategory(id) {
@ -111,6 +154,7 @@ function renderLinkGrid() {
a.href = link.url; a.href = link.url;
a.target = '_blank'; a.target = '_blank';
a.rel = 'noopener noreferrer'; a.rel = 'noopener noreferrer';
a.dataset.id = link.id;
const favicon = document.createElement('div'); const favicon = document.createElement('div');
favicon.className = 'link-favicon'; favicon.className = 'link-favicon';
@ -148,9 +192,28 @@ function renderLinkGrid() {
const addCard = document.createElement('button'); const addCard = document.createElement('button');
addCard.className = 'add-link-card'; addCard.className = 'add-link-card';
addCard.id = 'add-link-card';
addCard.innerHTML = '<i class="ti ti-plus"></i> Add link'; addCard.innerHTML = '<i class="ti ti-plus"></i> Add link';
addCard.onclick = () => openAddLinkModal(); addCard.onclick = () => openAddLinkModal();
grid.appendChild(addCard); 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) { function openModal(title, bodyHTML, onConfirm) {

View file

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

View file

@ -407,3 +407,16 @@ body {
height: 20px; height: 20px;
flex-shrink: 0; 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;
}