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.
This commit is contained in:
@@ -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]
|
## [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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user