- 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.
521 lines
16 KiB
JavaScript
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(' ');
|
|
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();
|
|
})();
|