- 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>
538 lines
20 KiB
JavaScript
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');
|
|
}
|
|
}
|