webui: edit-category modal + dropdown dedupe

Add an "Edit category…" context-menu item and modal that renames a
category and/or changes its save path (Uncategorized: path only).
Filter empty-named categories out of the add/set-category dropdowns so
the stored Uncategorized save-path entry doesn't double the option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 00:29:16 -04:00
parent c3985fe742
commit d55fc35c72
2 changed files with 34 additions and 2 deletions

View file

@ -36,6 +36,8 @@ export const api = {
createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }), createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }),
deleteCategory: (name) => jpost('/api/categories/delete', { name }), deleteCategory: (name) => jpost('/api/categories/delete', { name }),
editCategory: (name, newName, savePath) =>
jpost('/api/categories/edit', { name, newName, savePath }),
createTag: (name) => jpost('/api/tags', { name }), createTag: (name) => jpost('/api/tags', { name }),
deleteTag: (name) => jpost('/api/tags/delete', { name }), deleteTag: (name) => jpost('/api/tags/delete', { name }),

View file

@ -264,6 +264,9 @@ function onSidebarContext(e, type, value, removable) {
const items = [ const items = [
{ label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) }, { label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) },
]; ];
if (type === 'category') {
items.push({ label: 'Edit category…', act: () => openEditCategory(value) });
}
if (removable) { if (removable) {
items.push({ sep: true }); items.push({ sep: true });
items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) }); items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) });
@ -495,7 +498,7 @@ async function confirmDelete(hashes) {
/* ---------- add torrent modal ---------- */ /* ---------- add torrent modal ---------- */
function openAddModal() { function openAddModal() {
const cats = '<option value="" selected>Uncategorized</option>' + const cats = '<option value="" selected>Uncategorized</option>' +
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join(''); state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name)}</option>`).join('');
const tagOpts = state.meta.tags.map((t) => const tagOpts = state.meta.tags.map((t) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(t)}"><span>${f.esc(t)}</span></label>`).join(''); `<label class="cbdrop-item"><input type="checkbox" value="${f.esc(t)}"><span>${f.esc(t)}</span></label>`).join('');
openModal('Add torrent', ` openModal('Add torrent', `
@ -765,7 +768,7 @@ function promptCategory() {
const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || ''; const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || '';
const sel = (v) => (v === cur ? ' selected' : ''); const sel = (v) => (v === cur ? ' selected' : '');
const opts = `<option value=""${sel('')}>Uncategorized</option>` + const opts = `<option value=""${sel('')}>Uncategorized</option>` +
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name || 'Uncategorized')}</option>`).join(''); state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name)}</option>`).join('');
openModal('Set category', ` openModal('Set category', `
<div class="field"><label>Category</label><select id="catSel">${opts}</select></div> <div class="field"><label>Category</label><select id="catSel">${opts}</select></div>
<p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to Categories in the sidebar.</p>`, <p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to Categories in the sidebar.</p>`,
@ -795,6 +798,33 @@ function openCreateCategory() {
setTimeout(() => document.getElementById('catName')?.focus(), 0); setTimeout(() => document.getElementById('catName')?.focus(), 0);
} }
function openEditCategory(value) {
const isUncat = value === '';
const cur = state.meta.categories.find((c) => c.name === value);
const curPath = cur ? (cur.savePath || '') : '';
const nameField = isUncat
? `<div class="field"><label>Name</label><input type="text" value="Uncategorized" disabled /></div>`
: `<div class="field"><label>Name</label><input type="text" id="catName" value="${f.esc(value)}" /></div>`;
openModal('Edit category', `
${nameField}
<div class="field"><label>Save path</label>
<input type="text" id="catPath" value="${f.esc(curPath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: async () => {
const newName = isUncat ? '' : document.getElementById('catName').value.trim();
if (!isUncat && !newName) return closeModal();
const savePath = document.getElementById('catPath').value.trim();
await api.editCategory(value, newName, savePath);
await refreshMeta();
if (!isUncat && newName !== value && state.filter.type === 'category' && state.filter.value === value)
state.filter = { type: 'category', value: newName };
renderView();
closeModal();
},
}]);
setTimeout(() => document.getElementById('catName')?.focus(), 0);
}
function openCreateTag() { function openCreateTag() {
openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`, openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
[{ label: 'Cancel', act: closeModal }, { [{ label: 'Cancel', act: closeModal }, {