From 6f0ccf2edcd1bf131c3581cefdd9aa5174e49612 Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 26 Jul 2026 10:56:53 -0400 Subject: [PATCH] Polish settings UI and add Gitea Actions CI gate 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. --- .gitea/workflows/ci.yml | 34 ++++++++++++++++++++++ CHANGELOG.md | 7 +++++ README.md | 6 ++-- adapters/folder.js | 1 + adapters/immich.js | 4 +-- public/settings.js | 26 +++++++++++++---- public/style.css | 63 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..51b0174 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 98137bc..122b631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### 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 photo library. Reject moves the asset to Immich's own trash; undo restores it. Second reference implementation of the adapter contract, diff --git a/README.md b/README.md index 1c80b6f..260fc22 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ see [CONTRIBUTING.md](CONTRIBUTING.md). - Zero build step: Node.js + Express + vanilla HTML/CSS/JS - `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 +- CI on every push/PR (Gitea Actions): `npm test` + gitleaks ## Quickstart @@ -58,7 +59,8 @@ npm start ``` 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 `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. 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 diff --git a/adapters/folder.js b/adapters/folder.js index 7e2899c..421fb1f 100644 --- a/adapters/folder.js +++ b/adapters/folder.js @@ -73,6 +73,7 @@ class FolderAdapter extends Adapter { label: 'File extensions (comma separated, blank = all files)', type: 'text', 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' }, ]; diff --git a/adapters/immich.js b/adapters/immich.js index 0a5fcb3..9af87d8 100644 --- a/adapters/immich.js +++ b/adapters/immich.js @@ -17,11 +17,11 @@ class ImmichAdapter extends Adapter { static id = 'immich'; static label = 'Immich'; 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 = [ { 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', label: 'Order', diff --git a/public/settings.js b/public/settings.js index 7914c9d..56527a2 100644 --- a/public/settings.js +++ b/public/settings.js @@ -8,6 +8,7 @@ let adapters = []; let selectedAdapterId = null; let currentSettings = {}; + let savedConfig = null; async function api(path, options) { const res = await fetch(path, { @@ -116,20 +117,31 @@ formEl.appendChild(saveBtn); } + function settingsFor(adapterId) { + if (savedConfig && savedConfig.adapter === adapterId) { + return { ...(savedConfig.settings || {}) }; + } + return {}; + } + function renderAdapterList() { adapterListEl.innerHTML = ''; for (const adapter of adapters) { + const selected = adapter.id === selectedAdapterId; const card = document.createElement('label'); - card.className = 'adapter-card'; + card.className = `adapter-card${selected ? ' selected' : ''}`; card.innerHTML = ` -
- +
+ ${adapter.label}
${adapter.description || ''}
`; card.querySelector('input').addEventListener('change', () => { selectedAdapterId = adapter.id; + currentSettings = settingsFor(adapter.id); + renderAdapterList(); renderForm(); }); adapterListEl.appendChild(card); @@ -159,11 +171,13 @@ async function init() { const [adapterList, configResp] = await Promise.all([api('/api/adapters'), api('/api/config')]); adapters = adapterList; - if (configResp.config) { - selectedAdapterId = configResp.config.adapter; - currentSettings = configResp.config.settings || {}; + savedConfig = configResp.config || null; + if (savedConfig) { + selectedAdapterId = savedConfig.adapter; + currentSettings = settingsFor(selectedAdapterId); } else { selectedAdapterId = adapters[0] && adapters[0].id; + currentSettings = {}; } renderAdapterList(); renderForm(); diff --git a/public/style.css b/public/style.css index a212927..309f20c 100644 --- a/public/style.css +++ b/public/style.css @@ -441,17 +441,31 @@ h1 { } .form-field input[type='text'], +.form-field input[type='password'], .form-field input[type='number'], .form-field select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); - background: var(--card); + background: var(--card2); color: var(--text); 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 { display: flex; align-items: center; @@ -499,20 +513,63 @@ h1 { border: none; margin: 0; padding: 0; + min-inline-size: 0; } .adapter-card { + display: block; border: 1px solid var(--border); border-radius: 12px; padding: 14px; 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 { font-size: 12px; color: var(--sub); - margin-top: 4px; + margin: 6px 0 0 24px; + line-height: 1.4; } .primary-btn { -- 2.49.1