Files
media-player-server/media_server/static/js/scripts.js
alexei.dolgolyov 3cfc437599 Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse
- Dialog modals: scale+fade entrance/exit with animated backdrop
- Tab panels: fade-in with subtle slide on switch
- Settings sections: content slide-down on expand
- Browser grid/list items: staggered cascade entrance animation
- Connection banner: slide-in + attention pulse on disconnect
- Accessibility: prefers-reduced-motion disables all animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:45:02 +03:00

538 lines
20 KiB
JavaScript

// ============================================================
// Scripts: CRUD, quick access, execution dialog
// ============================================================
let scriptFormDirty = false;
async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch('/api/scripts/list', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
scripts = await response.json();
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
let _quickAccessGen = 0;
async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
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);
}
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);
});
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;
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);
});
}
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
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);
}
grid.innerHTML = '';
grid.appendChild(fragment);
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
buttonElement.classList.add('executing');
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
buttonElement.classList.remove('executing');
}
}
// ============================================================
// Script Management CRUD
// ============================================================
let _loadScriptsPromise = null;
async function loadScriptsTable() {
if (_loadScriptsPromise) return _loadScriptsPromise;
_loadScriptsPromise = _loadScriptsTableImpl();
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
return _loadScriptsPromise;
}
async function _loadScriptsTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch scripts');
}
const scriptsList = await response.json();
if (scriptsList.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = scriptsList.map(script => `
<tr>
<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>
<td>${script.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
<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-script-name="${escapeHtml(script.name)}" title="Delete script">
<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 scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
}
}
function showAddScriptDialog() {
const dialog = document.getElementById('scriptDialog');
const form = document.getElementById('scriptForm');
const title = document.getElementById('dialogTitle');
form.reset();
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch script details');
}
const scriptsList = await response.json();
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
return;
}
document.getElementById('scriptOriginalName').value = scriptName;
document.getElementById('scriptIsEdit').value = 'true';
document.getElementById('scriptName').value = scriptName;
document.getElementById('scriptName').disabled = true;
document.getElementById('scriptLabel').value = script.label || '';
document.getElementById('scriptCommand').value = script.command || '';
document.getElementById('scriptDescription').value = script.description || '';
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
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');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
}
}
async function closeScriptDialog() {
if (scriptFormDirty) {
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
async function saveScript(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('scriptIsEdit').value === 'true';
const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value :
document.getElementById('scriptName').value;
const data = {
command: document.getElementById('scriptCommand').value,
label: document.getElementById('scriptLabel').value || null,
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
};
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
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(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false;
closeScriptDialog();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
}
} catch (error) {
console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteScriptConfirm(scriptName) {
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
} else {
showToast(result.detail || 'Failed to delete script', 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
}
}
// ============================================================
// Execution Result Dialog (shared by scripts and callbacks)
// ============================================================
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
function showExecutionResult(name, result, type = 'script') {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
const outputSection = document.getElementById('outputSection');
const errorSection = document.getElementById('errorSection');
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`;
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
outputSection.style.display = 'block';
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
if (result.stderr && result.stderr.trim()) {
errorSection.style.display = 'block';
errorPre.textContent = result.stderr;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else if (!success && result.error) {
errorSection.style.display = 'block';
errorPre.textContent = result.error;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else {
errorSection.style.display = 'none';
}
dialog.showModal();
}
async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok) {
showExecutionResult(scriptName, result, 'script');
} else {
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'script');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'script');
}
}
async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showExecutionResult(callbackName, result, 'callback');
} else {
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'callback');
}
} catch (error) {
console.error(`Error executing callback ${callbackName}:`, error);
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'callback');
}
}