Consolidate tabs, Quick Access links, mini player nav, link descriptions

- Merge Scripts/Callbacks/Links tabs into single Settings tab with collapsible sections
- Rename Actions tab to Quick Access showing both scripts and configured links
- Add prev/next buttons to mini (secondary) player
- Add optional description field to links (backend + frontend)
- Add CSS chevron indicators on collapsible settings sections
- Persist section collapse/expand state in localStorage
- Fix race condition in Quick Access rendering with generation counter
- Order settings sections: Scripts, Links, Callbacks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 15:08:09 +03:00
parent 99dbbb1019
commit adf2d936da
7 changed files with 324 additions and 150 deletions

View File

@@ -400,12 +400,13 @@
document.getElementById('source').textContent = lastStatus.source || t('player.unknown_source');
}
// Reload tables to get translated content
// Reload tables and quick access to get translated content
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
displayQuickAccess();
}
}
@@ -564,8 +565,9 @@
setupVolumeSlider('volume-slider');
setupVolumeSlider('mini-volume-slider');
// Restore saved tab
const savedTab = localStorage.getItem('activeTab') || 'player';
// Restore saved tab (migrate old tab names)
let savedTab = localStorage.getItem('activeTab') || 'player';
if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings';
switchTab(savedTab);
// Snap indicator to initial position without animation
const initialActiveBtn = document.querySelector('.tab-btn.active');
@@ -700,6 +702,17 @@
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Settings sections: restore collapse state and persist on toggle
document.querySelectorAll('.settings-section').forEach(details => {
const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`;
const saved = localStorage.getItem(key);
if (saved === 'closed') details.removeAttribute('open');
else if (saved === 'open') details.setAttribute('open', '');
details.addEventListener('toggle', () => {
localStorage.setItem(key, details.open ? 'open' : 'closed');
});
});
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
@@ -840,6 +853,7 @@
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
@@ -1192,57 +1206,108 @@
if (response.ok) {
scripts = await response.json();
displayScripts();
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
function displayScripts() {
const container = document.getElementById('panel-quick-actions');
let _quickAccessGen = 0;
async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
grid.innerHTML = '';
// Build everything into a fragment before touching the DOM
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
if (scripts.length === 0) {
grid.innerHTML = `<div class="scripts-empty empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('scripts.no_scripts')}</p></div>`;
} else {
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
// Render script buttons
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
fragment.appendChild(button);
});
// Fetch link cards
try {
const token = localStorage.getItem('media_server_token');
if (token) {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (gen !== _quickAccessGen) return; // stale call, discard
if (response.ok) {
const links = await response.json();
hasLinks = links.length > 0;
links.forEach(link => {
const card = document.createElement('a');
card.className = 'script-btn link-card';
card.href = link.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
if (link.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', link.icon);
card.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = link.label || link.name;
card.appendChild(label);
if (link.description) {
const desc = document.createElement('div');
desc.className = 'script-description';
desc.textContent = link.description;
card.appendChild(desc);
}
fragment.appendChild(card);
});
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
grid.appendChild(button);
});
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
// Add "+" card at the end
const addCard = document.createElement('div');
addCard.className = 'script-btn add-card-grid';
addCard.onclick = () => showAddScriptDialog();
addCard.innerHTML = '<span class="add-card-icon">+</span>';
grid.appendChild(addCard);
// Show empty state if nothing
if (!hasScripts && !hasLinks) {
const empty = document.createElement('div');
empty.className = 'scripts-empty empty-state-illustration';
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
fragment.prepend(empty);
}
// Replace grid content atomically
grid.innerHTML = '';
grid.appendChild(fragment);
// Resolve MDI icons
resolveMdiIcons(grid);
@@ -3192,6 +3257,7 @@ async function showEditLinkDialog(linkName) {
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
document.getElementById('linkDescription').value = link.description || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
@@ -3241,7 +3307,8 @@ async function saveLink(event) {
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || ''
label: document.getElementById('linkLabel').value || '',
description: document.getElementById('linkDescription').value || ''
};
const endpoint = isEdit ?