Exclude course/item leaves from destination picker; ease new-folder UX

The destination picker (Sort Unsorted, Manage Library Move) was
listing individual course/item folders as pickable destinations
whenever they held content course detection doesn't recognize as
video/audio - ebooks, audiobooks in unsupported formats, etc. Add a
leaf-item check scoped to the picker's own category index so these no
longer show up, without touching the shared course-detection heuristic
used elsewhere (Library browsing, search, stats).

Also replace "Create new folder"'s single free-text path field with a
parent-folder picker plus a plain new-folder-name field and a live
"Will create: X/Y" preview, so the resulting path is confirmed before
applying instead of hand-typed blind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 13:13:09 -04:00
co-authored by Claude Sonnet 5
parent bcd1734c94
commit 62bb5e1318
3 changed files with 141 additions and 42 deletions
+98 -33
View File
@@ -415,12 +415,29 @@
gap: 8px;
}
.dest-picker select { flex: 1; }
.dest-new-input {
.new-folder-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.dest-new-input.empty {
.new-folder-fields .new-folder-parent,
.new-folder-fields .new-folder-name {
flex: 1;
min-width: 140px;
}
.new-folder-name.empty {
border-color: var(--error);
}
.new-folder-preview {
font-size: 0.8em;
color: var(--text-muted);
margin-top: 5px;
min-height: 1.1em;
}
.new-folder-preview.ready {
color: var(--accent);
}
.action-bar {
position: sticky;
bottom: 0;
@@ -641,6 +658,65 @@
.catch(() => {});
}
function newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew) {
return `
<div class="new-folder-fields" style="display: ${isNew ? 'flex' : 'none'};">
<select class="text-input new-folder-parent" data-initial="${escapeAttr(prefilledParent)}">
<option value="">(Library root — top level)</option>
${optionsHtml}
</select>
<input type="text" class="text-input new-folder-name ${isNew && !prefilledName ? 'empty' : ''}"
value="${escapeAttr(prefilledName)}" placeholder="New folder name">
</div>
<div class="new-folder-preview"></div>`;
}
// Wires the shared "+ Create new folder" behavior for one
// dest-select + its new-folder-fields sibling: toggling visibility,
// restoring the parent select's prefilled value (can't be an HTML
// `selected` attribute since the <option> list is shared/built once
// per render), and keeping the "Will create: ..." preview live.
function wireNewFolderFields(destSelect, fieldsWrap) {
const fieldsEl = fieldsWrap.querySelector('.new-folder-fields');
const preview = fieldsWrap.querySelector('.new-folder-preview');
const parentSelect = fieldsEl.querySelector('.new-folder-parent');
const nameInput = fieldsEl.querySelector('.new-folder-name');
if (parentSelect.dataset.initial) parentSelect.value = parentSelect.dataset.initial;
function updatePreview() {
const parent = parentSelect.value;
const name = nameInput.value.trim();
if (!name) {
preview.textContent = '';
preview.classList.remove('ready');
return;
}
preview.textContent = `Will create: ${parent ? parent + '/' + name : name}`;
preview.classList.add('ready');
}
destSelect.addEventListener('change', () => {
const showNew = destSelect.value === NEW_FOLDER_VALUE;
fieldsEl.style.display = showNew ? 'flex' : 'none';
preview.style.display = showNew ? 'block' : 'none';
if (showNew) nameInput.focus();
updatePreview();
});
parentSelect.addEventListener('change', updatePreview);
nameInput.addEventListener('input', () => {
nameInput.classList.toggle('empty', nameInput.value.trim() === '');
updatePreview();
});
preview.style.display = destSelect.value === NEW_FOLDER_VALUE ? 'block' : 'none';
updatePreview();
}
function newFolderDestination(fieldsWrap) {
const parent = fieldsWrap.querySelector('.new-folder-parent').value;
const name = fieldsWrap.querySelector('.new-folder-name').value.trim();
return name ? (parent ? `${parent}/${name}` : name) : '';
}
function renderRows(items) {
const container = document.getElementById('sort-rows');
const optionsHtml = categoryOptionsHtml();
@@ -652,8 +728,15 @@
// lastCategories, so it's the only case the <select> can point
// straight at; 'new_folder' and 'manual' (and any hand-edited
// destination that no longer matches a real folder) fall back
// to the "create new" option with the free-text path exposed.
// to the "create new" option, split into a parent-folder pick
// and a plain new-folder name.
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
let prefilledParent = '', prefilledName = '';
if (isNew && destValue) {
const slash = destValue.lastIndexOf('/');
if (slash === -1) { prefilledName = destValue; }
else { prefilledParent = destValue.slice(0, slash); prefilledName = destValue.slice(slash + 1); }
}
return `
<div class="sort-row" data-idx="${idx}">
<input type="checkbox" class="sort-row-check" ${checked}>
@@ -677,9 +760,7 @@
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="dest-new-input ${isNew && !destValue ? 'empty' : ''}"
style="display: ${isNew ? 'block' : 'none'};"
value="${escapeAttr(isNew ? destValue : '')}" placeholder="e.g. IT/NewCategory">
${newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew)}
</div>
</div>
</div>
@@ -688,18 +769,8 @@
container.querySelectorAll('.dest-select').forEach(select => {
select.value = select.dataset.initial;
const row = select.closest('.sort-row');
const newInput = row.querySelector('.dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
});
container.querySelectorAll('.dest-new-input').forEach(input => {
input.addEventListener('input', () => {
input.classList.toggle('empty', input.value.trim() === '');
});
const field = select.closest('.sort-field');
wireNewFolderFields(select, field);
});
}
@@ -757,14 +828,14 @@
const idx = parseInt(row.dataset.idx, 10);
const item = lastItems[idx];
const select = row.querySelector('.dest-select');
const newInput = row.querySelector('.dest-new-input');
const fieldsWrap = select.closest('.sort-field');
const renameInput = row.querySelector('.rename-input');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
const name = renameInput.value.trim();
if (!destination) {
skippedEmpty.push(item.name);
if (usingNew) newInput.classList.add('empty');
if (usingNew) fieldsWrap.querySelector('.new-folder-name').classList.add('empty');
return;
}
moves.push({ source: item.path, destination, name });
@@ -1176,7 +1247,7 @@
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="move-dest-new-input text-input" style="display: none; margin-top: 6px;" placeholder="e.g. IT/NewCategory">
${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
</div>
<div class="curate-move-actions">
<button class="btn btn-sm">Move</button>
@@ -1187,16 +1258,10 @@
row.after(panel);
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
wireNewFolderFields(select, panel.querySelector('.sort-field'));
if (!optionsHtml) {
select.value = NEW_FOLDER_VALUE;
newInput.style.display = 'block';
newInput.focus();
panel.querySelector('.new-folder-name').focus();
}
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
@@ -1206,14 +1271,14 @@
function submitMove(panel, btnEl, path) {
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
const fieldsWrap = panel.querySelector('.sort-field');
const status = panel.querySelector('.curate-move-status');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
if (!destination) {
status.style.color = 'var(--error)';
status.textContent = 'Choose or type a destination first.';
if (usingNew) newInput.focus();
if (usingNew) panel.querySelector('.new-folder-name').focus();
return;
}
status.style.color = 'var(--text-muted)';