(() => { '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.