From b052ccf82c4bd63955b4a5a605de2ff092f49600 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 11 May 2026 15:46:04 +0200 Subject: [PATCH] feat: add category/subcategory selectors to link add/edit modal for moving links --- backend/main.py | 4 +- frontend/app.js | 148 +++++++++++++++++++++++++++++++++++++-------- frontend/style.css | 24 ++++++++ 3 files changed, 149 insertions(+), 27 deletions(-) diff --git a/backend/main.py b/backend/main.py index 6c8cb77..0b5b4f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -183,8 +183,8 @@ async def create_link(link: LinkIn): async def update_link(link_id: int, link: LinkIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( - "UPDATE links SET label=?, url=?, favicon=?, position=? WHERE id=?", - (link.label, link.url, link.favicon, link.position, link_id) + "UPDATE links SET subcategory_id=?, label=?, url=?, favicon=?, position=? WHERE id=?", + (link.subcategory_id, link.label, link.url, link.favicon, link.position, link_id) ) await db.commit() return {"id": link_id, **link.dict()} diff --git a/frontend/app.js b/frontend/app.js index 855a332..3fad1e3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -15,7 +15,6 @@ const globeSVG = ` `; -// Sortable instances — kept so we can destroy/recreate on re-render let categorySortable = null; let subcategorySortable = null; let linkSortable = null; @@ -30,7 +29,6 @@ async function api(method, path, body) { 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); @@ -55,7 +53,6 @@ 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' : ''); @@ -65,8 +62,6 @@ function renderCategoryNav() { 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, @@ -101,7 +96,6 @@ async function selectCategory(id) { 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' : ''); @@ -111,14 +105,12 @@ function renderSubcategoryBar() { 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, @@ -147,7 +139,6 @@ async function selectSubcategory(id) { function renderLinkGrid() { const grid = document.getElementById('link-grid'); grid.innerHTML = ''; - state.links.forEach(link => { const a = document.createElement('a'); a.className = 'link-card'; @@ -155,7 +146,6 @@ function renderLinkGrid() { a.target = '_blank'; a.rel = 'noopener noreferrer'; a.dataset.id = link.id; - const favicon = document.createElement('div'); favicon.className = 'link-favicon'; if (link.favicon) { @@ -168,11 +158,9 @@ function renderLinkGrid() { } 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 = ` @@ -183,20 +171,17 @@ function renderLinkGrid() { `; - 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, @@ -320,7 +305,44 @@ function openEditSubcategoryModal(sub) { }); } +// Build the category+subcategory select HTML for link modals. +// selectedCatId / selectedSubId pre-select the dropdowns (used in edit mode). +function buildLocationFields(selectedCatId, selectedSubId) { + const catOptions = state.categories.map(c => + `` + ).join(''); + return ` +
+ + +
+
+ + +
+ `; +} + +// Populate the subcategory dropdown for the given category id. +// If targetSubId is provided, pre-select it. +async function populateSubSelect(catId, targetSubId) { + const subEl = document.getElementById('f-sub'); + if (!subEl) return; + subEl.innerHTML = ''; + const subs = await get('/subcategories/' + catId); + if (subs.length === 0) { + subEl.innerHTML = ''; + return; + } + subEl.innerHTML = subs.map(s => + `` + ).join(''); +} + function openAddLinkModal() { + const defaultCatId = state.activeCategoryId || (state.categories[0] && state.categories[0].id); + const defaultSubId = state.activeSubcategoryId; + openModal('Add link', `
@@ -330,6 +352,7 @@ function openAddLinkModal() {
+ ${buildLocationFields(defaultCatId, defaultSubId)}
@@ -346,23 +369,66 @@ function openAddLinkModal() { 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; + const subId = parseInt(document.getElementById('f-sub').value); + if (!label || !url || !subId) return; + // Find position: end of the target subcategory's links + const targetLinks = await get('/links/' + subId); await post('/links', { - subcategory_id: state.activeSubcategoryId, + subcategory_id: subId, label, url, favicon: favicon || null, - position: state.links.length + position: targetLinks.length }); closeModal(); - await selectSubcategory(state.activeSubcategoryId); + // If we added to the currently-viewed subcategory, refresh it; otherwise navigate there + if (subId === state.activeSubcategoryId) { + await selectSubcategory(state.activeSubcategoryId); + } else { + // Find the category for this subcategory and navigate + const allCats = state.categories; + for (const cat of allCats) { + const subs = await get('/subcategories/' + cat.id); + if (subs.find(s => s.id === subId)) { + await selectCategory(cat.id); + await selectSubcategory(subId); + break; + } + } + } }); + + // Populate subcategory dropdown for the initial category + populateSubSelect(defaultCatId, defaultSubId); + + // When category changes, reload subcategory dropdown + setTimeout(() => { + const catEl = document.getElementById('f-cat'); + if (catEl) { + catEl.addEventListener('change', () => { + populateSubSelect(parseInt(catEl.value), null); + }); + } + }, 0); + setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file'); } -function openEditLinkModal(link, e) { +async function openEditLinkModal(link, e) { e.preventDefault(); e.stopPropagation(); + + // Determine which category owns this link's subcategory so we can pre-select it + let ownerCatId = state.activeCategoryId; + // Search all categories for the one that contains link.subcategory_id + for (const cat of state.categories) { + const subs = await get('/subcategories/' + cat.id); + if (subs.find(s => s.id === link.subcategory_id)) { + ownerCatId = cat.id; + break; + } + } + openModal('Edit link', `
@@ -372,6 +438,7 @@ function openEditLinkModal(link, e) {
+ ${buildLocationFields(ownerCatId, link.subcategory_id)}
@@ -392,17 +459,49 @@ function openEditLinkModal(link, e) { 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; + const newSubId = parseInt(document.getElementById('f-sub').value); + if (!label || !url || !newSubId) return; + + const isMoving = newSubId !== link.subcategory_id; + let position = link.position; + if (isMoving) { + // Place at end of the destination subcategory + const targetLinks = await get('/links/' + newSubId); + position = targetLinks.length; + } + await put('/links/' + link.id, { - subcategory_id: state.activeSubcategoryId, + subcategory_id: newSubId, label, url, favicon: favicon || null, - position: link.position + position }); closeModal(); - await selectSubcategory(state.activeSubcategoryId); + + if (!isMoving) { + // Same subcategory — just refresh current view + await selectSubcategory(state.activeSubcategoryId); + } else { + // Link moved away — refresh current subcategory (link is gone from here) + // Then optionally navigate to the destination + await selectSubcategory(state.activeSubcategoryId); + } }); + + // Populate subcategory dropdown for the owner category + populateSubSelect(ownerCatId, link.subcategory_id); + + // When category changes, reload subcategory dropdown + setTimeout(() => { + const catEl = document.getElementById('f-cat'); + if (catEl) { + catEl.addEventListener('change', () => { + populateSubSelect(parseInt(catEl.value), null); + }); + } + }, 0); + setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file', link.favicon); } @@ -502,7 +601,6 @@ function setupFaviconFetch(urlId, faviconId, previewId, statusId, uploadBtnId, f } } - // Show existing favicon immediately if editing if (existingFavicon) { showPreview(existingFavicon); statusEl.textContent = existingFavicon; diff --git a/frontend/style.css b/frontend/style.css index 651a2cf..4a82c87 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -488,3 +488,27 @@ body { padding: 4px 8px; font-size: 12px; } + +.field select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + padding: 8px 10px; + outline: none; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; + cursor: pointer; +} +.field select:focus { + border-color: var(--accent); +} +.field select option { + background: #1a1a1a; + color: var(--text); +}