First Public Release!
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
/* HEVC Dashboard styling overrides */
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--panel: rgba(17, 17, 17, 0.30);
|
||||
--panel-soft: rgba(10, 10, 10, 0.78);
|
||||
--border: rgba(39, 39, 42, 0.95);
|
||||
--border-strong: rgba(63, 63, 70, 0.95);
|
||||
--text: #f4f4f5;
|
||||
--muted: #71717a;
|
||||
--accent: #10b981;
|
||||
--danger: #fb7185;
|
||||
--shadow: none;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2b2b31;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #40404a;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
width: min(100%, 1640px);
|
||||
padding-left: clamp(12px, 1.6vw, 20px);
|
||||
padding-right: clamp(12px, 1.6vw, 20px);
|
||||
padding-top: clamp(10px, 1.2vw, 16px);
|
||||
padding-bottom: clamp(10px, 1.2vw, 16px);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: rgba(17, 17, 17, 0.30);
|
||||
border: 1px solid rgba(39, 39, 42, 0.80);
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.surface-soft {
|
||||
background: rgba(12, 12, 12, 0.86);
|
||||
border-color: rgba(39, 39, 42, 0.75);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.dashboard-panel,
|
||||
.dashboard-config {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-panel-head,
|
||||
.panel-head {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dashboard-config-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-chip,
|
||||
.dashboard-status {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='text'],
|
||||
.dashboard-shell input[type='number'],
|
||||
.dashboard-shell select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #050505;
|
||||
border: 1px solid rgba(63, 63, 70, 0.95);
|
||||
color: #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 0.56rem 0.72rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.exclude-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #050505;
|
||||
border: 1px solid rgba(63, 63, 70, 0.95);
|
||||
color: #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.exclude-shell:focus-within {
|
||||
border-color: rgba(163, 163, 163, 0.95);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.exclude-shell-input {
|
||||
min-height: 20px;
|
||||
padding: 0.15rem 0.1rem;
|
||||
}
|
||||
|
||||
.panel-button {
|
||||
width: 100%;
|
||||
min-height: 2.15rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(63, 63, 70, 0.95);
|
||||
background: #050505;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.panel-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-button-small {
|
||||
width: auto;
|
||||
min-width: 3rem;
|
||||
padding-left: 0.7rem;
|
||||
padding-right: 0.7rem;
|
||||
}
|
||||
|
||||
.panel-button-primary {
|
||||
background: #f4f4f5;
|
||||
color: #000;
|
||||
border-color: #e4e4e7;
|
||||
}
|
||||
|
||||
.panel-button-success {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border-color: rgba(16, 185, 129, 0.75);
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.panel-button-neutral {
|
||||
background: #262626;
|
||||
color: #e5e7eb;
|
||||
border-color: rgba(63, 63, 70, 0.95);
|
||||
}
|
||||
|
||||
.panel-button-danger {
|
||||
background: rgba(127, 29, 29, 0.18);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(127, 29, 29, 0.6);
|
||||
}
|
||||
|
||||
.panel-button-muted {
|
||||
background: #090909;
|
||||
color: #71717a;
|
||||
border-color: rgba(63, 63, 70, 0.9);
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='text']::placeholder,
|
||||
.dashboard-shell input[type='number']::placeholder {
|
||||
color: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='text']:focus,
|
||||
.dashboard-shell input[type='number']:focus,
|
||||
.dashboard-shell select:focus {
|
||||
border-color: rgba(163, 163, 163, 0.95);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dashboard-shell select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: none;
|
||||
padding-right: 2.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-shell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0.5rem 0.78rem 0.5rem auto;
|
||||
width: 1.3rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(212, 212, 216, 0.84), rgba(212, 212, 216, 0.50));
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.select-shell select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.select-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-card {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.toggle-copy {
|
||||
max-width: calc(100% - 3.1rem);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 2.55rem;
|
||||
height: 1.28rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='number']::-webkit-inner-spin-button,
|
||||
.dashboard-shell input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-shell button {
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.dashboard-shell button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dashboard-shell button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.recent-pane,
|
||||
.logs-pane {
|
||||
scrollbar-color: #2f2f38 transparent;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.config-panel {
|
||||
max-height: calc(100vh - 17rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-pane {
|
||||
height: 148px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.dashboard-shell {
|
||||
width: min(100%, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.dashboard-shell {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.dashboard-shell input[type='text'],
|
||||
.dashboard-shell input[type='number'],
|
||||
.dashboard-shell select {
|
||||
border-radius: 12px;
|
||||
padding: 0.52rem 0.7rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.toggle-card {
|
||||
min-height: 72px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user