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 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()
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue