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 + ` `; document.getElementById('modal-overlay').classList.remove('hidden'); document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('modal-close').onclick = closeModal; document.getElementById('modal-confirm').onclick = onConfirm; } function closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); } function openAddCategoryModal() { openModal('Add category', `
`, async () => { const name = document.getElementById('f-name').value.trim(); const icon = document.getElementById('f-icon').value.trim(); if (!name) return; await post('/categories', { name, icon, position: state.categories.length }); closeModal(); await loadCategories(); }); } function openEditCategoryModal(cat) { openModal('Edit category', `
`, async () => { const name = document.getElementById('f-name').value.trim(); const icon = document.getElementById('f-icon').value.trim(); if (!name) return; await put('/categories/' + cat.id, { name, icon, position: cat.position }); closeModal(); await loadCategories(); }); } function openAddSubcategoryModal() { openModal('Add subcategory', `
`, async () => { const name = document.getElementById('f-name').value.trim(); if (!name) return; await post('/subcategories', { category_id: state.activeCategoryId, name, position: state.subcategories.length }); closeModal(); await selectCategory(state.activeCategoryId); }); } function openEditSubcategoryModal(sub) { openModal('Edit subcategory', `
`, async () => { const name = document.getElementById('f-name').value.trim(); if (!name) return; await put('/subcategories/' + sub.id, { category_id: state.activeCategoryId, name, position: sub.position }); closeModal(); await selectCategory(state.activeCategoryId); }); } function openAddLinkModal() { openModal('Add link', `
`, async () => { const label = document.getElementById('f-label').value.trim(); const url = document.getElementById('f-url').value.trim(); const favicon = document.getElementById('f-favicon').value.trim(); if (!label || !url) return; await post('/links', { subcategory_id: state.activeSubcategoryId, label, url, favicon: favicon || null, position: state.links.length }); closeModal(); await selectSubcategory(state.activeSubcategoryId); }); } function openEditLinkModal(link, e) { e.preventDefault(); e.stopPropagation(); openModal('Edit link', `
`, async () => { const label = document.getElementById('f-label').value.trim(); const url = document.getElementById('f-url').value.trim(); const favicon = document.getElementById('f-favicon').value.trim(); if (!label || !url) return; await put('/links/' + link.id, { subcategory_id: state.activeSubcategoryId, label, url, favicon: favicon || null, position: link.position }); closeModal(); await selectSubcategory(state.activeSubcategoryId); }); } async function deleteCategory(id) { closeModal(); await del('/categories/' + id); state.activeCategoryId = null; await loadCategories(); } async function deleteSubcategory(id) { closeModal(); await del('/subcategories/' + id); await selectCategory(state.activeCategoryId); } async function deleteLink(id, e) { e.preventDefault(); e.stopPropagation(); await del('/links/' + id); await selectSubcategory(state.activeSubcategoryId); } document.getElementById('search-form').addEventListener('submit', (e) => { e.preventDefault(); const q = document.getElementById('search-input').value.trim(); if (q) window.open('https://kagi.com/search?q=' + encodeURIComponent(q), '_blank'); }); document.getElementById('add-category-btn').onclick = openAddCategoryModal; document.getElementById('modal-overlay').addEventListener('click', (e) => { if (e.target === document.getElementById('modal-overlay')) closeModal(); }); loadCategories(); // Export function exportBookmarks(format) { document.getElementById('export-dropdown').classList.remove('open'); const a = document.createElement('a'); a.href = API + '/export/' + format; a.download = format === 'json' ? 'startpage-bookmarks.json' : 'startpage-bookmarks.html'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // Export dropdown toggle document.getElementById('export-btn').addEventListener('click', (e) => { e.stopPropagation(); document.getElementById('export-dropdown').classList.toggle('open'); }); document.addEventListener('click', () => { document.getElementById('export-dropdown').classList.remove('open'); }); // Import function triggerImport() { document.getElementById('export-dropdown').classList.remove('open'); document.getElementById('import-file-input').click(); } document.getElementById('import-file-input').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); try { const res = await fetch(API + '/import/html', { method: 'POST', body: formData }); if (!res.ok) throw new Error(await res.text()); const result = await res.json(); const i = result.imported; alert('Imported: ' + i.categories + ' categories, ' + i.subcategories + ' subcategories, ' + i.links + ' links.'); await loadCategories(); } catch (err) { alert('Import failed: ' + err.message); } e.target.value = ''; });