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.
This commit is contained in:
+53
-3
@@ -27,6 +27,10 @@
|
||||
return el.value;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderField(field) {
|
||||
const wrap = document.createElement('div');
|
||||
const existing = currentSettings[field.key];
|
||||
@@ -41,12 +45,58 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (field.type === 'folder') {
|
||||
wrap.className = 'form-field';
|
||||
wrap.innerHTML = `
|
||||
<label for="field_${field.key}">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<div class="folder-field-row">
|
||||
<input type="text" id="field_${field.key}"
|
||||
value="${value !== undefined ? String(value).replace(/"/g, '"') : ''}"
|
||||
placeholder="${field.placeholder || ''}">
|
||||
<button type="button" class="secondary-btn" data-browse-for="field_${field.key}">Browse…</button>
|
||||
</div>
|
||||
<div class="field-hint" data-browse-hint-for="field_${field.key}"></div>
|
||||
`;
|
||||
const browseBtn = wrap.querySelector(`[data-browse-for="field_${field.key}"]`);
|
||||
const hintEl = wrap.querySelector(`[data-browse-hint-for="field_${field.key}"]`);
|
||||
browseBtn.addEventListener('click', async () => {
|
||||
hintEl.textContent = '';
|
||||
browseBtn.disabled = true;
|
||||
browseBtn.textContent = 'Waiting for Finder\u2026';
|
||||
try {
|
||||
const { path: chosen } = await api('/api/browse-folder', { method: 'POST' });
|
||||
document.getElementById(`field_${field.key}`).value = chosen;
|
||||
} catch (err) {
|
||||
if (err.message !== 'Cancelled') hintEl.textContent = err.message;
|
||||
} finally {
|
||||
browseBtn.disabled = false;
|
||||
browseBtn.textContent = 'Browse\u2026';
|
||||
}
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
wrap.className = 'form-field';
|
||||
const optionsHtml = (field.options || [])
|
||||
.map(
|
||||
(opt) =>
|
||||
`<option value="${escapeAttr(opt.value)}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`
|
||||
)
|
||||
.join('');
|
||||
wrap.innerHTML = `
|
||||
<label for="field_${field.key}">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<select id="field_${field.key}">${optionsHtml}</select>
|
||||
`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.className = 'form-field';
|
||||
const inputType = field.type === 'number' ? 'number' : 'text';
|
||||
const inputType = field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text';
|
||||
wrap.innerHTML = `
|
||||
<label for="field_${field.key}">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<input type="${inputType}" id="field_${field.key}"
|
||||
value="${value !== undefined ? String(value).replace(/"/g, '"') : ''}"
|
||||
<input type="${inputType}" id="field_${field.key}" autocomplete="${field.type === 'password' ? 'off' : 'on'}"
|
||||
value="${value !== undefined ? escapeAttr(value) : ''}"
|
||||
placeholder="${field.placeholder || ''}">
|
||||
`;
|
||||
return wrap;
|
||||
|
||||
Reference in New Issue
Block a user