Files
qwen-image/tour-comfy/output/car.html
mike 1dead1c666 aa
2026-06-18 00:06:15 +02:00

931 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Image Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}
.header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(15, 15, 15, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #333;
padding: 16px 24px;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #888;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #22c55e;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-dot.updating {
background: #f59e0b;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
background: #1a1a1a;
border: 1px solid #333;
color: #ccc;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
background: #252525;
border-color: #444;
color: #fff;
}
.btn.primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.btn.primary:hover {
background: #3b82f6;
}
.prompt-bar {
display: flex;
gap: 8px;
align-items: center;
}
.prompt-input {
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
width: 320px;
outline: none;
transition: border-color 0.2s;
}
.prompt-input:focus {
border-color: #2563eb;
}
.prompt-input::placeholder {
color: #555;
}
.count {
font-size: 13px;
color: #666;
font-variant-numeric: tabular-nums;
}
.gallery {
padding: 100px 24px 40px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
max-width: 1800px;
margin: 0 auto;
}
.image-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
position: relative;
}
.image-card:hover {
transform: translateY(-2px);
border-color: #444;
}
.image-card.new {
border-color: #2563eb;
animation: highlight 1s ease;
}
@keyframes highlight {
from { box-shadow: 0 0 0 2px #2563eb; }
to { box-shadow: none; }
}
.image-wrapper {
position: relative;
padding-top: 300%; /* 1:3 aspect ratio (3x taller than wide) */
background: #111;
overflow: hidden;
}
.image-wrapper img {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.image-card:hover .image-wrapper img {
transform: scale(1.03);
}
.image-info {
padding: 14px 16px;
}
.image-name {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.image-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
}
.image-time {
display: flex;
align-items: center;
gap: 4px;
}
.badge {
background: #2563eb;
color: #fff;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.recent {
background: #22c55e;
}
.empty-state {
text-align: center;
padding: 120px 20px;
color: #555;
}
.empty-state svg {
width: 64px; height: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state h2 {
font-size: 20px;
color: #777;
margin-bottom: 8px;
}
.empty-state p {
font-size: 14px;
}
.toast {
position: fixed;
bottom: 24px; right: 24px;
background: #1a1a1a;
border: 1px solid #333;
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
color: #ccc;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 200;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success { border-left: 3px solid #22c55e; }
.toast.info { border-left: 3px solid #2563eb; }
.btn.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.image-card.selectable {
cursor: pointer;
user-select: none;
}
.image-card.selected {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.35);
}
.select-indicator {
position: absolute;
top: 8px; right: 8px;
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.5);
background: rgba(0,0,0,0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 10;
font-size: 13px;
font-weight: 700;
color: #fff;
transition: background 0.15s, border-color 0.15s;
}
.selectable .select-indicator { display: flex; }
.selected .select-indicator {
background: #2563eb;
border-color: #2563eb;
}
.selected .select-indicator::after { content: '✓'; }
.batch-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: rgba(12,12,12,0.97);
backdrop-filter: blur(12px);
border-top: 1px solid #2a2a2a;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
z-index: 100;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.batch-bar.visible { transform: translateY(0); }
.batch-count {
font-size: 13px;
color: #888;
min-width: 80px;
white-space: nowrap;
}
.batch-prompt-wrap { flex: 1; position: relative; }
.batch-prompt {
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 7px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.batch-prompt:focus { border-color: #2563eb; }
.batch-prompt::placeholder { color: #555; }
.batch-progress {
font-size: 12px;
color: #666;
min-width: 90px;
text-align: right;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="header">
<h1>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
Live Image Monitor
</h1>
<div class="prompt-bar">
<input class="prompt-input" id="promptInput" type="text" placeholder="prompt..." onkeydown="if(event.key==='Enter')setPrompt()" />
<button class="btn primary" onclick="setPrompt()">Set Prompt</button>
</div>
<div class="controls">
<span class="count" id="count">0 images</span>
<button class="btn" onclick="setIntervalTime(30)">30s</button>
<button class="btn" onclick="setIntervalTime(120)">2m</button>
<button class="btn primary" onclick="refreshNow()">Refresh Now</button>
<button class="btn" id="selectBtn" onclick="toggleSelectMode()">Select</button>
</div>
<div class="status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Auto-refresh: 2m</span>
</div>
</div>
<div class="gallery" id="gallery">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place this HTML file in your image folder, or configure the path below.</p>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="batch-bar" id="batchBar">
<span class="batch-count" id="batchCount">0 selected</span>
<div class="batch-prompt-wrap">
<input class="batch-prompt" id="batchPromptInput" list="promptHistory"
placeholder="temporal prompt..."
onkeydown="if(event.key==='Enter')processSelected()" />
<datalist id="promptHistory"></datalist>
</div>
<button class="btn" onclick="selectAll()">All</button>
<button class="btn" onclick="deselectAll()">None</button>
<button class="btn primary" id="processBtn" onclick="processSelected()">Process</button>
<span class="batch-progress" id="batchProgress"></span>
<button class="btn" onclick="toggleSelectMode()">Done</button>
</div>
<script>
// ============================================
// CONFIGURATION - EDIT THIS SECTION
// ============================================
// Set this to your image folder path relative to this HTML file
// Examples: './' (same folder), './images/', '../screenshots/'
const IMAGE_FOLDER = './';
// --- HYDRATION_START ---
const PRELOADED_IMAGES = [
"20260617_215245_img_87.png",
"20260617_134615_img_86.png",
"20260617_134229_img_83.png",
"20260617_134119_img_85.png",
"20260617_134041_img_84.png",
"20260617_133917_img_82.png",
"20260617_133832_img_81.png",
"20260617_133519_img_80.png",
"20260617_133411_img_79.png",
"20260617_133154_img_78.png",
"20260617_133129_img_77.png",
"20260617_132851_img_76.png",
"20260617_074613_img_75.png",
"20260617_074556_img_74.png",
"20260617_074413_img_73.png",
"20260617_074223_img_72.png",
"20260617_015946_img_71.png",
"20260617_015728_img_70.png",
"20260617_015611_img_69.png",
"20260617_015007_img_68.png",
"20260617_014351_img_66.png",
"20260617_013327_img_67.png",
"20260617_013231_img_68.png",
"20260617_013211_img_65.png",
"20260617_013150_img_66.png",
"20260617_013111_img_63.png",
"20260617_013035_img_64.png",
"20260617_012709_img_62.png",
"20260617_011132_img_61.png",
"20260617_005512_img_60.png",
"20260617_005200_img_59.png",
"20260617_005040_img_56.png",
"20260617_005026_img_55.png",
"20260617_005008_img_54.png",
"20260617_004942_img_53.png",
"20260617_004814_img_57.png",
"20260616_023306_img_52.png",
"20260616_023209_img_51.png",
"20260616_022543_img_50.png",
"20260616_022349_img_48.png",
"20260616_021938_20160903_200935.jpg",
"20260616_021235_img_47.png",
"20260616_021214_img_46.png",
"20260616_021150_img_44.png",
"20260616_021116_img_43.png",
"20260616_021056_img_45.png",
"20260616_021003_img_41.png",
"20260616_020908_img_40.png",
"20260616_020403_img_39.png",
"20260616_020059_img_38.png",
"20260616_020035_img_36.png",
"20260616_020020_img_35.png",
"20260616_015949_img_37.png",
"20260616_015919_img_33.png",
"20260616_015850_img_34.png",
"20260616_015341_img_32.png",
"20260616_014757_img_31.png",
"20260616_014225_img_30.png",
"20260616_014057_img_29.png",
"20260616_013755_img_28.png",
"20260616_013603_img_27.png",
"20260616_013013_img_26.png",
"20260616_012939_img_25.png",
"20260616_011823_imgxxxx.png",
"20260616_011447_img_24.png",
"20260616_010228_img_22.png",
"20260616_005752_img_21.png",
"20260616_005727_img_19.png",
"20260616_005202_img_20.png",
"20260616_004250_img_18.png",
"20260616_004220_img_17.png",
"20260616_004001_img_16.png",
"20260616_003916_img_15.png",
"20260616_003803_img_14.png",
"20260616_003629_img_13.png",
"20260616_003548_img.png",
"20260616_002456_test123.jpeg",
"20260616_002302_image.png",
"20260615_155756_img_6v1.png",
"20260615_155354_others.jpeg",
"20260615_154852_other.jpeg",
"20260615_154333_other.jpeg",
"20260615_153749_img_11.png",
"20260615_153426_img_12.png",
"20260615_153125_img_10.png",
"20260615_152826_img.png",
"20260615_152252_imgxxx.png",
"20260615_151829_img_92.png",
"20260615_151614_img_93.png",
"20260615_150812_img_19_2.png",
"20260615_150340_test.png",
"20260615_145017_img_93.png",
"img_9.png",
"b1.png",
"jb3.png",
"jb.png",
"jb1.png",
"img.png",
"out7.png",
"out5.png",
"out4.png",
"out3.png",
"out2.png",
"out.png",
"bb_01.png",
"tp236b.png",
"pass-1.png",
"t159zr-1.png",
"kbk99v.png",
"kk563t.png",
"Pasted image (9).png",
"Pasted image (7).png",
"Pasted image (5).png",
"Pasted image (4).png",
"Pasted image (3).png",
"Pasted image (2).png",
"Pasted image.png",
"pa01.png",
"pa0.png",
"p13.png",
"p12-2.png",
"p12.png",
"p11.png",
"p10.png",
"p1.png",
"img_2.png",
"img_3.png"
];
// --- HYDRATION_END ---
// Supported image extensions
const EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
// Auto-refresh interval in seconds (default: 120 = 2 minutes)
let REFRESH_INTERVAL = 120;
// ============================================
let autoRefreshTimer = null;
let knownFiles = new Set();
let currentFiles = [];
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function formatTime(date) {
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Try to get file info using HEAD requests (works with some local servers)
async function getFileInfo(filename) {
try {
const response = await fetch(IMAGE_FOLDER + filename, { method: 'HEAD' });
const lastMod = response.headers.get('last-modified');
const size = response.headers.get('content-length');
return {
modified: lastMod ? new Date(lastMod) : new Date(),
size: size ? parseInt(size) : 0
};
} catch {
return { modified: new Date(), size: 0 };
}
}
async function scanFolder() {
// For local file:// protocol, we can't list directories.
// This works when served via HTTP or you can manually list files.
// METHOD 1: If you have a simple file server, it might return directory listing
try {
const response = await fetch(IMAGE_FOLDER);
const text = await response.text();
// Try to parse common directory listing formats
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const links = Array.from(doc.querySelectorAll('a'))
.map(a => a.getAttribute('href'))
.filter(href => href && EXTENSIONS.some(ext => href.toLowerCase().endsWith(ext)))
.map(href => href.split('/').pop()); // Just the filename
if (links.length > 0) return links;
} catch (e) {
// Directory listing not available
}
// METHOD 2: Manual file list (fallback)
// If you can't use a server, list your files here or use the manual input below
return null;
}
async function loadImages() {
const gallery = document.getElementById('gallery');
const statusDot = document.getElementById('statusDot');
statusDot.classList.add('updating');
let files = null;
// Use preloaded images if available
if (typeof PRELOADED_IMAGES !== 'undefined' && PRELOADED_IMAGES.length > 0) {
files = PRELOADED_IMAGES;
} else {
files = await scanFolder();
}
// FALLBACK: If auto-scan doesn't work, use this manual approach
if (!files) {
files = await discoverImages();
}
// Sort by newest first (we use a cache-busting parameter to force reload)
const fileObjects = files.map(name => ({
name: name,
url: IMAGE_FOLDER + name + '?t=' + Date.now(),
cleanName: name.split('?')[0]
}));
// Check for new files
const newFiles = fileObjects.filter(f => !knownFiles.has(f.cleanName));
newFiles.forEach(f => knownFiles.add(f.cleanName));
currentFiles = fileObjects;
if (fileObjects.length === 0) {
gallery.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found in "${IMAGE_FOLDER}"</h2>
<p>Make sure this HTML file is in the same folder as your images,<br>or update the IMAGE_FOLDER path in the script.</p>
</div>
`;
statusDot.classList.remove('updating');
return;
}
// Render gallery
gallery.innerHTML = fileObjects.map((file, index) => {
const isNew = index < newFiles.length;
const isRecent = index < 3;
return `
<div class="image-card ${isNew ? 'new' : ''}" data-name="${file.cleanName}">
<div class="image-wrapper">
<div class="select-indicator"></div>
<img src="${file.url}"
alt="${file.cleanName}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div style=\\'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#444;font-size:13px;\\'>Failed to load</div>'">
</div>
<div class="image-info">
<div class="image-name" title="${file.cleanName}">${file.cleanName}</div>
<div class="image-meta">
<span class="image-time">#${String(fileObjects.length - index).padStart(3, '0')}</span>
${isRecent ? '<span class="badge recent">Latest</span>' : ''}
</div>
</div>
</div>
`;
}).join('');
updateCardSelection();
document.getElementById('count').textContent = `${fileObjects.length} image${fileObjects.length !== 1 ? 's' : ''}`;
if (newFiles.length > 0) {
showToast(`${newFiles.length} new image${newFiles.length !== 1 ? 's' : ''} detected`, 'success');
}
statusDot.classList.remove('updating');
}
// Discover images by trying common patterns (fallback for file:// protocol)
async function discoverImages() {
const found = [];
// Try to find images by testing if they exist
// This is a best-effort approach for local file access
// If you know your naming pattern, add it here:
const patterns = [];
// Try to extract from page if there are any references
const images = document.querySelectorAll('img[src]');
images.forEach(img => {
const src = img.getAttribute('src');
if (src && EXTENSIONS.some(ext => src.toLowerCase().includes(ext))) {
found.push(src.split('/').pop().split('?')[0]);
}
});
// Remove duplicates
return [...new Set(found)];
}
function setIntervalTime(seconds) {
REFRESH_INTERVAL = seconds;
document.getElementById('statusText').textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`;
clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
showToast(`Refresh interval set to ${seconds < 60 ? seconds + ' seconds' : (seconds / 60) + ' minutes'}`, 'info');
}
function refreshNow() {
loadImages();
}
const API = 'http://127.0.0.1:8500';
// --- selection state ---
let selectionMode = false;
const selectedFiles = new Set();
function toggleSelectMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedFiles.clear();
document.getElementById('selectBtn').classList.toggle('active', selectionMode);
const bar = document.getElementById('batchBar');
bar.classList.toggle('visible', selectionMode);
document.querySelector('.gallery').style.paddingBottom = selectionMode ? '90px' : '40px';
updateCardSelection();
updateBatchBar();
}
function toggleFile(name) {
if (selectedFiles.has(name)) selectedFiles.delete(name);
else selectedFiles.add(name);
updateCardSelection();
updateBatchBar();
}
function updateCardSelection() {
document.querySelectorAll('.image-card').forEach(card => {
const name = card.dataset.name;
card.classList.toggle('selectable', selectionMode);
card.classList.toggle('selected', selectionMode && selectedFiles.has(name));
});
}
function updateBatchBar() {
const n = selectedFiles.size;
document.getElementById('batchCount').textContent = `${n} selected`;
document.getElementById('processBtn').textContent = n > 0 ? `Process (${n})` : 'Process';
}
function selectAll() {
currentFiles.forEach(f => selectedFiles.add(f.cleanName));
updateCardSelection();
updateBatchBar();
}
function deselectAll() {
selectedFiles.clear();
updateCardSelection();
updateBatchBar();
}
// --- batch processing ---
let pollTimer = null;
async function processSelected() {
const prompt = document.getElementById('batchPromptInput').value.trim();
if (!prompt) { showToast('Enter a prompt first', 'info'); return; }
if (selectedFiles.size === 0) { showToast('No images selected', 'info'); return; }
savePromptHistory(prompt);
const filenames = [...selectedFiles];
document.getElementById('batchProgress').textContent = 'Submitting…';
try {
const r = await fetch(`${API}/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, prompt, seed: -1, max_area: 0 }),
});
if (!r.ok) { showToast('Batch failed to start', 'info'); document.getElementById('batchProgress').textContent = ''; return; }
const { job_id, total } = await r.json();
showToast(`Processing ${total} image${total !== 1 ? 's' : ''}`, 'info');
pollJob(job_id, total);
} catch (e) {
showToast('API not reachable', 'info');
document.getElementById('batchProgress').textContent = '';
}
}
function pollJob(job_id, total) {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
try {
const r = await fetch(`${API}/batch/${job_id}`);
if (!r.ok) return;
const job = await r.json();
const done = job.done + job.failed;
document.getElementById('batchProgress').textContent = `${done}/${total}`;
if (job.status === 'running') {
pollJob(job_id, total);
} else {
const msg = job.failed > 0
? `Done: ${job.done} ok, ${job.failed} failed`
: `Done: ${job.done} processed`;
document.getElementById('batchProgress').textContent = msg;
showToast(msg, 'success');
loadImages();
}
} catch (e) { /* ignore transient errors */ pollJob(job_id, total); }
}, 2000);
}
// --- prompt history (localStorage) ---
function loadPromptHistory() {
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
const dl = document.getElementById('promptHistory');
dl.innerHTML = hist.map(p => `<option value="${p.replace(/"/g, '&quot;')}"></option>`).join('');
}
function savePromptHistory(prompt) {
let hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
hist = [prompt, ...hist.filter(p => p !== prompt)].slice(0, 20);
localStorage.setItem('batchPromptHistory', JSON.stringify(hist));
loadPromptHistory();
}
async function loadCurrentPrompt() {
try {
const r = await fetch(`${API}/config`);
if (r.ok) {
const conf = await r.json();
document.getElementById('promptInput').value = conf.prompt || '';
}
} catch (e) {
// API not reachable yet
}
}
async function setPrompt() {
const prompt = document.getElementById('promptInput').value.trim();
if (!prompt) return;
try {
const r = await fetch(`${API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (r.ok) {
showToast('Prompt updated', 'success');
} else {
showToast('Failed to update prompt', 'info');
}
} catch (e) {
showToast('API not reachable', 'info');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadCurrentPrompt();
loadPromptHistory();
loadImages();
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
// Selection click delegation
document.getElementById('gallery').addEventListener('click', e => {
if (!selectionMode) return;
const card = e.target.closest('.image-card');
if (card) toggleFile(card.dataset.name);
});
});
// Handle visibility change - refresh when tab becomes active
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
loadImages();
}
});
</script>
</body>
</html>