908 lines
41 KiB
HTML
908 lines
41 KiB
HTML
<!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 · 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 & 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 · SVG/vector stays sharp at any zoom. No video on Maps — 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||
|
|
|