630 lines
23 KiB
JavaScript
630 lines
23 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// State & DOM
|
|
// ---------------------------------------------------------------------------
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
let workerUrl = $('worker-url-display').textContent.trim();
|
|
let evtSource = null;
|
|
let isPolling = false;
|
|
let excludedFolders = new Set();
|
|
let recommendedThreads = 3;
|
|
|
|
const elStatus = $('status-indicator');
|
|
const elStatusText = $('status-text');
|
|
|
|
const elDone = $('stat-done');
|
|
const elSkipped = $('stat-skipped');
|
|
const elSaved = $('stat-saved');
|
|
const elQueued = $('stat-queued');
|
|
|
|
const elActiveList = $('active-encodes-container');
|
|
const elRecentList = $('recent-finished-container');
|
|
const elLogCount = $('log-count');
|
|
const elWorkerUrl = $('worker-url-display');
|
|
const elUrlEditor = $('url-editor');
|
|
const elUrlInput = $('worker-url-input');
|
|
const elModalBdrop = $('modal-backdrop');
|
|
const elModalBody = $('modal-body');
|
|
const elModalCrumb = $('modal-breadcrumb');
|
|
const elModalPath = $('modal-current-path');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Worker URL
|
|
// ---------------------------------------------------------------------------
|
|
function updateWorkerDisplay() {
|
|
elWorkerUrl.textContent = workerUrl;
|
|
elUrlInput.value = workerUrl;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API Helpers
|
|
// ---------------------------------------------------------------------------
|
|
async function POST(path, body = {}) {
|
|
const res = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET Helpers
|
|
// ---------------------------------------------------------------------------
|
|
async function GET(path) {
|
|
const res = await fetch(path);
|
|
return res.json();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status Polling
|
|
// ---------------------------------------------------------------------------
|
|
function applyStatus(data) {
|
|
const state = data.state || 'unreachable';
|
|
elStatus.dataset.state = state;
|
|
elStatusText.textContent = state.toUpperCase();
|
|
|
|
// Color dynamic header status indicator
|
|
elStatus.className = "status-badge px-4 py-2 border rounded-xl text-xs font-bold tracking-wider flex items-center gap-2 transition-all ";
|
|
let dotColorClass = "bg-zinc-500";
|
|
if (state === 'idle') {
|
|
elStatus.classList.add("border-zinc-800", "text-zinc-400", "bg-zinc-950/80");
|
|
dotColorClass = "bg-zinc-500";
|
|
} else if (state === 'scanning' || state === 'paused') {
|
|
elStatus.classList.add("border-amber-500/50", "text-amber-500", "bg-amber-950/10");
|
|
dotColorClass = "bg-amber-500";
|
|
} else if (state === 'running' || state === 'done') {
|
|
elStatus.classList.add("border-emerald-500/50", "text-emerald-500", "bg-emerald-950/10");
|
|
dotColorClass = "bg-emerald-500";
|
|
} else {
|
|
elStatus.classList.add("border-rose-500/50", "text-rose-500", "bg-rose-950/10");
|
|
dotColorClass = "bg-rose-500";
|
|
}
|
|
const statusDot = $('status-dot');
|
|
if (statusDot) {
|
|
statusDot.className = `w-2 h-2 rounded-full ${dotColorClass}`;
|
|
}
|
|
|
|
elDone.textContent = data.done_count ?? 0;
|
|
elSkipped.textContent = data.skipped_count ?? 0;
|
|
elSaved.textContent = data.total_saved_human ?? '0 B';
|
|
elQueued.textContent = data.queued_count ?? 0;
|
|
|
|
// Render extra stats
|
|
const elTotal = $('stat-total');
|
|
const elRatio = $('stat-ratio');
|
|
if (elTotal) elTotal.textContent = data.total_files ?? 0;
|
|
if (elRatio) elRatio.textContent = (data.compression_ratio ?? 0.0) + '%';
|
|
|
|
// Auto-set threads to recommended value if returned and not overridden by user
|
|
const selectThreads = $('threads');
|
|
if (selectThreads && data.recommended_threads && !selectThreads.dataset.userModified) {
|
|
selectThreads.value = String(data.recommended_threads);
|
|
}
|
|
|
|
renderActiveEncodes(data.active_encodes || []);
|
|
renderRecentDone(data.recent_done || []);
|
|
updateButtons(state);
|
|
}
|
|
|
|
async function pollStatus() {
|
|
try {
|
|
const data = await GET('/proxy/status');
|
|
if (data && data.state) applyStatus(data);
|
|
else throw new Error('Invalid status');
|
|
} catch (_) {
|
|
elStatus.dataset.state = 'unreachable';
|
|
elStatusText.textContent = 'UNREACHABLE';
|
|
|
|
elStatus.className = "status-badge px-4 py-2 border border-rose-500/50 text-rose-500 bg-rose-950/10 rounded-xl text-xs font-bold tracking-wider flex items-center gap-2";
|
|
const statusDot = $('status-dot');
|
|
if (statusDot) statusDot.className = "w-2 h-2 rounded-full bg-rose-500";
|
|
|
|
updateButtons('unreachable');
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (isPolling) return;
|
|
isPolling = true;
|
|
pollStatus();
|
|
setInterval(pollStatus, 2000);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render UI
|
|
// ---------------------------------------------------------------------------
|
|
function renderActiveEncodes(list) {
|
|
if (!list || list.length === 0) {
|
|
elActiveList.innerHTML = '<div class="text-zinc-500 py-6 text-center text-xs">No active processes</div>';
|
|
return;
|
|
}
|
|
let html = '';
|
|
list.forEach(enc => {
|
|
html += `
|
|
<div class="bg-zinc-900/30 border border-zinc-800/80 rounded-xl p-4 flex flex-col gap-3">
|
|
<div class="flex justify-between items-start gap-4">
|
|
<div class="font-medium text-xs text-zinc-200 truncate flex-1" title="${escAttr(enc.file)}">${escAttr(enc.file)}</div>
|
|
<div class="text-xs font-semibold text-emerald-500 font-mono">${enc.progress}%</div>
|
|
</div>
|
|
<div class="bg-zinc-950 h-1 rounded-full overflow-hidden border border-zinc-900">
|
|
<div class="bg-emerald-600 h-full rounded-full transition-all duration-300" style="width: ${enc.progress}%;"></div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-zinc-400 font-medium">
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
|
${enc.eta}
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
|
${enc.fps} fps
|
|
</span>
|
|
<span class="text-zinc-500">·</span>
|
|
<span>${enc.speed}</span>
|
|
<span class="text-zinc-500">·</span>
|
|
<span>${enc.bitrate}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
elActiveList.innerHTML = html;
|
|
}
|
|
|
|
function renderRecentDone(list) {
|
|
if (!list || list.length === 0) {
|
|
elRecentList.innerHTML = '<div class="text-zinc-500 py-6 text-center text-xs">Nothing finished yet</div>';
|
|
return;
|
|
}
|
|
let html = '';
|
|
list.forEach(item => {
|
|
html += `
|
|
<div class="flex justify-between items-center gap-4 p-3 bg-zinc-950/40 border border-zinc-900/60 rounded-xl hover:border-zinc-800 transition-colors">
|
|
<div class="text-xs text-zinc-300 font-medium truncate flex-1" title="${escAttr(item.file)}">${escAttr(item.file)}</div>
|
|
<div class="text-xs text-emerald-500 font-semibold font-mono bg-emerald-950/20 px-2 py-0.5 rounded border border-emerald-900/30 whitespace-nowrap">Saved ${item.saved_human}</div>
|
|
</div>
|
|
`;
|
|
});
|
|
elRecentList.innerHTML = html;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Button state machine
|
|
// ---------------------------------------------------------------------------
|
|
function updateButtons(state) {
|
|
const scan = $('btn-scan');
|
|
const proc = $('btn-process');
|
|
const p = $('btn-pause');
|
|
const r = $('btn-resume');
|
|
const x = $('btn-stop');
|
|
const cl = $('btn-clear-db');
|
|
|
|
// Reset
|
|
[scan, proc, p, r, x, cl].forEach(b => { if(b) b.disabled = true; });
|
|
|
|
if (state === 'idle' || state === 'done' || state === 'unreachable') {
|
|
if (scan) scan.disabled = false;
|
|
if (proc) proc.disabled = false;
|
|
if (cl) cl.disabled = false;
|
|
setHidden(p, false);
|
|
setHidden(r, true);
|
|
} else if (state === 'scanning' || state === 'running') {
|
|
if (p) p.disabled = false;
|
|
if (x) x.disabled = false;
|
|
setHidden(p, false);
|
|
setHidden(r, true);
|
|
} else if (state === 'paused') {
|
|
if (r) r.disabled = false;
|
|
if (x) x.disabled = false;
|
|
setHidden(p, true);
|
|
setHidden(r, false);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server-Sent Events (Logs)
|
|
// ---------------------------------------------------------------------------
|
|
function connectSSE() {
|
|
if (evtSource) {
|
|
evtSource.close();
|
|
}
|
|
evtSource = new EventSource('/proxy/logs/stream');
|
|
const logContainer = $('logs-container');
|
|
let lineCount = 0;
|
|
|
|
evtSource.onmessage = (e) => {
|
|
if (e.data.includes('keepalive')) return;
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
const div = document.createElement('div');
|
|
div.className = 'py-0.5 border-b border-zinc-950 font-mono text-[11px] flex gap-3 text-zinc-300';
|
|
|
|
let levelColor = 'text-zinc-500';
|
|
if (data.level === 'SUCCESS') levelColor = 'text-emerald-400 font-semibold';
|
|
else if (data.level === 'WARN') levelColor = 'text-amber-400 font-semibold';
|
|
else if (data.level === 'ERROR') levelColor = 'text-rose-400 font-bold';
|
|
else if (data.level === 'INFO') levelColor = 'text-zinc-400';
|
|
|
|
div.innerHTML = `
|
|
<span class="text-zinc-650 select-none">${data.time.split(' ')[1]}</span>
|
|
<span class="w-16 shrink-0 uppercase tracking-wider font-bold text-[10px] ${levelColor}">${data.level}</span>
|
|
<span class="break-all flex-1 text-zinc-300">${escAttr(data.message)}</span>
|
|
`;
|
|
logContainer.appendChild(div);
|
|
lineCount++;
|
|
elLogCount.textContent = lineCount + ' lines';
|
|
|
|
// Auto-scroll logic
|
|
if (logContainer.scrollHeight - logContainer.scrollTop < logContainer.clientHeight + 100) {
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
|
|
evtSource.onerror = () => {
|
|
evtSource.close();
|
|
setTimeout(connectSSE, 5000); // Reconnect
|
|
};
|
|
}
|
|
|
|
$('btn-clear-logs')?.addEventListener('click', () => {
|
|
$('logs-container').innerHTML = '';
|
|
elLogCount.textContent = '0 lines';
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config Builders
|
|
// ---------------------------------------------------------------------------
|
|
function escAttr(str) {
|
|
return String(str).replace(/"/g,'"');
|
|
}
|
|
|
|
function setHidden(element, hidden) {
|
|
if (!element) return;
|
|
element.classList.toggle('is-hidden', hidden);
|
|
}
|
|
|
|
function calcRecommendedThreads(logicalCores) {
|
|
const cores = Math.max(1, logicalCores || 4);
|
|
if (cores >= 12) return Math.floor(cores / 2);
|
|
if (cores >= 6) return cores - 2;
|
|
return Math.max(1, cores - 1);
|
|
}
|
|
|
|
async function setupThreadOptions() {
|
|
const select = $('threads');
|
|
if (!select) return;
|
|
|
|
let logicalCores = 8;
|
|
try {
|
|
const sysinfo = await GET('/proxy/sysinfo');
|
|
logicalCores = sysinfo.cpu_count || 8;
|
|
recommendedThreads = sysinfo.recommended_threads || 3;
|
|
if ($('spec-cpu')) $('spec-cpu').textContent = sysinfo.cpu_name || 'Unknown CPU';
|
|
if ($('spec-gpu')) $('spec-gpu').textContent = sysinfo.gpu_name || 'Unknown GPU';
|
|
} catch (err) {
|
|
console.error('Error fetching sysinfo from server, falling back to local hardware:', err);
|
|
logicalCores = Number(navigator.hardwareConcurrency) || 8;
|
|
recommendedThreads = Math.max(1, Math.min(logicalCores, calcRecommendedThreads(logicalCores)));
|
|
if ($('spec-cpu')) $('spec-cpu').textContent = 'Unknown CPU';
|
|
if ($('spec-gpu')) $('spec-gpu').textContent = 'Unknown GPU';
|
|
}
|
|
|
|
const maxThreads = Math.max(4, Math.min(12, logicalCores));
|
|
recommendedThreads = Math.max(1, Math.min(maxThreads, recommendedThreads));
|
|
|
|
select.innerHTML = '';
|
|
for (let t = 1; t <= maxThreads; t++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(t);
|
|
opt.textContent = `${t} thread${t === 1 ? '' : 's'}${t === recommendedThreads ? ' (Recommended)' : ''}`;
|
|
if (t === recommendedThreads) {
|
|
opt.selected = true;
|
|
}
|
|
select.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function buildStartConfig() {
|
|
const minSizeMB = parseInt($('min-size').value, 10) || 0;
|
|
return {
|
|
media_dir: $('media-dir').value.trim(),
|
|
temp_dir: $('temp-dir').value.trim(),
|
|
min_size_bytes: minSizeMB * 1024 * 1024,
|
|
excluded_folders: [...excludedFolders],
|
|
threads: parseInt($('threads').value, 10) || recommendedThreads,
|
|
preset: $('preset').value || 'medium',
|
|
crf: parseInt($('crf').value, 10) || 22,
|
|
discord_webhook: $('discord-webhook') ? $('discord-webhook').value.trim() : '',
|
|
custom_ffmpeg: $('custom-ffmpeg') ? $('custom-ffmpeg').value.trim() : '',
|
|
custom_ffprobe: $('custom-ffprobe') ? $('custom-ffprobe').value.trim() : '',
|
|
encoder: $('encoder') ? $('encoder').value : 'libx265',
|
|
audio_codec: $('audio-codec') ? $('audio-codec').value : 'auto',
|
|
rename_output: $('rename-output') ? $('rename-output').checked : false,
|
|
force_reencode: $('force-reencode') ? $('force-reencode').checked : false,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initialization
|
|
// ---------------------------------------------------------------------------
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
updateWorkerDisplay();
|
|
setupThreadOptions();
|
|
startPolling();
|
|
connectSSE();
|
|
|
|
// Mark threads as user-modified if manually changed
|
|
$('threads')?.addEventListener('change', () => {
|
|
$('threads').dataset.userModified = 'true';
|
|
});
|
|
|
|
// Auto-save typed inputs on blur
|
|
$('media-dir')?.addEventListener('blur', () => {
|
|
POST('/config', { media_root: $('media-dir').value.trim() });
|
|
});
|
|
$('temp-dir')?.addEventListener('blur', () => {
|
|
POST('/config', { temp_dir: $('temp-dir').value.trim() });
|
|
});
|
|
$('discord-webhook')?.addEventListener('blur', () => {
|
|
POST('/config', { discord_webhook: $('discord-webhook').value.trim() });
|
|
});
|
|
$('custom-ffmpeg')?.addEventListener('blur', () => {
|
|
POST('/config', { custom_ffmpeg: $('custom-ffmpeg').value.trim() });
|
|
});
|
|
$('custom-ffprobe')?.addEventListener('blur', () => {
|
|
POST('/config', { custom_ffprobe: $('custom-ffprobe').value.trim() });
|
|
});
|
|
$('encoder')?.addEventListener('change', () => {
|
|
POST('/config', { encoder: $('encoder').value });
|
|
});
|
|
$('audio-codec')?.addEventListener('change', () => {
|
|
POST('/config', { audio_codec: $('audio-codec').value });
|
|
});
|
|
$('rename-output')?.addEventListener('change', () => {
|
|
POST('/config', { rename_output: $('rename-output').checked });
|
|
});
|
|
$('force-reencode')?.addEventListener('change', () => {
|
|
POST('/config', { force_reencode: $('force-reencode').checked });
|
|
});
|
|
|
|
// Test Discord Webhook
|
|
$('btn-test-webhook')?.addEventListener('click', async () => {
|
|
const url = $('discord-webhook').value.trim();
|
|
if (!url) {
|
|
alert('Please enter a Discord Webhook URL first.');
|
|
return;
|
|
}
|
|
const btn = $('btn-test-webhook');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Testing...';
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await POST('/proxy/test-webhook', { discord_webhook: url });
|
|
if (res.ok) {
|
|
alert('Test notification sent successfully!');
|
|
} else {
|
|
alert('Error: ' + (res.error || 'Failed to send notification.'));
|
|
}
|
|
} catch (err) {
|
|
alert('Network error testing webhook.');
|
|
} finally {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// --- Control buttons ---
|
|
$('btn-scan')?.addEventListener('click', async () => {
|
|
await POST('/proxy/scan', buildStartConfig());
|
|
pollStatus();
|
|
});
|
|
$('btn-process')?.addEventListener('click', async () => {
|
|
await POST('/proxy/process', buildStartConfig());
|
|
pollStatus();
|
|
});
|
|
$('btn-clear-db')?.addEventListener('click', async () => {
|
|
if (!confirm('Clear the entire media tracking database?')) return;
|
|
await POST('/proxy/database/clear');
|
|
pollStatus();
|
|
});
|
|
$('btn-pause')?.addEventListener('click', async () => {
|
|
await POST('/proxy/pause');
|
|
pollStatus();
|
|
});
|
|
$('btn-resume')?.addEventListener('click', async () => {
|
|
await POST('/proxy/resume');
|
|
pollStatus();
|
|
});
|
|
$('btn-stop')?.addEventListener('click', async () => {
|
|
await POST('/proxy/stop');
|
|
pollStatus();
|
|
});
|
|
|
|
// --- Worker URL Editor ---
|
|
$('btn-edit-url')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
elUrlEditor.classList.remove('is-hidden');
|
|
});
|
|
$('btn-save-url')?.addEventListener('click', async () => {
|
|
const newVal = elUrlInput.value.trim();
|
|
if (newVal) {
|
|
await POST('/config', { worker_url: newVal });
|
|
workerUrl = newVal;
|
|
updateWorkerDisplay();
|
|
elUrlEditor.classList.add('is-hidden');
|
|
connectSSE();
|
|
pollStatus();
|
|
}
|
|
});
|
|
$('btn-cancel-url')?.addEventListener('click', () => {
|
|
elUrlEditor.classList.add('is-hidden');
|
|
});
|
|
|
|
// --- Tag input ---
|
|
const tagInput = $('excl-input');
|
|
tagInput?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const val = tagInput.value.trim();
|
|
if (val && !excludedFolders.has(val)) {
|
|
excludedFolders.add(val);
|
|
renderTags();
|
|
}
|
|
tagInput.value = '';
|
|
}
|
|
});
|
|
|
|
function renderTags() {
|
|
const container = $('excl-tags');
|
|
// keep only the input
|
|
Array.from(container.querySelectorAll('.tag')).forEach(el => el.remove());
|
|
excludedFolders.forEach(tag => {
|
|
const el = document.createElement('div');
|
|
el.className = 'tag bg-zinc-800 text-zinc-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-lg flex items-center gap-1.5 border border-zinc-700/60 select-none';
|
|
el.innerHTML = `${escAttr(tag)} <span class="tag-remove text-zinc-500 hover:text-white cursor-pointer font-bold text-[11px]">×</span>`;
|
|
el.querySelector('.tag-remove').onclick = () => {
|
|
excludedFolders.delete(tag);
|
|
renderTags();
|
|
};
|
|
container.insertBefore(el, tagInput);
|
|
});
|
|
}
|
|
|
|
// --- Local File Browser ---
|
|
let modalTarget = 'media-dir';
|
|
|
|
async function loadDrives() {
|
|
const container = $('modal-drive-switcher');
|
|
if (!container) return [];
|
|
try {
|
|
const drives = await GET('/proxy/drives');
|
|
const label = container.querySelector('span');
|
|
container.innerHTML = '';
|
|
if (label) container.appendChild(label);
|
|
if (drives && Array.isArray(drives)) {
|
|
drives.forEach(drive => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'px-3 py-1 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-lg text-xs font-semibold transition-colors';
|
|
btn.textContent = drive;
|
|
btn.onclick = () => loadDir(drive);
|
|
container.appendChild(btn);
|
|
});
|
|
return drives;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading drives:', err);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
$('btn-browse')?.addEventListener('click', async () => {
|
|
modalTarget = 'media-dir';
|
|
elModalBdrop.classList.remove('is-hidden');
|
|
const drives = await loadDrives();
|
|
const val = $('media-dir').value.trim();
|
|
if (val) {
|
|
loadDir(val);
|
|
} else if (drives && drives.length > 0) {
|
|
loadDir(drives[0]);
|
|
} else {
|
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
|
}
|
|
});
|
|
|
|
$('btn-browse-temp')?.addEventListener('click', async () => {
|
|
modalTarget = 'temp-dir';
|
|
elModalBdrop.classList.remove('is-hidden');
|
|
const drives = await loadDrives();
|
|
const val = $('temp-dir').value.trim();
|
|
if (val) {
|
|
loadDir(val);
|
|
} else if (drives && drives.length > 0) {
|
|
loadDir(drives[0]);
|
|
} else {
|
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
|
}
|
|
});
|
|
|
|
$('btn-browse-excl')?.addEventListener('click', async () => {
|
|
modalTarget = 'exclude';
|
|
elModalBdrop.classList.remove('is-hidden');
|
|
const drives = await loadDrives();
|
|
const val = $('media-dir').value.trim();
|
|
if (val) {
|
|
loadDir(val);
|
|
} else if (drives && drives.length > 0) {
|
|
loadDir(drives[0]);
|
|
} else {
|
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
|
}
|
|
});
|
|
|
|
$('btn-modal-cancel')?.addEventListener('click', () => {
|
|
elModalBdrop.classList.add('is-hidden');
|
|
});
|
|
$('btn-modal-select')?.addEventListener('click', () => {
|
|
if (modalTarget === 'media-dir') {
|
|
$('media-dir').value = elModalPath.value;
|
|
POST('/config', { media_root: elModalPath.value });
|
|
} else if (modalTarget === 'temp-dir') {
|
|
$('temp-dir').value = elModalPath.value;
|
|
POST('/config', { temp_dir: elModalPath.value });
|
|
} else if (modalTarget === 'exclude') {
|
|
// Use the last part of the path as the folder name
|
|
const folderName = elModalPath.value.split('/').pop() || elModalPath.value;
|
|
if (folderName && !excludedFolders.has(folderName)) {
|
|
excludedFolders.add(folderName);
|
|
renderTags();
|
|
}
|
|
}
|
|
elModalBdrop.classList.add('is-hidden');
|
|
});
|
|
|
|
async function loadDir(path) {
|
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">Loading...</div>';
|
|
try {
|
|
const res = await GET(`/proxy/browse?path=${encodeURIComponent(path)}`);
|
|
if (res.error) {
|
|
elModalBody.innerHTML = `<div class="p-5 text-rose-500 text-xs font-semibold">${escAttr(res.error)}</div>`;
|
|
return;
|
|
}
|
|
|
|
elModalPath.value = res.path;
|
|
renderBreadcrumb(res.path, res.parent);
|
|
|
|
if (!res.dirs || res.dirs.length === 0) {
|
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">No subdirectories found.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
res.dirs.forEach(d => {
|
|
html += `
|
|
<div class="dir-item px-4 py-2.5 text-xs text-zinc-300 rounded-xl hover:bg-zinc-900 hover:text-white flex items-center gap-3 cursor-pointer transition-colors" data-path="${escAttr(d.full_path)}">
|
|
<svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
|
<span class="truncate">${escAttr(d.name)}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
elModalBody.innerHTML = html;
|
|
|
|
elModalBody.querySelectorAll('.dir-item').forEach(el => {
|
|
el.addEventListener('click', () => loadDir(el.dataset.path));
|
|
});
|
|
} catch (exc) {
|
|
elModalBody.innerHTML = `<div class="p-5 text-rose-500 text-xs font-semibold">Error loading directory</div>`;
|
|
}
|
|
}
|
|
|
|
function renderBreadcrumb(path, parent) {
|
|
let html = '';
|
|
if (parent) {
|
|
html += `<div class="px-6 py-2 border-b border-zinc-900 bg-zinc-900/30 text-xs font-semibold text-zinc-400 cursor-pointer hover:bg-zinc-900 hover:text-white transition-colors" id="crumb-up">↑ Up to ${escAttr(parent)}</div>`;
|
|
}
|
|
html += `<div class="px-6 py-2 text-xs font-mono font-medium text-emerald-500 break-all select-all">${escAttr(path)}</div>`;
|
|
elModalCrumb.innerHTML = html;
|
|
|
|
$('crumb-up')?.addEventListener('click', () => loadDir(parent));
|
|
}
|
|
});
|