Add standalone desktop workflow
This commit is contained in:
@@ -225,6 +225,17 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.path-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.path-input-row .form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
@@ -268,10 +279,25 @@ a:hover { text-decoration: underline; }
|
||||
/* Checkbox list */
|
||||
.source-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.source-tree {
|
||||
padding: 8px 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, rgba(33, 133, 208, 0.03), rgba(34, 36, 38, 0.015));
|
||||
}
|
||||
.source-tree-empty {
|
||||
padding: 12px 16px;
|
||||
padding: 20px 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.source-root-card {
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(33, 133, 208, 0.18);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 24px rgba(27, 28, 29, 0.04);
|
||||
}
|
||||
.source-root-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.source-node {
|
||||
border-bottom: 1px solid var(--border-lite);
|
||||
@@ -284,10 +310,22 @@ a:hover { text-decoration: underline; }
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.source-row:hover {
|
||||
background: rgba(33, 133, 208, 0.04);
|
||||
}
|
||||
.source-root-row {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(33, 133, 208, 0.12);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(33, 133, 208, 0.08), rgba(33, 133, 208, 0.015) 42%, rgba(255, 255, 255, 0.96) 100%);
|
||||
}
|
||||
.source-root-row:hover {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(33, 133, 208, 0.12), rgba(33, 133, 208, 0.03) 42%, rgba(255, 255, 255, 1) 100%);
|
||||
}
|
||||
.source-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -318,12 +356,40 @@ a:hover { text-decoration: underline; }
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.source-root-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.source-root-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 133, 208, 0.1);
|
||||
color: var(--accent-dark);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.source-item-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.source-children {
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
padding: 6px 0 8px 0;
|
||||
}
|
||||
.source-children::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
top: 0;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, rgba(33, 133, 208, 0.2), rgba(33, 133, 208, 0.03));
|
||||
}
|
||||
.source-loading {
|
||||
padding: 6px 16px 10px 48px;
|
||||
@@ -418,4 +484,11 @@ a:hover { text-decoration: underline; }
|
||||
.disk-grid { grid-template-columns: 1fr; }
|
||||
.kv-table th { width: 130px; }
|
||||
.btn-row { flex-wrap: wrap; }
|
||||
.path-input-row { flex-wrap: wrap; }
|
||||
.source-root-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.source-root-title {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
{{define "content"}}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Disks</h2>
|
||||
<h2>Mounted Disk</h2>
|
||||
<div class="panel-body">
|
||||
<div id="diskSummary" class="text-muted">Loading disks...</div>
|
||||
<div class="path-input-row">
|
||||
<input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
|
||||
<button type="button" class="button-primary" onclick="pickMountPath()">+</button>
|
||||
<button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
|
||||
</div>
|
||||
<div class="form-hint">Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="disk-grid" id="diskGrid"></div>
|
||||
<div id="diskState"></div>
|
||||
|
||||
<script>
|
||||
let disks = [];
|
||||
const selectedDisk = { info: null };
|
||||
const taskState = new Map();
|
||||
const taskPollers = new Map();
|
||||
|
||||
@@ -24,16 +29,12 @@ function escapeHTML(value) {
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function diskKey(disk) {
|
||||
return disk.disk_id || disk.mount_path;
|
||||
}
|
||||
|
||||
function badgeClass(state) {
|
||||
return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
|
||||
}
|
||||
|
||||
function badgeLabel(state) {
|
||||
return ({ absent: 'Not connected', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
|
||||
return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
|
||||
}
|
||||
|
||||
function fmtSpeed(bps) {
|
||||
@@ -69,77 +70,74 @@ function taskMeta(task) {
|
||||
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function renderDisks() {
|
||||
const grid = document.getElementById('diskGrid');
|
||||
const summary = document.getElementById('diskSummary');
|
||||
|
||||
if (!disks.length) {
|
||||
summary.textContent = 'No disks found.';
|
||||
grid.innerHTML = '';
|
||||
function renderDisk() {
|
||||
const root = document.getElementById('diskState');
|
||||
const disk = selectedDisk.info;
|
||||
if (!disk) {
|
||||
root.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="panel-body text-muted">Choose a mounted disk directory to inspect it.</div>
|
||||
</section>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const knownCount = disks.filter((disk) => disk.state === 'known').length;
|
||||
summary.textContent = `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`;
|
||||
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
|
||||
const progress = activeTask ? activeTask.progress : 0;
|
||||
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
|
||||
const meta = activeTask ? taskMeta(activeTask) : '';
|
||||
const isKnown = disk.state === 'known';
|
||||
const isForeign = disk.state === 'foreign';
|
||||
const hasCapacity = disk.state !== 'absent';
|
||||
|
||||
grid.innerHTML = disks.map((disk) => {
|
||||
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
|
||||
const progress = activeTask ? activeTask.progress : 0;
|
||||
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
|
||||
const meta = activeTask ? taskMeta(activeTask) : '';
|
||||
const isKnown = disk.state === 'known';
|
||||
const isForeign = disk.state === 'foreign';
|
||||
const hasCapacity = disk.state !== 'absent';
|
||||
|
||||
return `
|
||||
<section class="panel disk-card">
|
||||
<h2>${escapeHTML(disk.mount_path)}</h2>
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Disk ID</th>
|
||||
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total capacity</th>
|
||||
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Free space</th>
|
||||
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last copied</th>
|
||||
<td>${fmtDateTime(disk.last_copied_at)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${activeTask ? `
|
||||
<div class="panel-body progress-wrap">
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width:${progress}%"></div>
|
||||
</div>
|
||||
<div class="progress-label">${escapeHTML(message)}</div>
|
||||
<div class="progress-label">${escapeHTML(meta)}</div>
|
||||
root.innerHTML = `
|
||||
<section class="panel disk-card">
|
||||
<h2>${escapeHTML(disk.mount_path)}</h2>
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Disk ID</th>
|
||||
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total capacity</th>
|
||||
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Free space</th>
|
||||
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last copied</th>
|
||||
<td>${fmtDateTime(disk.last_copied_at)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${activeTask ? `
|
||||
<div class="panel-body progress-wrap">
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width:${progress}%"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="btn-row">
|
||||
${isKnown ? `
|
||||
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Replace media</button>
|
||||
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Add media</button>
|
||||
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy" data-disk-id="${escapeHTML(disk.disk_id)}">Cancel</button>
|
||||
` : ''}
|
||||
${isForeign ? `
|
||||
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
|
||||
` : ''}
|
||||
<div class="progress-label">${escapeHTML(message)}</div>
|
||||
<div class="progress-label">${escapeHTML(meta)}</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
` : ''}
|
||||
<div class="btn-row">
|
||||
${isKnown ? `
|
||||
<button class="button-danger" data-action="start-copy" data-mode="replace" ${activeTask ? 'disabled' : ''}>Replace media</button>
|
||||
<button class="button-primary" data-action="start-copy" data-mode="add" ${activeTask ? 'disabled' : ''}>Add media</button>
|
||||
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy">Cancel</button>
|
||||
` : ''}
|
||||
${isForeign ? `
|
||||
<button class="button-secondary" data-action="init-disk">Initialize disk</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function stopTaskPoll(taskID) {
|
||||
@@ -154,28 +152,63 @@ function startTaskPoll(taskID) {
|
||||
pollTask(taskID);
|
||||
}
|
||||
|
||||
async function refreshDisks() {
|
||||
try {
|
||||
const response = await fetch('/api/disks');
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
disks = payload.items || [];
|
||||
renderDisks();
|
||||
async function pickFolder() {
|
||||
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Failed to choose folder');
|
||||
}
|
||||
return payload.path || '';
|
||||
}
|
||||
|
||||
const activeTasks = new Set();
|
||||
for (const disk of disks) {
|
||||
if (disk.active_task_id) {
|
||||
activeTasks.add(disk.active_task_id);
|
||||
startTaskPoll(disk.active_task_id);
|
||||
}
|
||||
async function pickMountPath() {
|
||||
try {
|
||||
const path = await pickFolder();
|
||||
if (!path) return;
|
||||
document.getElementById('mountPath').value = path;
|
||||
localStorage.setItem('jukebox.selectedMountPath', path);
|
||||
await refreshSelectedDisk();
|
||||
} catch (error) {
|
||||
toast(error.message || 'Failed to choose folder', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedDisk() {
|
||||
const mountPath = document.getElementById('mountPath').value.trim();
|
||||
if (!mountPath) {
|
||||
selectedDisk.info = null;
|
||||
renderDisk();
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('jukebox.selectedMountPath', mountPath);
|
||||
try {
|
||||
const response = await fetch('/api/disks/probe?mount_path=' + encodeURIComponent(mountPath));
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
toast(payload.error || 'Failed to inspect directory', 'error');
|
||||
return;
|
||||
}
|
||||
for (const taskID of Array.from(taskPollers.keys())) {
|
||||
if (!activeTasks.has(taskID)) {
|
||||
selectedDisk.info = payload;
|
||||
renderDisk();
|
||||
|
||||
if (payload.active_task_id) {
|
||||
for (const taskID of Array.from(taskPollers.keys())) {
|
||||
if (taskID !== payload.active_task_id) {
|
||||
stopTaskPoll(taskID);
|
||||
taskState.delete(taskID);
|
||||
}
|
||||
}
|
||||
startTaskPoll(payload.active_task_id);
|
||||
} else {
|
||||
for (const taskID of Array.from(taskPollers.keys())) {
|
||||
stopTaskPoll(taskID);
|
||||
taskState.delete(taskID);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
toast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTask(taskID) {
|
||||
@@ -184,7 +217,7 @@ async function pollTask(taskID) {
|
||||
if (!response.ok) return;
|
||||
const task = await response.json();
|
||||
taskState.set(taskID, task);
|
||||
renderDisks();
|
||||
renderDisk();
|
||||
|
||||
if (['success', 'failed', 'canceled'].includes(task.status)) {
|
||||
stopTaskPoll(taskID);
|
||||
@@ -192,17 +225,19 @@ async function pollTask(taskID) {
|
||||
if (task.status === 'success') toast(task.message || 'Done', 'ok');
|
||||
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
|
||||
if (task.status === 'canceled') toast('Copy canceled', 'error');
|
||||
refreshDisks();
|
||||
refreshSelectedDisk();
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
async function startCopy(diskID, mode) {
|
||||
async function startCopy(mode) {
|
||||
const mountPath = document.getElementById('mountPath').value.trim();
|
||||
if (!mountPath) return;
|
||||
try {
|
||||
const response = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
|
||||
const response = await fetch('/api/disks/copy/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode })
|
||||
body: JSON.stringify({ mount_path: mountPath, mode })
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
@@ -210,22 +245,25 @@ async function startCopy(diskID, mode) {
|
||||
return;
|
||||
}
|
||||
startTaskPoll(payload.task_id);
|
||||
refreshDisks();
|
||||
refreshSelectedDisk();
|
||||
} catch (error) {
|
||||
toast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCopy(diskID) {
|
||||
async function cancelCopy() {
|
||||
if (!selectedDisk.info || !selectedDisk.info.disk_id) return;
|
||||
try {
|
||||
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
|
||||
await fetch('/api/disks/' + encodeURIComponent(selectedDisk.info.disk_id) + '/copy/cancel', { method: 'POST' });
|
||||
toast('Canceling...', 'ok');
|
||||
} catch (error) {
|
||||
toast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function initDisk(mountPath) {
|
||||
async function initDisk() {
|
||||
const mountPath = document.getElementById('mountPath').value.trim();
|
||||
if (!mountPath) return;
|
||||
try {
|
||||
const response = await fetch('/api/disks/init', {
|
||||
method: 'POST',
|
||||
@@ -238,23 +276,28 @@ async function initDisk(mountPath) {
|
||||
return;
|
||||
}
|
||||
toast('Disk initialized', 'ok');
|
||||
refreshDisks();
|
||||
refreshSelectedDisk();
|
||||
} catch (error) {
|
||||
toast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('diskGrid').addEventListener('click', (event) => {
|
||||
document.getElementById('diskState').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
if (action === 'start-copy') startCopy(button.dataset.diskId, button.dataset.mode || 'add');
|
||||
if (action === 'cancel-copy') cancelCopy(button.dataset.diskId);
|
||||
if (action === 'init-disk') initDisk(button.dataset.mountPath);
|
||||
if (action === 'start-copy') startCopy(button.dataset.mode || 'add');
|
||||
if (action === 'cancel-copy') cancelCopy();
|
||||
if (action === 'init-disk') initDisk();
|
||||
});
|
||||
|
||||
refreshDisks();
|
||||
setInterval(refreshDisks, 5000);
|
||||
const savedMountPath = localStorage.getItem('jukebox.selectedMountPath');
|
||||
if (savedMountPath) {
|
||||
document.getElementById('mountPath').value = savedMountPath;
|
||||
refreshSelectedDisk();
|
||||
} else {
|
||||
renderDisk();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
<section class="panel">
|
||||
<h2>Copy Sources</h2>
|
||||
<div class="panel-body">
|
||||
<div class="form-hint">Select top-level folders or expand branches and choose individual nested directories.</div>
|
||||
<div class="form-hint">Add one or more root folders with source files. After that, expand each root and enable or disable individual nested folders with checkboxes.</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="button-primary" onclick="addSourceRoot()">Add source folder</button>
|
||||
<button type="button" class="button-secondary button-sm" onclick="reloadAllSourceTrees()">Refresh trees</button>
|
||||
</div>
|
||||
<div class="source-list">
|
||||
<div class="source-tree" id="sourceTree">
|
||||
<div class="text-muted source-tree-empty">Loading...</div>
|
||||
<div class="text-muted source-tree-empty">No source folders added yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="button-secondary button-sm" onclick="reloadSourceTree()">Refresh list</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -77,6 +78,7 @@
|
||||
const sourceTree = new Map();
|
||||
const expandedNodes = new Set();
|
||||
const loadingNodes = new Set();
|
||||
let sourceRoots = [];
|
||||
let sourceConfig = {};
|
||||
|
||||
function escapeHTML(value) {
|
||||
@@ -89,13 +91,43 @@ function escapeHTML(value) {
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function pathDepth(path) {
|
||||
return path ? path.split('/').length : 0;
|
||||
function pathSegments(path) {
|
||||
return String(path || '').split(/[\\/]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function nodeName(path) {
|
||||
const parts = pathSegments(path);
|
||||
return parts.length ? parts[parts.length - 1] : path;
|
||||
}
|
||||
|
||||
function normalizeComparePath(path) {
|
||||
return String(path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function isSamePath(a, b) {
|
||||
return normalizeComparePath(a) === normalizeComparePath(b);
|
||||
}
|
||||
|
||||
function isPathWithin(base, candidate) {
|
||||
const baseNorm = normalizeComparePath(base);
|
||||
const candidateNorm = normalizeComparePath(candidate);
|
||||
return candidateNorm === baseNorm || candidateNorm.startsWith(baseNorm + '/');
|
||||
}
|
||||
|
||||
function parentPath(path) {
|
||||
if (!path || !path.includes('/')) return '';
|
||||
return path.slice(0, path.lastIndexOf('/'));
|
||||
const value = String(path || '').replace(/[\\/]+$/, '');
|
||||
const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
|
||||
if (slash < 0) return '';
|
||||
if (slash === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, slash + 1);
|
||||
if (slash === 0) return value.slice(0, 1);
|
||||
return value.slice(0, slash);
|
||||
}
|
||||
|
||||
function relativeDepth(root, path) {
|
||||
if (isSamePath(root, path)) return 0;
|
||||
const rootParts = pathSegments(root);
|
||||
const pathParts = pathSegments(path);
|
||||
return Math.max(0, pathParts.length - rootParts.length);
|
||||
}
|
||||
|
||||
function effectiveSourceState(path) {
|
||||
@@ -104,37 +136,45 @@ function effectiveSourceState(path) {
|
||||
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
|
||||
return sourceConfig[current];
|
||||
}
|
||||
if (!current) return true;
|
||||
current = parentPath(current);
|
||||
if (!current) return true;
|
||||
}
|
||||
}
|
||||
|
||||
function collectSourcesForSave() {
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
const roots = sourceTree.get('') || [];
|
||||
|
||||
for (const item of roots) {
|
||||
items.push({ path: item.path, enabled: effectiveSourceState(item.path) });
|
||||
seen.add(item.path);
|
||||
}
|
||||
sourceRoots.forEach((root) => {
|
||||
items.push({ path: root, enabled: effectiveSourceState(root), root: true });
|
||||
seen.add(normalizeComparePath(root));
|
||||
});
|
||||
|
||||
Object.entries(sourceConfig).forEach(([path, enabled]) => {
|
||||
if (seen.has(path)) return;
|
||||
items.push({ path, enabled });
|
||||
const key = normalizeComparePath(path);
|
||||
if (seen.has(key)) return;
|
||||
items.push({ path, enabled, root: false });
|
||||
});
|
||||
|
||||
return items.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
async function loadSourceChildren(path = '') {
|
||||
if (loadingNodes.has(path)) return;
|
||||
async function pickFolder() {
|
||||
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Failed to choose folder');
|
||||
}
|
||||
return payload.path || '';
|
||||
}
|
||||
|
||||
async function loadSourceChildren(path) {
|
||||
if (!path || loadingNodes.has(path)) return;
|
||||
loadingNodes.add(path);
|
||||
renderSources();
|
||||
|
||||
try {
|
||||
const query = path ? '?path=' + encodeURIComponent(path) : '';
|
||||
const response = await fetch('/api/sources' + query);
|
||||
const response = await fetch('/api/sources?path=' + encodeURIComponent(path));
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
sourceTree.set(path, payload.items || []);
|
||||
@@ -159,15 +199,59 @@ function toggleSource(path, checked) {
|
||||
renderSources();
|
||||
}
|
||||
|
||||
function renderSourceNodes(parent = '') {
|
||||
const items = sourceTree.get(parent) || [];
|
||||
function removeRoot(path) {
|
||||
sourceRoots = sourceRoots.filter((root) => !isSamePath(root, path));
|
||||
sourceTree.delete(path);
|
||||
expandedNodes.delete(path);
|
||||
loadingNodes.delete(path);
|
||||
|
||||
Object.keys(sourceConfig).forEach((key) => {
|
||||
if (isPathWithin(path, key)) {
|
||||
delete sourceConfig[key];
|
||||
}
|
||||
});
|
||||
|
||||
renderSources();
|
||||
}
|
||||
|
||||
async function addSourceRoot() {
|
||||
try {
|
||||
const path = await pickFolder();
|
||||
if (!path) return;
|
||||
if (sourceRoots.some((root) => isSamePath(root, path))) {
|
||||
toast('This source folder is already added', 'error');
|
||||
return;
|
||||
}
|
||||
sourceRoots.push(path);
|
||||
sourceRoots.sort((a, b) => a.localeCompare(b));
|
||||
sourceConfig[path] = true;
|
||||
expandedNodes.add(path);
|
||||
await loadSourceChildren(path);
|
||||
} catch (error) {
|
||||
toast(error.message || 'Failed to choose folder', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAllSourceTrees() {
|
||||
const roots = [...sourceRoots];
|
||||
sourceTree.clear();
|
||||
for (const root of roots) {
|
||||
if (expandedNodes.has(root)) {
|
||||
await loadSourceChildren(root);
|
||||
}
|
||||
}
|
||||
renderSources();
|
||||
}
|
||||
|
||||
function renderSourceNodes(root, parentPathValue) {
|
||||
const items = sourceTree.get(parentPathValue) || [];
|
||||
return items.map((item) => {
|
||||
const checked = effectiveSourceState(item.path);
|
||||
const expanded = expandedNodes.has(item.path);
|
||||
const childrenKnown = sourceTree.has(item.path);
|
||||
const children = childrenKnown ? sourceTree.get(item.path) : [];
|
||||
const hasChildren = !childrenKnown || children.length > 0;
|
||||
const pad = 16 + pathDepth(item.path) * 20;
|
||||
const pad = 16 + (relativeDepth(root, item.path) + 1) * 20;
|
||||
|
||||
return `
|
||||
<div class="source-node">
|
||||
@@ -182,11 +266,11 @@ function renderSourceNodes(parent = '') {
|
||||
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(item.path)}" ${checked ? 'checked' : ''}>
|
||||
<div class="source-label">
|
||||
<span class="source-item-name">${escapeHTML(item.name)}</span>
|
||||
<span class="source-item-path">/media/${escapeHTML(item.path)}</span>
|
||||
<span class="source-item-path">${escapeHTML(item.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${expanded && loadingNodes.has(item.path) ? '<div class="source-loading">Loading...</div>' : ''}
|
||||
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(item.path)}</div>` : ''}
|
||||
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(root, item.path)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -194,24 +278,54 @@ function renderSourceNodes(parent = '') {
|
||||
|
||||
function renderSources() {
|
||||
const el = document.getElementById('sourceTree');
|
||||
const roots = sourceTree.get('');
|
||||
|
||||
if (loadingNodes.has('') && !roots) {
|
||||
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
|
||||
return;
|
||||
}
|
||||
if (!roots || !roots.length) {
|
||||
el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
|
||||
if (!sourceRoots.length) {
|
||||
el.innerHTML = '<div class="text-muted source-tree-empty">No source folders added yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = renderSourceNodes('');
|
||||
el.innerHTML = sourceRoots.map((root) => {
|
||||
const checked = effectiveSourceState(root);
|
||||
const expanded = expandedNodes.has(root);
|
||||
const childrenKnown = sourceTree.has(root);
|
||||
const children = childrenKnown ? sourceTree.get(root) : [];
|
||||
const hasChildren = !childrenKnown || children.length > 0;
|
||||
|
||||
return `
|
||||
<div class="source-root-card">
|
||||
<div class="source-row source-root-row">
|
||||
<button
|
||||
type="button"
|
||||
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
|
||||
data-action="toggle-expand"
|
||||
data-path="${escapeHTML(root)}"
|
||||
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
|
||||
>${expanded ? '▾' : '▸'}</button>
|
||||
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(root)}" ${checked ? 'checked' : ''}>
|
||||
<div class="source-label">
|
||||
<div class="source-root-title">
|
||||
<span class="source-item-name">${escapeHTML(nodeName(root))}</span>
|
||||
<span class="source-root-badge">Root</span>
|
||||
</div>
|
||||
<span class="source-item-path">${escapeHTML(root)}</span>
|
||||
</div>
|
||||
<button type="button" class="button-secondary button-sm" data-action="remove-root" data-path="${escapeHTML(root)}">Remove</button>
|
||||
</div>
|
||||
${expanded && loadingNodes.has(root) ? '<div class="source-loading">Loading...</div>' : ''}
|
||||
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(root, root)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function reloadSourceTree() {
|
||||
sourceTree.clear();
|
||||
expandedNodes.clear();
|
||||
await loadSourceChildren('');
|
||||
function deriveRootsFromSources(sources) {
|
||||
const explicitRoots = sources.filter((source) => source.root).map((source) => source.path);
|
||||
if (explicitRoots.length) {
|
||||
return explicitRoots;
|
||||
}
|
||||
|
||||
return sources
|
||||
.map((source) => source.path)
|
||||
.filter((path, index, all) => !all.some((other, otherIndex) => otherIndex !== index && isPathWithin(other, path) && !isSamePath(other, path)));
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -229,7 +343,10 @@ async function loadSettings() {
|
||||
(cfg.sources || []).forEach((source) => {
|
||||
sourceConfig[source.path] = !!source.enabled;
|
||||
});
|
||||
renderSources();
|
||||
sourceRoots = deriveRootsFromSources(cfg.sources || []).sort((a, b) => a.localeCompare(b));
|
||||
expandedNodes.clear();
|
||||
sourceTree.clear();
|
||||
await reloadAllSourceTrees();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
@@ -264,16 +381,22 @@ async function saveSettings(event) {
|
||||
}
|
||||
|
||||
document.getElementById('sourceTree').addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('[data-action="toggle-expand"]');
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
const path = button.dataset.path;
|
||||
if (expandedNodes.has(path)) {
|
||||
expandedNodes.delete(path);
|
||||
renderSources();
|
||||
return;
|
||||
if (action === 'toggle-expand') {
|
||||
if (expandedNodes.has(path)) {
|
||||
expandedNodes.delete(path);
|
||||
renderSources();
|
||||
return;
|
||||
}
|
||||
await ensureExpanded(path);
|
||||
}
|
||||
if (action === 'remove-root') {
|
||||
removeRoot(path);
|
||||
}
|
||||
await ensureExpanded(path);
|
||||
});
|
||||
|
||||
document.getElementById('sourceTree').addEventListener('change', (event) => {
|
||||
@@ -283,6 +406,5 @@ document.getElementById('sourceTree').addEventListener('change', (event) => {
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
loadSourceChildren('');
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user