diff --git a/backend/main.py b/backend/main.py index a11e7a0..6fa200b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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,10 +190,29 @@ 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() dest = os.path.join(FAVICON_DIR, filename) with open(dest, "wb") as f: shutil.copyfileobj(file.file, f) - return {"favicon": filename} \ No newline at end of file + return {"favicon": filename} diff --git a/frontend/app.js b/frontend/app.js index 86c3bff..f692565 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -15,6 +15,11 @@ const globeSVG = ` `; +// 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 = `${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 = ' 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 = ' 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) { @@ -202,7 +265,7 @@ function openEditCategoryModal(cat) {