const API = '/api'; let state = { categories: [], activeCategoryId: null, subcategories: [], activeSubcategoryId: null, links: [] }; 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, headers: body ? { 'Content-Type': 'application/json' } : {} }; if (body) opts.body = JSON.stringify(body); const res = await fetch(API + path, opts); if (!res.ok) throw new Error(await res.text()); return res.json(); } const get = (path) => api('GET', path); 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.find(c => c.id === state.activeCategoryId) ? state.activeCategoryId : state.categories[0].id ); } } 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); }; 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) { state.activeCategoryId = id; state.subcategories = await get('/subcategories/' + id); state.activeSubcategoryId = null; renderCategoryNav(); renderSubcategoryBar(); if (state.subcategories.length > 0) { await selectSubcategory(state.subcategories[0].id); } else { state.links = []; renderLinkGrid(); } } function renderSubcategoryBar() { const bar = document.getElementById('subcategory-bar'); bar.innerHTML = ''; 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); }; 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) { state.activeSubcategoryId = id; state.links = await get('/links/' + id); renderSubcategoryBar(); renderLinkGrid(); } function renderLinkGrid() { const grid = document.getElementById('link-grid'); grid.innerHTML = ''; state.links.forEach(link => { const a = document.createElement('a'); a.className = 'link-card'; 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'; if (link.favicon) { const img = document.createElement('img'); img.src = '/favicons/' + link.favicon; img.width = 20; img.height = 20; img.onerror = () => { favicon.innerHTML = globeSVG; }; favicon.appendChild(img); } else { favicon.innerHTML = globeSVG; } const label = document.createElement('span'); label.className = 'link-label'; label.textContent = link.label; const actions = document.createElement('div'); actions.className = 'link-actions'; actions.innerHTML = ` `; a.appendChild(favicon); a.appendChild(label); a.appendChild(actions); grid.appendChild(a); }); 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) { document.getElementById('modal-title').textContent = title; document.getElementById('modal-body').innerHTML = bodyHTML + `