Polish settings UI and add CI gate #1

Merged
ilia merged 1 commits from polish/settings-ci into main 2026-07-26 09:59:05 -05:00
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]
### 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,
+4 -2
View File
@@ -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
+1
View File
@@ -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' },
];
+2 -2
View File
@@ -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',
+20 -6
View File
@@ -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 = `
<div class="form-field checkbox" style="margin-bottom:0">
<input type="radio" name="adapter" value="${adapter.id}" ${adapter.id === selectedAdapterId ? 'checked' : ''}>
<div class="adapter-card-title">
<input type="radio" name="adapter" value="${adapter.id}"
aria-label="${adapter.label}" ${selected ? 'checked' : ''}>
<strong>${adapter.label}</strong>
</div>
<div class="adapter-desc">${adapter.description || ''}</div>
`;
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();
+60 -3
View File
@@ -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 {