chore: improved web layout

This commit is contained in:
2026-03-08 00:40:38 +01:00
parent 45c0c177eb
commit 6dff83ac61

View File

@@ -104,10 +104,51 @@
.empty { text-align: center; padding: 40px; color: var(--text2); } .empty { text-align: center; padding: 40px; color: var(--text2); }
/* Advanced options toggle */
.advanced-toggle { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 0.85rem; padding: 4px 0; display: flex; align-items: center; gap: 6px; font-family: inherit; margin-bottom: 8px; transition: color 0.15s; }
.advanced-toggle:hover { color: var(--text); }
.advanced-toggle .chevron { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; }
.advanced-toggle.open .chevron { transform: rotate(90deg); }
.advanced-section { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; }
.advanced-section.open { max-height: 600px; }
/* Drag-and-drop feedback */
textarea.drag-over { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(29,185,84,0.25); }
textarea.drop-flash { animation: dropFlash 0.6s ease; }
@keyframes dropFlash { 0%,100% { box-shadow: none; } 30%,70% { box-shadow: 0 0 0 3px var(--accent); border-color: var(--accent); } }
/* Running job pulse */
.status-running { animation: statusPulse 1.8s ease-in-out infinite; }
@keyframes statusPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
/* Toast */
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border-radius: var(--radius); padding: 12px 18px; font-size: 0.88rem; box-shadow: 0 4px 20px rgba(0,0,0,0.5); opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; max-width: 320px; z-index: 999; border-left: 3px solid var(--accent); }
#toast.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
#toast.error { border-left-color: var(--danger); }
/* Bottom navigation bar (mobile only) */
#bottom-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; height: 56px; background: var(--surface); border-top: 1px solid var(--surface2); z-index: 100; }
.bottom-tab { flex: 1; background: none; border: none; color: var(--text2); cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-size: 0.62rem; font-family: inherit; padding: 0; transition: color 0.15s; -webkit-tap-highlight-color: transparent; }
.bottom-tab .btab-icon { display: flex; }
.bottom-tab.active { color: var(--accent); }
/* Touch targets */
.file-actions .btn-icon { padding: 8px 12px; min-width: 36px; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
.file-item { min-height: 48px; }
.checkbox-label { padding: 6px 0; }
@media (max-width: 600px) { @media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; } .form-row { grid-template-columns: 1fr; }
.header { flex-wrap: wrap; } .header { flex-wrap: wrap; }
.tabs { margin-left: 0; width: 100%; } .tabs { display: none; }
.container { padding: 16px; }
.card { padding: 16px; }
body { padding-bottom: 64px; }
textarea, select, input { font-size: 1rem; }
#btn-download { width: 100%; border-radius: var(--radius); }
.job-preview { max-width: calc(100vw - 120px); }
#toast { left: 16px; right: 16px; bottom: 72px; max-width: none; }
#bottom-nav { display: flex; }
} }
</style> </style>
</head> </head>
@@ -119,7 +160,6 @@
<button class="tab" onclick="showPage('jobs')">Jobs</button> <button class="tab" onclick="showPage('jobs')">Jobs</button>
<button class="tab" onclick="showPage('files')">Files</button> <button class="tab" onclick="showPage('files')">Files</button>
<button class="tab" onclick="showPage('settings')">Settings</button> <button class="tab" onclick="showPage('settings')">Settings</button>
{% if auth_enabled %}<a href="/logout" class="tab" style="text-decoration:none">Logout</a>{% endif %}
</div> </div>
</div> </div>
@@ -151,37 +191,44 @@
<option value="mp3" selected>MP3</option> <option value="mp3" selected>MP3</option>
</select> </select>
</div> </div>
<div>
<label for="video_format">Video Format</label>
<select id="video_format">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label for="cover_size">Cover Size</label>
<select id="cover_size">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large" selected>Large</option>
<option value="extra-large">Extra Large</option>
</select>
</div>
<div>
<label for="download_mode">Download Mode</label>
<select id="download_mode">
<option value="ytdlp">yt-dlp</option>
<option value="aria2c">aria2c</option>
</select>
</div>
</div> </div>
<div class="checkbox-row"> <button class="advanced-toggle" id="advanced-toggle" onclick="toggleAdvanced()">
<label class="checkbox-label"><input type="checkbox" id="save_cover"> Save Cover Art</label> <span class="chevron">&#9654;</span> Advanced options
<label class="checkbox-label"><input type="checkbox" id="save_playlist"> Save Playlist File</label> </button>
<label class="checkbox-label"><input type="checkbox" id="overwrite"> Overwrite Existing</label> <div class="advanced-section" id="advanced-section">
<label class="checkbox-label"><input type="checkbox" id="download_music_videos"> Download Music Videos</label> <div class="form-row">
<label class="checkbox-label"><input type="checkbox" id="no_lrc"> No Lyrics</label> <div>
<label for="video_format">Video Format</label>
<select id="video_format">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label for="cover_size">Cover Size</label>
<select id="cover_size">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large" selected>Large</option>
<option value="extra-large">Extra Large</option>
</select>
</div>
<div>
<label for="download_mode">Download Mode</label>
<select id="download_mode">
<option value="ytdlp">yt-dlp</option>
<option value="aria2c">aria2c</option>
</select>
</div>
</div>
<div class="checkbox-row">
<label class="checkbox-label"><input type="checkbox" id="save_cover"> Save Cover Art</label>
<label class="checkbox-label"><input type="checkbox" id="save_playlist"> Save Playlist File</label>
<label class="checkbox-label"><input type="checkbox" id="overwrite"> Overwrite Existing</label>
<label class="checkbox-label"><input type="checkbox" id="download_music_videos"> Download Music Videos</label>
<label class="checkbox-label"><input type="checkbox" id="no_lrc"> No Lyrics</label>
</div>
</div> </div>
<button class="btn" id="btn-download" onclick="startDownload()">Start Download</button> <button class="btn" id="btn-download" onclick="startDownload()">Start Download</button>
@@ -207,17 +254,23 @@
<!-- SETTINGS PAGE --> <!-- SETTINGS PAGE -->
<div id="page-settings" class="page"> <div id="page-settings" class="page">
{% if auth_enabled %}
<div class="card">
<h2>Account</h2>
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
</div>
{% endif %}
<div class="card"> <div class="card">
<h2>Cookies</h2> <h2>Cookies</h2>
<div class="cookie-status" id="cookie-status"> <div class="cookie-status" id="cookie-status">
<div class="dot dot-red"></div> <div class="dot dot-red"></div>
<span>Checking...</span> <span>Checking...</span>
</div> </div>
<br> <div style="margin-top:16px">
<label>Upload cookies.txt (Netscape format)</label> <label>Upload cookies.txt (Netscape format)</label>
<input type="file" id="cookie-file" accept=".txt" style="margin-top:8px"> <input type="file" id="cookie-file" accept=".txt" style="margin-top:8px; display:block">
<br><br> <button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadCookies()">Upload</button>
<button class="btn btn-sm btn-secondary" onclick="uploadCookies()">Upload</button> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Widevine Device</h2> <h2>Widevine Device</h2>
@@ -225,15 +278,36 @@
<div class="dot dot-red"></div> <div class="dot dot-red"></div>
<span>Checking...</span> <span>Checking...</span>
</div> </div>
<br> <div style="margin-top:16px">
<label>Upload device.wvd (required for AAC quality)</label> <label>Upload device.wvd (required for AAC quality)</label>
<input type="file" id="wvd-file" accept=".wvd" style="margin-top:8px"> <input type="file" id="wvd-file" accept=".wvd" style="margin-top:8px; display:block">
<br><br> <button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadWvd()">Upload</button>
<button class="btn btn-sm btn-secondary" onclick="uploadWvd()">Upload</button> </div>
</div> </div>
</div> </div>
</div> </div>
<nav id="bottom-nav">
<button class="bottom-tab active" data-page="download" onclick="showPage('download')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg></span>
<span>Download</span>
</button>
<button class="bottom-tab" data-page="jobs" onclick="showPage('jobs')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg></span>
<span>Jobs</span>
</button>
<button class="bottom-tab" data-page="files" onclick="showPage('files')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg></span>
<span>Files</span>
</button>
<button class="bottom-tab" data-page="settings" onclick="showPage('settings')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg></span>
<span>Settings</span>
</button>
</nav>
<div id="toast"></div>
<script> <script>
let currentPath = ""; let currentPath = "";
let jobPollInterval = null; let jobPollInterval = null;
@@ -245,6 +319,7 @@
document.getElementById('page-' + name).classList.add('active'); document.getElementById('page-' + name).classList.add('active');
const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`); const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`);
if (matchingTab) matchingTab.classList.add('active'); if (matchingTab) matchingTab.classList.add('active');
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === name));
if (name === 'jobs') loadJobs(); if (name === 'jobs') loadJobs();
if (name === 'files') loadFiles(""); if (name === 'files') loadFiles("");
@@ -285,10 +360,10 @@
document.getElementById('urls').value = ''; document.getElementById('urls').value = '';
document.querySelector('[onclick="showPage(\'jobs\')"]').click(); document.querySelector('[onclick="showPage(\'jobs\')"]').click();
} else { } else {
alert(result.error || 'Failed to start download'); showToast(result.error || 'Failed to start download', 'error');
} }
} catch (e) { } catch (e) {
alert('Error: ' + e.message); showToast('Error: ' + e.message, 'error');
} }
btn.disabled = false; btn.disabled = false;
} }
@@ -429,7 +504,7 @@
const encodedPath = encodeURIComponent(f.path); const encodedPath = encodeURIComponent(f.path);
if (f.is_dir) { if (f.is_dir) {
return `<li class="file-item"> return `<li class="file-item">
<span class="file-icon">&#128193;</span> <span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg></span>
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span> <span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
<div class="file-actions"> <div class="file-actions">
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">&#11015;</button> <button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">&#11015;</button>
@@ -439,7 +514,7 @@
} else { } else {
const size = formatSize(f.size); const size = formatSize(f.size);
return `<li class="file-item"> return `<li class="file-item">
<span class="file-icon">&#127925;</span> <span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span> <span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
<span class="file-size">${size}</span> <span class="file-size">${size}</span>
<div class="file-actions"> <div class="file-actions">
@@ -487,20 +562,20 @@
async function uploadCookies() { async function uploadCookies() {
const file = document.getElementById('cookie-file').files[0]; const file = document.getElementById('cookie-file').files[0];
if (!file) { alert('Select a file first'); return; } if (!file) { showToast('Select a file first', 'error'); return; }
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
try { try {
const res = await fetch('/api/cookies', { method: 'POST', body: form }); const res = await fetch('/api/cookies', { method: 'POST', body: form });
if (res.ok) { if (res.ok) {
alert('Cookies uploaded successfully'); showToast('Cookies uploaded successfully');
checkCookies(); checkCookies();
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Upload failed'); showToast(data.error || 'Upload failed', 'error');
} }
} catch (e) { } catch (e) {
alert('Error: ' + e.message); showToast('Error: ' + e.message, 'error');
} }
} }
@@ -521,20 +596,20 @@
async function uploadWvd() { async function uploadWvd() {
const file = document.getElementById('wvd-file').files[0]; const file = document.getElementById('wvd-file').files[0];
if (!file) { alert('Select a file first'); return; } if (!file) { showToast('Select a file first', 'error'); return; }
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
try { try {
const res = await fetch('/api/wvd', { method: 'POST', body: form }); const res = await fetch('/api/wvd', { method: 'POST', body: form });
if (res.ok) { if (res.ok) {
alert('WVD file uploaded successfully'); showToast('WVD file uploaded successfully');
checkWvd(); checkWvd();
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Upload failed'); showToast(data.error || 'Upload failed', 'error');
} }
} catch (e) { } catch (e) {
alert('Error: ' + e.message); showToast('Error: ' + e.message, 'error');
} }
} }
@@ -556,6 +631,42 @@
return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'show' + (type === 'error' ? ' error' : '');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.className = ''; }, 3000);
}
function toggleAdvanced() {
const section = document.getElementById('advanced-section');
const toggle = document.getElementById('advanced-toggle');
const isOpen = section.classList.toggle('open');
toggle.classList.toggle('open', isOpen);
localStorage.setItem('votify-advanced-open', isOpen ? '1' : '');
}
// Drag-and-drop Spotify URLs onto the page
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
const text = e.dataTransfer.getData('text') || e.dataTransfer.getData('text/uri-list') || '';
const lines = text.split(/[\r\n\s]+/).filter(l => l.includes('open.spotify.com'));
if (!lines.length) return;
const ta = document.getElementById('urls');
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n');
ta.classList.remove('drop-flash');
void ta.offsetWidth; // reflow to restart animation
ta.classList.add('drop-flash');
setTimeout(() => ta.classList.remove('drop-flash'), 650);
showPage('download');
});
const urlTextarea = document.getElementById('urls');
urlTextarea.addEventListener('dragover', () => urlTextarea.classList.add('drag-over'));
urlTextarea.addEventListener('dragleave', () => urlTextarea.classList.remove('drag-over'));
urlTextarea.addEventListener('drop', () => urlTextarea.classList.remove('drag-over'));
// Remember settings // Remember settings
const SETTINGS_KEY = 'votify-settings'; const SETTINGS_KEY = 'votify-settings';
const SETTING_IDS = ['audio_quality', 'output_format', 'video_format', 'cover_size', 'download_mode', 'save_cover', 'save_playlist', 'overwrite', 'download_music_videos', 'no_lrc']; const SETTING_IDS = ['audio_quality', 'output_format', 'video_format', 'cover_size', 'download_mode', 'save_cover', 'save_playlist', 'overwrite', 'download_music_videos', 'no_lrc'];
@@ -583,6 +694,10 @@
} }
loadSettings(); loadSettings();
if (localStorage.getItem('votify-advanced-open')) {
document.getElementById('advanced-section').classList.add('open');
document.getElementById('advanced-toggle').classList.add('open');
}
checkCookies(); checkCookies();
checkWvd(); checkWvd();