Polish settings UI and add Gitea Actions CI gate
CI / Gitleaks (pull_request) Successful in 17s
CI / Unit tests (pull_request) Successful in 20s

Settings: highlight the selected adapter, restyle radios/password/select
for the dark theme, and keep saved settings scoped to the chosen adapter.
CI: run npm test + gitleaks on push/PR so the suite is an actual gate.
This commit is contained in:
2026-07-26 10:56:53 -04:00
parent 0c9919f93c
commit 6f0ccf2edc
7 changed files with 128 additions and 13 deletions
+34
View File
@@ -0,0 +1,34 @@
---
# Homelab CI for SwipeAnything
name: CI
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
test:
name: Unit tests
runs-on: [homelab, self-hosted, linux]
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: Install
run: npm ci
- name: Test
run: npm test
secret-scan:
name: Gitleaks
runs-on: [homelab, self-hosted, linux, heavy]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
run: |
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact
+7
View File
@@ -6,6 +6,13 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Gitea Actions CI (`.gitea/workflows/ci.yml`): `npm test` + gitleaks on
every push/PR — the first PR gate for this repo.
- Settings UI polish: selected-adapter highlight, custom radios (no native
connecting-line artifact), styled password/select fields, cleaner radio
accessible names, and adapter-scoped settings when switching sources.
### Added (earlier)
- **Immich adapter** (`adapters/immich.js`): swipe through a self-hosted - **Immich adapter** (`adapters/immich.js`): swipe through a self-hosted
Immich photo library. Reject moves the asset to Immich's own trash; undo Immich photo library. Reject moves the asset to Immich's own trash; undo
restores it. Second reference implementation of the adapter contract, restores it. Second reference implementation of the adapter contract,
+4 -2
View File
@@ -47,6 +47,7 @@ see [CONTRIBUTING.md](CONTRIBUTING.md).
- Zero build step: Node.js + Express + vanilla HTML/CSS/JS - Zero build step: Node.js + Express + vanilla HTML/CSS/JS
- `npm test` runs a real test suite (folder adapter, Immich adapter with a - `npm test` runs a real test suite (folder adapter, Immich adapter with a
mocked API, and the HTTP API end-to-end) — no separate test server to stand up mocked API, and the HTTP API end-to-end) — no separate test server to stand up
- CI on every push/PR (Gitea Actions): `npm test` + gitleaks
## Quickstart ## Quickstart
@@ -58,7 +59,8 @@ npm start
``` ```
Open `http://localhost:5757`. On first run you'll land on **Settings** Open `http://localhost:5757`. On first run you'll land on **Settings**
pick an adapter, fill in its settings, and start swiping. pick an adapter (the selected one is highlighted), fill in its settings
(Browse… opens a Finder dialog on macOS for folder paths), and start swiping.
Alternatively, copy `swipeanything.config.example.json` to Alternatively, copy `swipeanything.config.example.json` to
`swipeanything.config.json` and edit it directly: `swipeanything.config.json` and edit it directly:
@@ -75,7 +77,7 @@ npm test
Uses Node's built-in test runner (`node --test`) — no extra dev dependencies. Uses Node's built-in test runner (`node --test`) — no extra dev dependencies.
The Immich adapter is tested with a mocked `fetch`, so nothing here needs a The Immich adapter is tested with a mocked `fetch`, so nothing here needs a
live server. live server. The same suite is the PR gate in [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml).
## Controls ## Controls
+1
View File
@@ -73,6 +73,7 @@ class FolderAdapter extends Adapter {
label: 'File extensions (comma separated, blank = all files)', label: 'File extensions (comma separated, blank = all files)',
type: 'text', type: 'text',
default: 'jpg,jpeg,png,gif,webp,heic,bmp', default: 'jpg,jpeg,png,gif,webp,heic,bmp',
placeholder: 'jpg,jpeg,png,gif,webp,heic,bmp',
}, },
{ key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' }, { key: 'trashDirName', label: 'Trash folder name', type: 'text', default: '.swipeanything-trash' },
]; ];
+2 -2
View File
@@ -17,11 +17,11 @@ class ImmichAdapter extends Adapter {
static id = 'immich'; static id = 'immich';
static label = 'Immich'; static label = 'Immich';
static description = static description =
'Swipe through a self-hosted Immich photo library. Reject moves the asset to Immich\u2019s own trash; undo restores it.'; "Swipe through a self-hosted Immich photo library. Reject moves the asset to Immich's own trash; undo restores it.";
static configSchema = [ static configSchema = [
{ key: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'https://immich.example.com' }, { key: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'https://immich.example.com' },
{ key: 'apiKey', label: 'API key', type: 'password', required: true }, { key: 'apiKey', label: 'API key', type: 'password', required: true, placeholder: 'immich_api_key_…' },
{ {
key: 'mode', key: 'mode',
label: 'Order', label: 'Order',
+20 -6
View File
@@ -8,6 +8,7 @@
let adapters = []; let adapters = [];
let selectedAdapterId = null; let selectedAdapterId = null;
let currentSettings = {}; let currentSettings = {};
let savedConfig = null;
async function api(path, options) { async function api(path, options) {
const res = await fetch(path, { const res = await fetch(path, {
@@ -116,20 +117,31 @@
formEl.appendChild(saveBtn); formEl.appendChild(saveBtn);
} }
function settingsFor(adapterId) {
if (savedConfig && savedConfig.adapter === adapterId) {
return { ...(savedConfig.settings || {}) };
}
return {};
}
function renderAdapterList() { function renderAdapterList() {
adapterListEl.innerHTML = ''; adapterListEl.innerHTML = '';
for (const adapter of adapters) { for (const adapter of adapters) {
const selected = adapter.id === selectedAdapterId;
const card = document.createElement('label'); const card = document.createElement('label');
card.className = 'adapter-card'; card.className = `adapter-card${selected ? ' selected' : ''}`;
card.innerHTML = ` card.innerHTML = `
<div class="form-field checkbox" style="margin-bottom:0"> <div class="adapter-card-title">
<input type="radio" name="adapter" value="${adapter.id}" ${adapter.id === selectedAdapterId ? 'checked' : ''}> <input type="radio" name="adapter" value="${adapter.id}"
aria-label="${adapter.label}" ${selected ? 'checked' : ''}>
<strong>${adapter.label}</strong> <strong>${adapter.label}</strong>
</div> </div>
<div class="adapter-desc">${adapter.description || ''}</div> <div class="adapter-desc">${adapter.description || ''}</div>
`; `;
card.querySelector('input').addEventListener('change', () => { card.querySelector('input').addEventListener('change', () => {
selectedAdapterId = adapter.id; selectedAdapterId = adapter.id;
currentSettings = settingsFor(adapter.id);
renderAdapterList();
renderForm(); renderForm();
}); });
adapterListEl.appendChild(card); adapterListEl.appendChild(card);
@@ -159,11 +171,13 @@
async function init() { async function init() {
const [adapterList, configResp] = await Promise.all([api('/api/adapters'), api('/api/config')]); const [adapterList, configResp] = await Promise.all([api('/api/adapters'), api('/api/config')]);
adapters = adapterList; adapters = adapterList;
if (configResp.config) { savedConfig = configResp.config || null;
selectedAdapterId = configResp.config.adapter; if (savedConfig) {
currentSettings = configResp.config.settings || {}; selectedAdapterId = savedConfig.adapter;
currentSettings = settingsFor(selectedAdapterId);
} else { } else {
selectedAdapterId = adapters[0] && adapters[0].id; selectedAdapterId = adapters[0] && adapters[0].id;
currentSettings = {};
} }
renderAdapterList(); renderAdapterList();
renderForm(); renderForm();
+60 -3
View File
@@ -441,17 +441,31 @@ h1 {
} }
.form-field input[type='text'], .form-field input[type='text'],
.form-field input[type='password'],
.form-field input[type='number'], .form-field input[type='number'],
.form-field select { .form-field select {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--card); background: var(--card2);
color: var(--text); color: var(--text);
font-size: 14px; font-size: 14px;
} }
.form-field input::placeholder {
color: var(--sub);
opacity: 0.7;
}
.form-field select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%239aa3ab' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.form-field.checkbox { .form-field.checkbox {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -499,20 +513,63 @@ h1 {
border: none; border: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-inline-size: 0;
} }
.adapter-card { .adapter-card {
display: block;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 14px; padding: 14px;
background: var(--card); background: var(--card);
margin-bottom: 16px; margin-bottom: 12px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.adapter-card:hover {
border-color: #3a424a;
}
.adapter-card.selected {
border-color: var(--accent);
background: #141c28;
}
.adapter-card-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
}
.adapter-card input[type='radio'] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
margin: 0;
flex: none;
border: 2px solid var(--sub);
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.adapter-card input[type='radio']:checked {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 45%, transparent 48%);
}
.adapter-card-title strong {
font-size: 14px;
} }
.adapter-desc { .adapter-desc {
font-size: 12px; font-size: 12px;
color: var(--sub); color: var(--sub);
margin-top: 4px; margin: 6px 0 0 24px;
line-height: 1.4;
} }
.primary-btn { .primary-btn {