Add drag-and-drop sorting for categories, subcategories, and links via SortableJS
This commit is contained in:
parent
f9deb0f850
commit
2f0aca09e2
4 changed files with 115 additions and 19 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue