(() => { 'use strict'; const deckEl = document.getElementById('deck'); const actionsRowEl = document.getElementById('actionsRow'); const progressFillEl = document.getElementById('progressFill'); const statsLineEl = document.getElementById('statsLine'); const sourceLabelEl = document.getElementById('sourceLabel'); const rescanLink = document.getElementById('rescanLink'); const emptyTrashLink = document.getElementById('emptyTrashLink'); const shortcutsLink = document.getElementById('shortcutsLink'); const shortcutsModal = document.getElementById('shortcutsModal'); const shortcutsClose = document.getElementById('shortcutsClose'); const liveRegionEl = document.getElementById('liveRegion'); const DRAG_THRESHOLD = 110; let state = null; // last /api/queue payload let dragging = null; let busy = false; let focusBeforeModal = null; const KEY_LABEL = { ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓', ' ': 'Space', }; const KEY_SPOKEN = { ArrowLeft: 'left arrow', ArrowRight: 'right arrow', ArrowUp: 'up arrow', ArrowDown: 'down arrow', ' ': 'space', }; async function api(path, options) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `Request failed: ${res.status}`); return body; } function fmtMeta(meta) { if (!meta) return []; return Object.entries(meta).map(([key, value]) => `${key}: ${value}`); } function keyGlyph(key) { return KEY_LABEL[key] || key; } function keySpoken(key) { return KEY_SPOKEN[key] || key; } function announce(message) { liveRegionEl.textContent = ''; // re-trigger even if the text is identical to the last announcement window.requestAnimationFrame(() => { liveRegionEl.textContent = message; }); } function renderPreview(item) { const wrap = document.createElement('div'); wrap.className = 'preview'; const src = `/api/preview/${item.id}`; const thumbSrc = `/api/thumbnail/${item.id}`; switch (item.previewType) { case 'image': { const img = document.createElement('img'); img.src = thumbSrc; img.alt = item.title; img.draggable = false; img.addEventListener( 'error', () => { if (img.src.endsWith(thumbSrc)) { img.src = src; // fall back to the full file if a thumbnail couldn't be made return; } wrap.classList.add('file-icon'); wrap.textContent = 'image failed to load\n(try jpg/png/webp — heic may need a Mac to preview)'; wrap.style.fontSize = '12px'; wrap.style.color = 'var(--sub)'; wrap.style.whiteSpace = 'pre-line'; wrap.style.padding = '24px'; }, { once: false } ); wrap.appendChild(img); break; } case 'audio': { const audio = document.createElement('audio'); audio.controls = true; audio.src = src; wrap.style.padding = '40px 10px'; wrap.appendChild(audio); break; } case 'video': { const video = document.createElement('video'); video.controls = true; video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; video.poster = thumbSrc; video.src = src; wrap.appendChild(video); break; } case 'text': { const pre = document.createElement('pre'); pre.textContent = 'Loading preview...'; wrap.appendChild(pre); fetch(src) .then((r) => r.text()) .then((text) => { pre.textContent = text.slice(0, 2000); }) .catch(() => { pre.textContent = '(preview unavailable)'; }); break; } default: { wrap.classList.add('file-icon'); wrap.setAttribute('aria-hidden', 'true'); wrap.textContent = '\u{1F4C4}'; } } return wrap; } function renderCard(item, actions, position) { const card = document.createElement('div'); card.className = 'card'; card.setAttribute('role', 'group'); card.setAttribute('aria-label', `${position.index} of ${position.total}: ${item.title}`); card.appendChild(renderPreview(item)); const title = document.createElement('div'); title.className = 'title'; title.textContent = item.title; card.appendChild(title); if (item.subtitle) { const subtitle = document.createElement('div'); subtitle.className = 'subtitle'; subtitle.textContent = item.subtitle; card.appendChild(subtitle); } const metaEntries = fmtMeta(item.meta); if (metaEntries.length) { const row = document.createElement('div'); row.className = 'meta-row'; for (const entry of metaEntries) { const badge = document.createElement('span'); badge.className = 'meta-badge'; badge.textContent = entry; row.appendChild(badge); } card.appendChild(row); } for (const dir of ['left', 'right']) { const action = actions.find((a) => a.direction === dir); if (!action) continue; const stamp = document.createElement('div'); stamp.className = `stamp ${dir}`; stamp.dataset.direction = dir; stamp.textContent = action.label; stamp.setAttribute('aria-hidden', 'true'); card.appendChild(stamp); } attachDrag(card, actions); return card; } function attachDrag(card, actions) { const leftAction = actions.find((a) => a.direction === 'left'); const rightAction = actions.find((a) => a.direction === 'right'); const leftStamp = card.querySelector('.stamp.left'); const rightStamp = card.querySelector('.stamp.right'); function onPointerDown(e) { if (busy) return; dragging = { startX: e.clientX, startY: e.clientY, dx: 0 }; card.setPointerCapture(e.pointerId); } function onPointerMove(e) { if (!dragging) return; dragging.dx = e.clientX - dragging.startX; const dy = (e.clientY - dragging.startY) * 0.2; const rotate = dragging.dx / 18; card.style.transform = `translate(${dragging.dx}px, ${dy}px) rotate(${rotate}deg)`; const ratio = Math.min(Math.abs(dragging.dx) / DRAG_THRESHOLD, 1); if (dragging.dx > 0 && rightStamp) rightStamp.style.opacity = String(ratio); if (dragging.dx < 0 && leftStamp) leftStamp.style.opacity = String(ratio); } function onPointerUp() { if (!dragging) return; const dx = dragging.dx; dragging = null; if (dx > DRAG_THRESHOLD && rightAction) { animateAction(rightAction); } else if (dx < -DRAG_THRESHOLD && leftAction) { animateAction(leftAction); } else { card.style.transform = ''; if (leftStamp) leftStamp.style.opacity = '0'; if (rightStamp) rightStamp.style.opacity = '0'; } } card.addEventListener('pointerdown', onPointerDown); card.addEventListener('pointermove', onPointerMove); card.addEventListener('pointerup', onPointerUp); card.addEventListener('pointercancel', onPointerUp); } /** Show KEEP/REJECT stamp + fling, then commit the action. */ function animateAction(action) { if (busy || !state || !state.current) return; const card = deckEl.querySelector('.card'); if (!card) { performAction(action.id); return; } const dir = action.direction === 'right' ? 1 : action.direction === 'left' ? -1 : 0; const stamp = card.querySelector(`.stamp.${action.direction}`); if (stamp) stamp.style.opacity = '1'; if (dir === 0) { performAction(action.id); return; } busy = true; card.style.transition = 'transform 0.28s ease-out, opacity 0.28s ease-out'; card.style.transform = `translate(${dir * 500}px, -40px) rotate(${dir * 25}deg)`; card.style.opacity = '0'; setTimeout(() => { busy = false; performAction(action.id); }, 220); } function triggerAction(actionId) { if (busy || !state) return; const action = (state.actions || []).find((a) => a.id === actionId); if (!action) return; if (action.direction === 'left' || action.direction === 'right') { animateAction(action); } else { performAction(actionId); } } function renderActionsRow(actions) { actionsRowEl.innerHTML = ''; for (const action of actions) { const btn = document.createElement('button'); btn.className = 'action-btn'; btn.type = 'button'; btn.setAttribute('aria-label', `${action.label}, ${keySpoken(action.key)}`); if (action.id === 'keep') btn.classList.add('keep'); if (action.isDestructive) btn.classList.add('reject'); if (action.id === 'skip') btn.classList.add('skip'); const label = document.createElement('span'); label.className = 'action-label'; label.textContent = action.label; label.setAttribute('aria-hidden', 'true'); const key = document.createElement('span'); key.className = 'action-key'; key.textContent = keyGlyph(action.key); key.setAttribute('aria-hidden', 'true'); btn.appendChild(label); btn.appendChild(key); btn.addEventListener('click', () => triggerAction(action.id)); actionsRowEl.appendChild(btn); } const undoBtn = document.createElement('button'); undoBtn.type = 'button'; undoBtn.className = 'action-btn undo'; undoBtn.disabled = !state || !state.canUndo; undoBtn.setAttribute('aria-label', 'Undo, up arrow'); const undoLabel = document.createElement('span'); undoLabel.className = 'action-label'; undoLabel.textContent = 'Undo'; undoLabel.setAttribute('aria-hidden', 'true'); const undoKey = document.createElement('span'); undoKey.className = 'action-key'; undoKey.textContent = '↑'; undoKey.setAttribute('aria-hidden', 'true'); undoBtn.appendChild(undoLabel); undoBtn.appendChild(undoKey); undoBtn.addEventListener('click', undo); actionsRowEl.appendChild(undoBtn); } function renderStats() { if (!state) return; const pct = state.total ? Math.round((state.reviewed / state.total) * 100) : 0; progressFillEl.style.width = `${pct}%`; progressFillEl.setAttribute('aria-valuemax', String(state.total)); progressFillEl.setAttribute('aria-valuenow', String(state.reviewed)); sourceLabelEl.textContent = state.sourceLabel || ''; const countBits = Object.entries(state.counts || {}) .map(([id, n]) => `${id}: ${n}`) .join(' \u00b7 '); statsLineEl.textContent = `${state.reviewed}/${state.total} reviewed${countBits ? ' \u2014 ' + countBits : ''}`; if (state.trashInfo && state.trashInfo.count > 0) { emptyTrashLink.hidden = false; emptyTrashLink.textContent = `Empty trash (${state.trashInfo.count})`; } else { emptyTrashLink.hidden = true; } } function renderDeck() { deckEl.innerHTML = ''; if (!state) return; if (!state.current) { const empty = document.createElement('div'); empty.className = 'empty-state'; const countBits = Object.entries(state.counts || {}) .map(([id, n]) => `${n} ${id}`) .join('    '); empty.innerHTML = `All done. ${state.total} item(s) reviewed.
${countBits}
`; deckEl.appendChild(empty); renderActionsRow([]); announce(`All done. ${state.total} items reviewed.`); return; } const position = { index: state.reviewed + 1, total: state.total }; const card = renderCard(state.current, state.actions, position); deckEl.appendChild(card); renderActionsRow(state.actions); announce(`Item ${position.index} of ${position.total}: ${state.current.title}`); } function render() { renderStats(); renderDeck(); } function showError(message) { deckEl.innerHTML = ``; } function getFocusable(container) { return Array.from( container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') ).filter((el) => !el.disabled && el.offsetParent !== null); } function trapFocus(e) { if (e.key !== 'Tab') return; const focusable = getFocusable(shortcutsModal); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } function openShortcuts() { focusBeforeModal = document.activeElement; shortcutsModal.hidden = false; shortcutsModal.setAttribute('aria-hidden', 'false'); shortcutsModal.addEventListener('keydown', trapFocus); shortcutsClose.focus(); } function closeShortcuts() { shortcutsModal.hidden = true; shortcutsModal.setAttribute('aria-hidden', 'true'); shortcutsModal.removeEventListener('keydown', trapFocus); if (focusBeforeModal && typeof focusBeforeModal.focus === 'function') { focusBeforeModal.focus(); } focusBeforeModal = null; } function toggleShortcuts() { if (shortcutsModal.hidden) openShortcuts(); else closeShortcuts(); } async function refresh() { try { state = await api('/api/queue'); render(); } catch (err) { if (String(err.message).includes('Not configured')) { window.location.href = 'settings.html'; return; } showError(err.message); } } async function performAction(actionId) { if (!state || !state.current) return; const itemId = state.current.id; try { state = await api('/api/action', { method: 'POST', body: JSON.stringify({ itemId, actionId }) }); render(); } catch (err) { showError(err.message); setTimeout(refresh, 800); } } async function undo() { if (busy) return; try { state = await api('/api/undo', { method: 'POST' }); render(); } catch (err) { showError(err.message); } } rescanLink.addEventListener('click', async () => { try { state = await api('/api/rescan', { method: 'POST' }); render(); } catch (err) { showError(err.message); } }); emptyTrashLink.addEventListener('click', async () => { const count = state && state.trashInfo ? state.trashInfo.count : 0; const ok = window.confirm( `Permanently delete ${count} item(s) from trash? This cannot be undone.` ); if (!ok) return; try { state = await api('/api/empty-trash', { method: 'POST' }); render(); announce('Trash emptied.'); } catch (err) { showError(err.message); } }); shortcutsLink.addEventListener('click', () => { toggleShortcuts(); }); shortcutsClose.addEventListener('click', closeShortcuts); shortcutsModal.addEventListener('click', (e) => { if (e.target === shortcutsModal) closeShortcuts(); }); document.addEventListener('keydown', (e) => { // Shift+? (Shift+/ on most keyboards) toggles shortcuts help if (e.key === '?' || (e.shiftKey && e.key === '/')) { e.preventDefault(); toggleShortcuts(); return; } if (e.key === 'Escape' && !shortcutsModal.hidden) { e.preventDefault(); closeShortcuts(); return; } if (!shortcutsModal.hidden) return; if (!state) return; if (e.key === 'ArrowUp' || ((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey))) { e.preventDefault(); undo(); return; } if (!state.current || busy) return; if (e.key === 'ArrowDown' || e.key === ' ') { e.preventDefault(); triggerAction('skip'); return; } if (e.key === 'ArrowRight') { e.preventDefault(); triggerAction('keep'); return; } if (e.key === 'ArrowLeft') { e.preventDefault(); triggerAction('reject'); return; } }); refresh(); })();