mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
base.html shipped ~4,200 lines of inline JavaScript inside the HTML
document, re-downloaded and re-parsed on every page load (gzip helps the
transfer, but inline scripts can never be browser-cached). The four
largest blocks - none containing any Jinja syntax, verified by scanning
every inline block for {{ }} / {% %} - now live as static files served
with the app's existing mtime-versioned immutable caching:
- js/htmx-config.js (246 lines): HTMX swap/script-execution config,
toggleSection helpers
- js/app-early.js (346 lines): early helpers + the app() stub that must
precede Alpine init
- js/app-shell.js (2,997 lines): SSE wiring + the full Alpine app()
implementation and tab logic
- js/custom-feeds-helpers.js (262 lines): custom-feeds table helpers
Each replacement <script src> is CLASSIC (no defer/async) at the exact
position of the inline block it replaces - identical execution timing and
DOM visibility to inline scripts, so relative ordering with the deferred
scripts and with each other is unchanged. base.html drops from ~4,940 to
1,079 lines.
Validation: extraction proven lossless by programmatically reassembling
the four files back into the template and comparing against git HEAD -
byte-for-byte identical. Jinja parse passes; script open/close tags
balanced (53/53, after excluding a literal "<script>" inside an HTML
comment).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
263 lines
14 KiB
JavaScript
263 lines
14 KiB
JavaScript
// Custom feeds table helper functions
|
|
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
|
function addCustomFeedRow(fieldId, fullKey, maxItems, pluginId) {
|
|
const tbody = document.getElementById(fieldId + '_tbody');
|
|
if (!tbody) return;
|
|
|
|
const currentRows = tbody.querySelectorAll('.custom-feed-row');
|
|
if (currentRows.length >= maxItems) {
|
|
alert(`Maximum ${maxItems} feeds allowed`);
|
|
return;
|
|
}
|
|
|
|
const newIndex = currentRows.length;
|
|
const newRow = document.createElement('tr');
|
|
newRow.className = 'custom-feed-row';
|
|
newRow.setAttribute('data-index', newIndex);
|
|
|
|
// Create name cell
|
|
const nameCell = document.createElement('td');
|
|
nameCell.className = 'px-4 py-3 whitespace-nowrap';
|
|
const nameInput = document.createElement('input');
|
|
nameInput.type = 'text';
|
|
nameInput.name = `${fullKey}.${newIndex}.name`;
|
|
nameInput.value = '';
|
|
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
nameInput.placeholder = 'Feed Name';
|
|
nameInput.required = true;
|
|
nameCell.appendChild(nameInput);
|
|
|
|
// Create URL cell
|
|
const urlCell = document.createElement('td');
|
|
urlCell.className = 'px-4 py-3 whitespace-nowrap';
|
|
const urlInput = document.createElement('input');
|
|
urlInput.type = 'url';
|
|
urlInput.name = `${fullKey}.${newIndex}.url`;
|
|
urlInput.value = '';
|
|
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
urlInput.placeholder = 'https://example.com/feed';
|
|
urlInput.required = true;
|
|
urlCell.appendChild(urlInput);
|
|
|
|
// Create logo cell
|
|
const logoCell = document.createElement('td');
|
|
logoCell.className = 'px-4 py-3 whitespace-nowrap';
|
|
const logoContainer = document.createElement('div');
|
|
logoContainer.className = 'flex items-center space-x-2';
|
|
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.id = `${fieldId}_logo_${newIndex}`;
|
|
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
|
fileInput.style.display = 'none';
|
|
fileInput.dataset.index = String(newIndex);
|
|
// Use addEventListener with dataset index to allow reindexing
|
|
fileInput.addEventListener('change', function(e) {
|
|
const idx = parseInt(e.target.dataset.index || '0', 10);
|
|
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
|
|
});
|
|
|
|
const uploadButton = document.createElement('button');
|
|
uploadButton.type = 'button';
|
|
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
|
|
// Use fileInput directly instead of getElementById for reindexing compatibility
|
|
uploadButton.addEventListener('click', function() {
|
|
fileInput.click();
|
|
});
|
|
const uploadIcon = document.createElement('i');
|
|
uploadIcon.className = 'fas fa-upload mr-1';
|
|
uploadButton.appendChild(uploadIcon);
|
|
uploadButton.appendChild(document.createTextNode(' Upload'));
|
|
|
|
const noLogoSpan = document.createElement('span');
|
|
noLogoSpan.className = 'text-xs text-gray-400';
|
|
noLogoSpan.textContent = 'No logo';
|
|
|
|
logoContainer.appendChild(fileInput);
|
|
logoContainer.appendChild(uploadButton);
|
|
logoContainer.appendChild(noLogoSpan);
|
|
logoCell.appendChild(logoContainer);
|
|
|
|
// Create enabled cell
|
|
const enabledCell = document.createElement('td');
|
|
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
|
const enabledInput = document.createElement('input');
|
|
enabledInput.type = 'checkbox';
|
|
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
|
|
enabledInput.checked = true;
|
|
enabledInput.value = 'true';
|
|
enabledInput.className = 'h-4 w-4 text-blue-600';
|
|
enabledCell.appendChild(enabledInput);
|
|
|
|
// Create remove cell
|
|
const removeCell = document.createElement('td');
|
|
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
|
const removeButton = document.createElement('button');
|
|
removeButton.type = 'button';
|
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
|
// Use addEventListener instead of string-based onclick to prevent injection
|
|
removeButton.addEventListener('click', function() {
|
|
removeCustomFeedRow(this);
|
|
});
|
|
const removeIcon = document.createElement('i');
|
|
removeIcon.className = 'fas fa-trash';
|
|
removeButton.appendChild(removeIcon);
|
|
removeCell.appendChild(removeButton);
|
|
|
|
// Append all cells to row
|
|
newRow.appendChild(nameCell);
|
|
newRow.appendChild(urlCell);
|
|
newRow.appendChild(logoCell);
|
|
newRow.appendChild(enabledCell);
|
|
newRow.appendChild(removeCell);
|
|
tbody.appendChild(newRow);
|
|
}
|
|
|
|
function removeCustomFeedRow(button) {
|
|
const row = button.closest('tr');
|
|
if (!row) return;
|
|
|
|
if (confirm('Remove this feed?')) {
|
|
const tbody = row.parentElement;
|
|
if (!tbody) return;
|
|
|
|
row.remove();
|
|
|
|
// Re-index remaining rows
|
|
const rows = tbody.querySelectorAll('.custom-feed-row');
|
|
rows.forEach((r, index) => {
|
|
const oldIndex = r.getAttribute('data-index');
|
|
r.setAttribute('data-index', index);
|
|
// Update all input names with new index
|
|
r.querySelectorAll('input, button').forEach(input => {
|
|
const name = input.getAttribute('name');
|
|
if (name) {
|
|
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
|
|
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
|
|
}
|
|
const id = input.id;
|
|
if (id) {
|
|
// Keep IDs aligned after reindex (supports both _logo_<n> and _logo_preview_<n>)
|
|
input.id = id
|
|
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
|
|
.replace(/_logo_\d+$/, `_logo_${index}`);
|
|
}
|
|
// Keep dataset index aligned so event handlers remain correct after reindex
|
|
if (input.dataset && 'index' in input.dataset) {
|
|
input.dataset.index = String(index);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('plugin_id', pluginId);
|
|
|
|
fetch('/api/v3/plugins/assets/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(response => {
|
|
// Check HTTP status before parsing JSON
|
|
if (!response.ok) {
|
|
return response.text().then(text => {
|
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
|
|
const uploadedFile = data.data.files[0];
|
|
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
|
if (row) {
|
|
const logoCell = row.querySelector('td:nth-child(3)');
|
|
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
|
|
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
|
|
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
|
|
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
|
|
|
|
// Normalize path: remove leading slashes, then add single leading slash
|
|
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
|
|
const imageSrc = '/' + normalizedPath;
|
|
|
|
// Clear logoCell and build DOM safely to prevent XSS
|
|
logoCell.textContent = ''; // Clear existing content
|
|
|
|
// Create container div
|
|
const container = document.createElement('div');
|
|
container.className = 'flex items-center space-x-2';
|
|
|
|
// Create file input
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.id = `${fieldId}_logo_${index}`;
|
|
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
|
fileInput.style.display = 'none';
|
|
fileInput.dataset.index = String(index);
|
|
// Use addEventListener with dataset index to allow reindexing
|
|
fileInput.addEventListener('change', function(e) {
|
|
const idx = parseInt(e.target.dataset.index || '0', 10);
|
|
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
|
|
});
|
|
|
|
// Create upload button
|
|
const uploadButton = document.createElement('button');
|
|
uploadButton.type = 'button';
|
|
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
|
|
// Use fileInput directly instead of getElementById for reindexing compatibility
|
|
uploadButton.addEventListener('click', function() {
|
|
fileInput.click();
|
|
});
|
|
const uploadIcon = document.createElement('i');
|
|
uploadIcon.className = 'fas fa-upload mr-1';
|
|
uploadButton.appendChild(uploadIcon);
|
|
uploadButton.appendChild(document.createTextNode(' Upload'));
|
|
|
|
// Create img element - use normalized path, set src via property to prevent XSS
|
|
const img = document.createElement('img');
|
|
img.src = imageSrc; // Use property assignment with normalized path
|
|
img.alt = 'Logo';
|
|
img.className = 'w-8 h-8 object-cover rounded border';
|
|
img.id = `${fieldId}_logo_preview_${index}`;
|
|
|
|
// Create hidden input for path - set value via property to prevent XSS
|
|
const pathInput = document.createElement('input');
|
|
pathInput.type = 'hidden';
|
|
pathInput.name = pathName;
|
|
pathInput.value = imageSrc;
|
|
|
|
// Create hidden input for id - set value via property to prevent XSS
|
|
const idInput = document.createElement('input');
|
|
idInput.type = 'hidden';
|
|
idInput.name = idName;
|
|
idInput.value = String(uploadedFile.id); // Ensure it's a string
|
|
|
|
// Append all elements to container
|
|
container.appendChild(fileInput);
|
|
container.appendChild(uploadButton);
|
|
container.appendChild(img);
|
|
container.appendChild(pathInput);
|
|
container.appendChild(idInput);
|
|
|
|
// Append container to logoCell
|
|
logoCell.appendChild(container);
|
|
}
|
|
// Allow re-uploading the same file (change event won't fire otherwise)
|
|
event.target.value = '';
|
|
} else {
|
|
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Upload error:', error);
|
|
alert('Upload failed: ' + error.message);
|
|
});
|
|
}
|