Files
SwipeAnything/public/app.js
T
ilia 0c9919f93c Add second adapter, thumbnails, a11y pass, and a real test suite
- Immich adapter (adapters/immich.js): swipe a self-hosted photo library
  over its REST API, proving the adapter contract works for remote
  sources, not just the filesystem.
- Refactor the preview contract from resolvePreviewPath() to
  streamPreview()/streamThumbnail(), so adapters can serve previews from
  anywhere (local file, proxied fetch, etc).
- macOS Quick Look-backed thumbnail cache (lib/thumbnails.js): real
  resized thumbnails, HEIC/HEIF, and video poster frames, with a hard
  timeout and graceful fallback to the original file everywhere else.
- Native "Browse..." folder picker (osascript) in Settings, with manual
  typing as the fallback on other platforms.
- Confirm-guarded "Empty trash" action -- the only place this project
  permanently deletes anything.
- Session resume: progress now survives a server restart and an
  unchanged Rescan via .swipeanything-session.json.
- Accessibility pass: live region item announcements, aria-labels on
  action buttons, focus-trapped shortcuts dialog with focus restore,
  visible focus rings, a real role="progressbar", and <button>s instead
  of <a href="#"> in the header.
- node:test suite (23 tests) covering the folder adapter, the Immich
  adapter (mocked fetch, no live server needed), and the HTTP API
  end-to-end; wired up as `npm test`.
- Also includes the keyboard shortcuts (up/down undo/skip), swipe stamps,
  and shortcuts modal from the previous session that hadn't been
  committed yet.
2026-07-25 19:41:47 -04:00

521 lines
16 KiB
JavaScript

(() => {
'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]) => `<strong>${n}</strong> ${id}`)
.join(' &nbsp;&nbsp; ');
empty.innerHTML = `All done. ${state.total} item(s) reviewed.<div class="summary">${countBits}</div>`;
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 = `<div class="error-state" role="alert">${message}</div>`;
}
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();
})();