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);
+}