Files

908 lines
41 KiB
HTML
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content Manager - Bible App</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 20px;
/* Extra bottom padding so the last section can be scrolled all the way to the top
of the viewport on tall vertical touchscreens (no reaching to the bottom edge). */
padding-bottom: 100vh;
line-height: 1.6;
}
/* Belt-and-suspenders spacer: guarantees scroll-past distance even if body padding
is collapsed by any container in unusual viewport / zoom conditions. */
.bottom-scroll-spacer {
display: block;
width: 100%;
height: 100vh;
min-height: 900px;
pointer-events: none;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #fff;
margin-bottom: 30px;
font-size: 2em;
}
.section {
background: #2a2a2a;
border-radius: 8px;
padding: 25px;
margin-bottom: 30px;
border: 1px solid #3a3a3a;
}
.section h2 {
color: #fff;
margin-bottom: 20px;
font-size: 1.5em;
border-bottom: 2px solid #4a4a4a;
padding-bottom: 10px;
}
.upload-area {
border: 2px dashed #4a4a4a;
border-radius: 8px;
padding: 30px;
text-align: center;
margin-bottom: 20px;
background: #1f1f1f;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #6a6a6a;
background: #252525;
}
.upload-area.dragover {
border-color: #0090ff;
background: #1a2a3a;
}
.upload-area input[type="file"] {
display: none;
}
.upload-btn {
background: #0090ff;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: background 0.3s;
}
.upload-btn:hover {
background: #0070cc;
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.file-item {
background: #1f1f1f;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 15px;
position: relative;
transition: all 0.3s;
}
.file-item:hover {
border-color: #5a5a5a;
transform: translateY(-2px);
}
.file-item.active {
border-color: #0090ff;
background: #1a2a3a;
}
.file-item.active::before {
content: "✓ ACTIVE";
position: absolute;
top: 5px;
right: 5px;
background: #0090ff;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
}
.file-item.dragging {
opacity: 0.5;
transform: scale(1.05);
z-index: 1000;
border-color: #0090ff;
box-shadow: 0 4px 12px rgba(0, 144, 255, 0.4);
}
.file-item img {
width: 100%;
height: 150px;
object-fit: contain;
background: #000;
border-radius: 4px;
margin-bottom: 10px;
}
/* Vector art is usually dark paint on a TRANSPARENT background, so against the black
thumbnail tile it looks like an empty black square (the file is fine — there is just
nothing to see). Give SVG thumbnails white paper, the way a map or diagram is meant to be
viewed. Substring match so ?v= cache-busters still hit. */
.file-item img[src*=".svg"] {
background: #ffffff;
}
.file-item .file-name {
font-size: 14px;
color: #e0e0e0;
word-break: break-word;
margin-bottom: 10px;
}
.file-item .file-actions {
display: flex;
gap: 5px;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.btn-set-active {
background: #0090ff;
color: white;
}
.btn-set-active:hover {
background: #0070cc;
}
.btn-delete {
background: #cc3333;
color: white;
}
.btn-delete:hover {
background: #aa2222;
}
.status-message {
padding: 12px;
border-radius: 6px;
margin-bottom: 15px;
display: none;
}
.status-message.success {
background: #1a4a2a;
color: #90ee90;
border: 1px solid #2a6a3a;
display: block;
}
.status-message.error {
background: #4a1a1a;
color: #ee9090;
border: 1px solid #6a2a2a;
display: block;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #0090ff;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
/* Sticky top nav so Content Manager <-> Settings is one tap from anywhere on the page —
these two get switched between constantly while loading content for a service.
z-index sits below the page's own overlays (1000) so it can never cover a dialog.
The box-shadow spreads the bar's own background 20px sideways to cover the body's
padding gutters, and 20px down into its own margin, so content never shows through. */
.page-nav {
position: sticky;
top: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #1a1a1a;
border-bottom: 1px solid #3a3a3a;
box-shadow: 0 0 0 20px #1a1a1a;
padding: 12px 0;
margin-bottom: 20px;
}
.page-nav .back-link { margin-bottom: 0; }
.nav-switch {
display: inline-block;
text-decoration: none;
background: #0090ff;
color: #fff;
font-size: 15px;
font-weight: 600;
padding: 10px 18px;
border-radius: 6px;
white-space: nowrap;
transition: background 0.2s;
}
.nav-switch:hover { background: #33a6ff; }
.nav-switch:active { background: #0074cc; }
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #4a4a4a;
border-top-color: #0090ff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-left: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="page-nav">
<a href="/" class="back-link" id="backToAppLink">← Back to App</a>
<a href="/settings.html" class="nav-switch">Settings →</a>
</div>
<h1>Content Manager</h1>
<div id="statusMessage" class="status-message"></div>
<!-- Splash Screens Section -->
<div class="section">
<h2>Splash Screens</h2>
<div class="upload-area" id="splashUploadArea">
<p>Click or drag images here to upload</p>
<p style="font-size: 12px; color: #888; margin-top: 10px;">Supported: PNG, JPG, JPEG, GIF, BMP, WEBP</p>
<input type="file" id="splashFileInput" accept="image/*" multiple>
</div>
<div class="file-list" id="splashFileList">
<div style="color: #888; grid-column: 1/-1;">Loading...</div>
</div>
</div>
<!-- Announcements Section -->
<div class="section">
<h2>Announcements</h2>
<div class="upload-area" id="announcementsUploadArea">
<p>Click or drag images here to upload</p>
<p style="font-size: 12px; color: #888; margin-top: 10px;">Supported: PNG, JPG, JPEG, GIF, BMP, WEBP</p>
<input type="file" id="announcementsFileInput" accept="image/*" multiple>
</div>
<div class="file-list" id="announcementsFileList">
<div style="color: #888; grid-column: 1/-1;">Loading...</div>
</div>
</div>
<!-- Graphics Section -->
<div class="section">
<h2>Graphics</h2>
<div class="upload-area" id="graphicsUploadArea">
<p>Click or drag images or videos here to upload</p>
<p style="font-size: 12px; color: #888; margin-top: 10px;">Images: PNG, JPG, JPEG, GIF, BMP, WEBP, SVG, AVIF &nbsp;·&nbsp; Videos: MP4, M4V, WEBM, MOV (tap to play/pause in the app; set Loop below)</p>
<input type="file" id="graphicsFileInput" accept="image/*,video/mp4,video/webm,video/quicktime,.mp4,.m4v,.webm,.mov" multiple>
</div>
<div class="file-list" id="graphicsFileList">
<div style="color: #888; grid-column: 1/-1;">Loading...</div>
</div>
</div>
<!-- Maps & Timelines Section -->
<div class="section">
<h2>Maps &amp; Timelines</h2>
<div class="upload-area" id="mapsUploadArea">
<p>Click or drag images here to upload</p>
<p style="font-size: 12px; color: #888; margin-top: 10px;">Supported: PNG, JPG, JPEG, GIF, BMP, WEBP, SVG, AVIF &nbsp;·&nbsp; SVG/vector stays sharp at any zoom. No video on Maps &mdash; use Graphics for video.</p>
<input type="file" id="mapsFileInput" accept="image/png,image/jpeg,image/gif,image/bmp,image/webp,image/svg+xml,image/avif,.png,.jpg,.jpeg,.jfif,.gif,.bmp,.webp,.svg,.avif" multiple>
</div>
<div class="file-list" id="mapsFileList">
<div style="color: #888; grid-column: 1/-1;">Loading...</div>
</div>
</div>
<!-- Notes Section -->
<div class="section">
<h2>Notes (PDFs or Images)</h2>
<div class="upload-area" id="notesUploadArea">
<p>Click or drag PDF or image files here to upload</p>
<p style="font-size: 12px; color: #888; margin-top: 10px;">Supported: PDF, PNG, JPG, JPEG, GIF, BMP, WEBP</p>
<input type="file" id="notesFileInput" accept=".pdf,image/*" multiple>
</div>
<div class="file-list" id="notesFileList">
<div style="color: #888; grid-column: 1/-1;">Loading...</div>
</div>
</div>
<!-- Scroll-past spacer so the last section can be reached at the top of vertical touchscreens. -->
<div class="bottom-scroll-spacer" aria-hidden="true"></div>
</div>
<script>
const statusMessage = document.getElementById('statusMessage');
// Per-section latch for the "only one file -> make it active" shortcut below. setActive()
// calls loadFiles() again, so without this latch the two would call each other forever.
const __autoActivated = new Set();
function showStatus(message, isError = false) {
statusMessage.textContent = message;
statusMessage.className = 'status-message ' + (isError ? 'error' : 'success');
setTimeout(() => {
statusMessage.className = 'status-message';
}, 5000);
}
async function uploadFile(type, file) {
try {
// Read file as array buffer
const arrayBuffer = await file.arrayBuffer();
const blob = new Blob([arrayBuffer]);
// Use the same pattern as notes/upload - send raw binary with x-filename header
const response = await fetch(`/api/${type}/upload`, {
method: 'POST',
headers: {
'x-filename': file.name
},
body: blob
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Upload failed');
}
// Use the actual saved filename from the server response
const savedFilename = result.file || file.name;
console.log(`[CONTENT MANAGER] Upload successful:`, result);
showStatus(`✓ ${savedFilename} uploaded successfully!`);
// Reload files list to show the new file (with delay to ensure file is written and list API can see it)
await new Promise(resolve => setTimeout(resolve, 300));
await loadFiles(type);
return result;
} catch (error) {
showStatus(`Failed to upload ${file.name}: ${error.message}`, true);
throw error;
}
}
async function setActive(type, filename) {
try {
// Verify file exists first
const listResp = await fetch(`/api/${type}/list?t=${Date.now()}`, { cache: 'no-store' });
if (listResp.ok) {
const listData = await listResp.json();
const files = listData.files || listData.images || [];
const fileExists = files.some(f => f.name === filename);
if (!fileExists) {
showStatus(`Error: File "${filename}" not found. Please refresh the page.`, true);
loadFiles(type);
return;
}
}
const response = await fetch(`/api/${type}/set-default`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to set active');
}
// Verify the default was set correctly
await new Promise(resolve => setTimeout(resolve, 200));
const verifyResp = await fetch(`/api/${type}/list?t=${Date.now()}`, { cache: 'no-store' });
let verifiedFilename = result.filename || filename;
if (verifyResp.ok) {
const verifyData = await verifyResp.json();
verifiedFilename = verifyData.currentDefault || verifiedFilename;
if (verifyData.currentDefault === filename || verifyData.currentDefault === result.filename) {
showStatus(`✓ ${verifiedFilename} is now active! The ${type === 'announcements' ? 'Announcements' : type === 'graphics' ? 'Graphics' : type === 'maps' ? 'Maps & Timelines' : type === 'notes' ? 'Notes' : 'Splash'} will update automatically.`);
} else {
showStatus(`Warning: Default may not have been set correctly. Please try again.`, true);
}
}
// Reload files list to show updated active state
await loadFiles(type);
// Notify the main app window if it's open (for splash and announcements)
// Use a longer delay to ensure server has written the config file
setTimeout(() => {
try {
// Try to send message to parent window
if (window.opener && !window.opener.closed) {
// Send content-update message for all content types
window.opener.postMessage({ type: 'content-update', contentType: type, filename: verifiedFilename }, '*');
}
// Also try to send to all windows (for same-origin)
window.postMessage({ type: 'content-update', contentType: type, filename: verifiedFilename }, '*');
} catch (e) {
console.error('[CONTENT MANAGER] Failed to send update message:', e);
}
}, 300);
} catch (error) {
showStatus(`Failed to set active: ${error.message}`, true);
}
}
async function deleteFile(type, filename) {
if (!confirm(`Are you sure you want to delete ${filename}?`)) {
return;
}
try {
const response = await fetch(`/api/${type}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Delete failed');
}
showStatus(`${filename} deleted successfully!`);
loadFiles(type);
} catch (error) {
showStatus(`Failed to delete: ${error.message}`, true);
}
}
async function saveOrderedImages(type, order) {
try {
const resp = await fetch(`/api/${type}/set-order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to save order');
showStatus(`${type === 'graphics' ? 'Graphics' : type === 'maps' ? 'Maps & Timelines' : 'Announcement'} order saved.`);
} catch (e) {
showStatus(`Failed to save order: ${e.message}`, true);
}
}
function makeImageListDraggable(listElement, type) {
const items = Array.from(listElement.querySelectorAll('.file-item'));
items.forEach(item => item.setAttribute('draggable', 'true'));
let dragSrc = null;
let touchSrc = null;
let touchStartY = null;
let touchStartX = null;
let touchOriginalY = null; // Original Y position for transform calculation
let touchDragStarted = false; // Track if drag has started
// Mouse drag handlers
const onDragStart = (e) => {
dragSrc = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragSrc.dataset.name);
dragSrc.classList.add('dragging');
};
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.currentTarget;
if (!dragSrc || dragSrc === target) return;
const items = Array.from(listElement.querySelectorAll('.file-item'));
const srcIdx = items.indexOf(dragSrc);
const tgtIdx = items.indexOf(target);
if (srcIdx < 0 || tgtIdx < 0) return;
if (srcIdx < tgtIdx) {
listElement.insertBefore(dragSrc, target.nextSibling);
} else {
listElement.insertBefore(dragSrc, target);
}
};
const onDragEnd = () => {
items.forEach(i => i.classList.remove('dragging'));
const newOrder = Array.from(listElement.querySelectorAll('.file-item')).map(i => i.dataset.name);
if (newOrder.length) saveOrderedImages(type, newOrder);
dragSrc = null;
};
// Touch drag handlers
const onTouchStart = (e) => {
// Only handle single touch
if (e.touches.length !== 1) return;
touchSrc = e.currentTarget;
const touch = e.touches[0];
touchStartY = touch.clientY;
touchStartX = touch.clientX;
touchOriginalY = touch.clientY; // Store original for transform
touchDragStarted = false; // Reset drag state
// Don't add dragging class yet - wait until movement threshold is met
};
const onTouchMove = (e) => {
if (!touchSrc || e.touches.length !== 1) return;
const touch = e.touches[0];
const currentY = touch.clientY;
const currentX = touch.clientX;
// Calculate movement
const deltaY = currentY - touchStartY;
const deltaX = currentX - touchStartX;
// Check if we've moved enough to start dragging (to distinguish from taps)
if (!touchDragStarted) {
if (Math.abs(deltaY) < 10 && Math.abs(deltaX) < 10) return;
// Start dragging
touchDragStarted = true;
touchSrc.classList.add('dragging');
}
e.preventDefault(); // Prevent scrolling
// Calculate transform relative to original touch position
const transformY = currentY - touchOriginalY;
touchSrc.style.transform = `translateY(${transformY}px) scale(1.05)`;
// Find the element under the touch point
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
if (!elementBelow) return;
// Find the closest file-item (could be the element itself or a child)
let target = elementBelow.closest('.file-item');
if (!target || target === touchSrc || !listElement.contains(target)) return;
// Reorder items
const items = Array.from(listElement.querySelectorAll('.file-item'));
const srcIdx = items.indexOf(touchSrc);
const tgtIdx = items.indexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx === tgtIdx) return;
// Perform reorder
if (srcIdx < tgtIdx) {
listElement.insertBefore(touchSrc, target.nextSibling);
} else {
listElement.insertBefore(touchSrc, target);
}
// Update touchStartY to current position for next reorder check
touchStartY = currentY;
};
const onTouchEnd = () => {
if (!touchSrc) return;
// Only save order if drag actually started
if (touchDragStarted) {
// Reset transform
touchSrc.style.transform = '';
touchSrc.classList.remove('dragging');
// Save new order
const newOrder = Array.from(listElement.querySelectorAll('.file-item')).map(i => i.dataset.name);
if (newOrder.length) saveOrderedImages(type, newOrder);
}
// Reset touch state
touchSrc = null;
touchStartY = null;
touchStartX = null;
touchOriginalY = null;
touchDragStarted = false;
};
// Add mouse event listeners
items.forEach(item => {
item.addEventListener('dragstart', onDragStart);
item.addEventListener('dragover', onDragOver);
item.addEventListener('dragend', onDragEnd);
// Add touch event listeners
item.addEventListener('touchstart', onTouchStart, { passive: false });
item.addEventListener('touchmove', onTouchMove, { passive: false });
item.addEventListener('touchend', onTouchEnd);
item.addEventListener('touchcancel', onTouchEnd);
});
}
async function loadFiles(type) {
const listElement = document.getElementById(`${type}FileList`);
listElement.innerHTML = '<div style="color: #888; grid-column: 1/-1;">Loading...</div>';
try {
const response = await fetch(`/api/${type}/list?t=${Date.now()}`, { cache: 'no-store' });
const result = await response.json();
console.log(`[CONTENT MANAGER] Loaded ${type} files:`, result);
if (!response.ok) {
throw new Error(result.error || 'Failed to load files');
}
const files = result.files || result.images || [];
const currentDefault = result.currentDefault || '';
const usesOrderOnly = type === 'announcements' || type === 'graphics' || type === 'maps';
if (files.length === 0) {
listElement.innerHTML = '<div style="color: #888; grid-column: 1/-1;">No files found. Upload some files to get started.</div>';
return;
}
/*
* One file in the section? Make it the active one automatically — one less tap while
* setting up before a service. Only fires when there is NO valid default already, so
* a deliberate choice is never silently retargeted, and only once per section per
* page load (setActive re-enters loadFiles).
*/
if (!usesOrderOnly && files.length === 1 && !__autoActivated.has(type)) {
const defaultStillThere = currentDefault && files.some(f => f.name === currentDefault);
if (!defaultStillThere) {
__autoActivated.add(type);
await setActive(type, files[0].name);
return;
}
}
// Escape all interpolated values; pass file name via data-* + event listeners
// (not inline onclick) so names with quotes/markup can't break buttons or inject HTML.
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
listElement.innerHTML = files.map(file => {
const isActive = !usesOrderOnly && (file.name === currentDefault || file.url === currentDefault);
const isVideoFile = /\.(mp4|m4v|webm|mov)$/i.test(file.name);
const isImage = !isVideoFile && (type === 'splash' || type === 'announcements' || type === 'graphics' || type === 'maps' || (type === 'notes' && /\.(png|jpe?g|gif|bmp|webp)$/i.test(file.name)));
// Ensure URL has proper cache busting for thumbnails
const thumbnailUrl = file.url ? (file.url + (file.url.includes('?') ? '&' : '?') + `t=${Date.now()}`) : '';
const safeName = esc(file.name);
const safeType = esc(type);
return `
<div class="file-item ${isActive ? 'active' : ''}" data-name="${safeName}">
${isVideoFile ? `<video src="${esc(thumbnailUrl)}" muted preload="metadata" style="width:100%; border-radius:6px; pointer-events:none;"></video>` : isImage ? `<img src="${esc(thumbnailUrl)}" alt="${safeName}" onerror="this.style.display='none';">` : ''}
<div class="file-name">${safeName}</div>
<div class="file-actions">
${!usesOrderOnly && !isActive ? `<button class="btn btn-set-active" data-type="${safeType}" data-name="${safeName}">Set Active</button>` : ''}
${(type === 'graphics' && isVideoFile) ? `<button class="btn btn-loop" data-name="${safeName}" data-loop="${file.loop ? '1' : '0'}">${file.loop ? 'Loop: ON' : 'Loop: OFF'}</button>` : ''}
<button class="btn btn-delete" data-type="${safeType}" data-name="${safeName}">Delete</button>
</div>
</div>
`;
}).join('');
listElement.querySelectorAll('.btn-set-active').forEach(btn => btn.addEventListener('click', () => setActive(btn.dataset.type, btn.dataset.name)));
listElement.querySelectorAll('.btn-delete').forEach(btn => btn.addEventListener('click', () => deleteFile(btn.dataset.type, btn.dataset.name)));
// Per-video loop toggle (graphics only)
listElement.querySelectorAll('.btn-loop').forEach(btn => btn.addEventListener('click', async () => {
const newLoop = btn.dataset.loop !== '1';
try {
const r = await fetch('/api/graphics/video-loop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.name, loop: newLoop }) });
const j = await r.json();
if (j && j.ok) { showStatus(`${btn.dataset.name}: loop ${newLoop ? 'ON' : 'OFF'}`); loadFiles('graphics'); }
else showStatus((j && j.error) || 'Could not save loop setting', true);
} catch (e) { showStatus('Error: ' + e.message, true); }
}));
if (type === 'announcements' || type === 'graphics' || type === 'maps') {
makeImageListDraggable(listElement, type);
}
} catch (error) {
listElement.innerHTML = `<div style="color: #cc3333; grid-column: 1/-1;">Error loading files: ${error.message}</div>`;
}
}
// Setup upload areas
['splash', 'announcements', 'graphics', 'maps', 'notes'].forEach(type => {
const uploadArea = document.getElementById(`${type}UploadArea`);
const fileInput = document.getElementById(`${type}FileInput`);
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', async (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
const isNotes = type === 'notes';
const isPdf = file.name.toLowerCase().endsWith('.pdf');
// Drag-dropped files sometimes arrive with an empty type, and the Windows picker
// is inconsistent about image/svg+xml — so fall back to the extension.
const isImg = file.type.startsWith('image/')
|| /\.(png|jpe?g|jfif|gif|bmp|webp|svg|avif|apng|ico)$/i.test(file.name);
const isVid = file.type.startsWith('video/') || /\.(mp4|m4v|webm|mov)$/i.test(file.name);
if (isNotes && !(isPdf || isImg)) {
showStatus(`${file.name} is not a PDF or image file`, true);
continue;
}
if ((type === 'splash' || type === 'announcements' || type === 'maps') && !isImg) {
showStatus(`${file.name} is not an image file`, true);
continue;
}
if (type === 'graphics' && !(isImg || isVid)) {
showStatus(`${file.name} is not an image or video file`, true);
continue;
}
await uploadFile(type, file);
}
});
fileInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const isNotes = type === 'notes';
const isPdf = file.name.toLowerCase().endsWith('.pdf');
// Drag-dropped files sometimes arrive with an empty type, and the Windows picker
// is inconsistent about image/svg+xml — so fall back to the extension.
const isImg = file.type.startsWith('image/')
|| /\.(png|jpe?g|jfif|gif|bmp|webp|svg|avif|apng|ico)$/i.test(file.name);
const isVid = file.type.startsWith('video/') || /\.(mp4|m4v|webm|mov)$/i.test(file.name);
if (isNotes && !(isPdf || isImg)) {
showStatus(`${file.name} is not a PDF or image file`, true);
continue;
}
if ((type === 'splash' || type === 'announcements' || type === 'maps') && !isImg) {
showStatus(`${file.name} is not an image file`, true);
continue;
}
if (type === 'graphics' && !(isImg || isVid)) {
showStatus(`${file.name} is not an image or video file`, true);
continue;
}
await uploadFile(type, file);
}
fileInput.value = '';
});
});
// Load files on page load
['splash', 'announcements', 'graphics', 'maps', 'notes'].forEach(type => {
loadFiles(type);
});
// Make functions available globally for onclick handlers
window.setActive = setActive;
window.deleteFile = deleteFile;
// Robust "Back to App" function that always works
function goBackToApp() {
try {
// Use replace to prevent back button issues and ensure it doesn't open new window
window.location.replace('/');
} catch (e) {
// Fallback if replace fails
try {
window.location.href = '/';
} catch (e2) {
// Last resort
window.top.location.href = '/';
}
}
}
// Set up back link handler (works for mouse, touch, and keyboard)
// Use a function that runs when DOM is ready
function setupBackLink() {
const backLink = document.getElementById('backToAppLink');
if (backLink) {
// Remove any existing handlers to prevent duplicates
const newBackLink = backLink.cloneNode(true);
backLink.parentNode.replaceChild(newBackLink, backLink);
// Prevent default anchor behavior
newBackLink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
goBackToApp();
return false;
}, { passive: false, capture: true });
// Touch events for touchscreen TVs
newBackLink.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
goBackToApp();
return false;
}, { passive: false, capture: true });
newBackLink.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
goBackToApp();
return false;
}
}, { passive: false, capture: true });
// Also prevent default href behavior
newBackLink.setAttribute('href', 'javascript:void(0)');
}
}
/*
* Content Manager <-> Settings shortcut. Wired the same way as the back link rather than
* left as a plain anchor: on the touchscreen TV a bare <a> tap is unreliable, which is why
* the back link carries explicit touchstart/pointerdown handlers too.
*/
function setupNavSwitch() {
const link = document.querySelector('.nav-switch');
if (!link) return;
const go = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
try { window.location.replace('/settings.html'); }
catch (_) { window.location.href = '/settings.html'; }
return false;
};
link.addEventListener('click', go, { passive: false, capture: true });
link.addEventListener('touchstart', go, { passive: false, capture: true });
link.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') return go(e);
}, { passive: false, capture: true });
link.setAttribute('href', 'javascript:void(0)');
}
// Set up immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupBackLink);
document.addEventListener('DOMContentLoaded', setupNavSwitch);
} else {
setupBackLink();
setupNavSwitch();
}
// Add ESC key support to go back to the app
// CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug
document.addEventListener('keydown', (e) => {
try {
// Defensive check: ensure event object is valid before accessing properties
if (!e || typeof e !== 'object' || typeof e.key !== 'string') return;
if (e.key === 'Escape' || e.key === 'Esc') {
// Don't trigger if user is typing in an input field
if (e.target && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
e.stopPropagation();
goBackToApp();
}
}
} catch (err) {
// Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect)
console.warn('[KEYBOARD] Content manager key handler error (likely keyboard disconnect):', err);
}
}, { capture: true });
</script>
</body>
</html>