Add header quick links with CRUD management and icon enhancements

- Add LinkConfig model and links field to settings
- Add CRUD API endpoints for links (list/create/update/delete)
- Add Links management tab in WebUI with add/edit/delete dialogs
- Add live icon preview in Link and Script dialog forms
- Show MDI icons inline in Quick Actions cards, Scripts table, Links table
- Add broadcast_links_changed WebSocket event for live updates
- Add EN/RU translations for all links management strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:42:18 +03:00
parent 6f6a4e4aec
commit 99dbbb1019
11 changed files with 886 additions and 5 deletions

View File

@@ -405,6 +405,7 @@
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
}
}
@@ -525,6 +526,7 @@
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
} else {
showAuthForm();
}
@@ -661,12 +663,43 @@
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
if (e.target === linkDialog) {
closeLinkDialog();
}
});
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.linkName;
if (action === 'edit') showEditLinkDialog(name);
else if (action === 'delete') deleteLinkConfirm(name);
});
// Track link form dirty state
const linkForm = document.getElementById('linkForm');
linkForm.addEventListener('input', () => {
linkFormDirty = true;
});
linkForm.addEventListener('change', () => {
linkFormDirty = true;
});
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
loadMediaFolders();
}
// Icon preview for script and link dialogs
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
@@ -790,6 +823,8 @@
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
};
ws.onmessage = (event) => {
@@ -801,6 +836,10 @@
console.log('Scripts changed, reloading...');
loadScripts(); // Reload Quick Actions
loadScriptsTable(); // Reload Script Management table
} else if (msg.type === 'links_changed') {
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
@@ -1174,6 +1213,13 @@
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);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
@@ -1197,6 +1243,9 @@
addCard.onclick = () => showAddScriptDialog();
addCard.innerHTML = '<span class="add-card-icon">+</span>';
grid.appendChild(addCard);
// Resolve MDI icons
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
@@ -1311,7 +1360,7 @@
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><code>${escapeHtml(script.name)}</code></td>
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
<td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
@@ -1331,6 +1380,7 @@
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
@@ -1353,6 +1403,7 @@
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
title.textContent = t('scripts.dialog.add');
// Reset dirty state
@@ -1396,6 +1447,14 @@
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
// Update icon preview
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('scripts.dialog.edit');
// Reset dirty state
@@ -2928,3 +2987,321 @@ async function toggleDisplayPower(monitorId, monitorName) {
}
}
// ============================================================
// Header Quick Links
// ============================================================
const mdiIconCache = {};
async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name"
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
return svg;
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
// Fallback: generic link icon
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
}
// Resolve all data-mdi-icon placeholders in a container
async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
// Debounced icon preview updater
function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
// Re-check value hasn't changed during fetch
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}
async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
let linkFormDirty = false;
async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = linksList.map(link => `
<tr>
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
<td>${escapeHtml(link.label || '')}</td>
<td>
<div class="action-buttons">
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
}
}
function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
dialog.close();
document.body.classList.remove('dialog-open');
}
async function saveLink(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}