Polish settings UI and add CI gate #1
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user