feat(immich): draw a server as a folder in the library

A saved Immich server is a node on the column beside the drives: no handle,
no picker — the key lives on the backend and the page only ever calls
`/api/immich/*` on its own origin. The dialog takes the address, the name and
the key, checks the key before storing anything, and offers the albums the key
can see; a blank tick list and a blank id field both mean every album. A share
key is one album and so has no picker.

`syncImmich` turns a server's albums into the shapes the tree already draws —
rows in `photos`, names in `walked` — so the column, the wall and the strip
need nothing new. The node's menu is the disk's menu minus what a server has
no disk for.

Also fixes `saveWalkedDirs` filling its noughts in before folding the keys:
a reading that spelled its keys the way it found them (a server's album
labels) had the lower-case spelling of a counted key written in as nought,
and the fold that settles the two spellings kept the nought.
This commit is contained in:
2026-10-10 16:16:37 +07:00
parent d2e2ebe108
commit e27998d820
9 changed files with 1529 additions and 60 deletions
+290
View File
@@ -0,0 +1,290 @@
import { useEffect, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import {
listImmichAlbums,
probeImmich,
saveImmichAlbums,
saveImmichServer,
type ImmichAlbum,
type ImmichError,
type ImmichServer,
} from '../engine/immich';
// The dialog that puts a server in the column, and the one that changes what an
// existing node keeps. The key only ever goes up: nothing here writes it anywhere
// but the backend's own call, which probes before it stores, so a saved server is
// one that answered.
//
// `server` is the node being edited — its albums, read live, so one added on
// Immich since the last visit is a row to tick. Without it the dialog adds: the
// address, the key, "check the key" (which stores nothing, so a typo is never a
// row), the albums, and the id field for the albums a key is too narrow to list.
//
// ponytail: album ticks and the id field are one list — a tick is an id, and the
// field is the way in for an album the API will not name. The field takes a whole
// `…/albums/<uuid>` address as readily as the uuid, since that is where a reader
// copies one from.
export function ImmichDialog({
server,
defaultUrl,
onSaved,
onClose,
}: {
server?: ImmichServer | null;
defaultUrl: string;
onSaved: (saved: ImmichServer, isNew: boolean) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const editing = Boolean(server);
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? defaultUrl);
const [key, setKey] = useState('');
const [type, setType] = useState<'api' | 'share'>(server?.type ?? 'api');
const [albums, setAlbums] = useState<ImmichAlbum[] | null>(null);
const [ticked, setTicked] = useState<Set<string>>(() => new Set(server?.albums ?? []));
const [ids, setIds] = useState('');
const [canDownload, setCanDownload] = useState<boolean | null>(server?.canDownload ?? null);
const [checkedAt, setCheckedAt] = useState<'none' | 'ok' | 'fail'>(editing ? 'ok' : 'none');
const [problem, setProblem] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// A share link *is* one album, so there is no picker for it: the node is the
// album the link names, and the backend reads it through the share routes.
const share = type === 'share';
const why = (error: ImmichError) =>
t(
error === 'auth'
? 'lib.immich.auth'
: error === 'unreachable'
? 'lib.immich.unreachable'
: error === 'invalid'
? 'lib.immich.invalid'
: 'lib.immich.serverErr'
);
// An existing node's albums are asked for live rather than drawn off the saved
// row: an album kept since the last visit and one deleted on the server both
// have to read right, and the saved list is neither.
useEffect(() => {
if (!server) return;
let alive = true;
void listImmichAlbums(server.id).then((rows) => {
if (alive && rows.length) setAlbums(rows);
});
return () => {
alive = false;
};
}, [server]);
// What the reader means to keep: the ticked albums and every uuid the field
// carries — a whole address, a list on one line, or both.
const chosen = (): string[] => {
const out = new Set(ticked);
for (const raw of ids.split(/[\s,;]+/)) {
const hit = raw.trim().match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (hit) out.add(hit[0].toLowerCase());
}
return [...out];
};
const check = async () => {
setBusy(true);
setProblem(null);
const res = await probeImmich(url.trim(), key.trim(), type);
setBusy(false);
if (!res.ok) {
setCheckedAt('fail');
setCanDownload(null);
setProblem(why(res.error));
return;
}
setCheckedAt('ok');
setCanDownload(res.canDownload);
setAlbums(res.albums);
// A fresh server keeps everything its key can see until the reader says
// otherwise: an empty tick list means "all of them", so ticking them is the
// same decision spelled out where it can be changed.
if (!share) setTicked(new Set(res.albums.map((a) => a.id)));
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setProblem(null);
if (editing && server) {
setBusy(true);
try {
const kept = await saveImmichAlbums(server.id, chosen());
onSaved({ ...server, albums: kept }, false);
} finally {
setBusy(false);
}
return;
}
if (!url.trim() || !key.trim()) {
setProblem(t('lib.immich.invalid'));
return;
}
setBusy(true);
try {
const res = await saveImmichServer({
name: name.trim(),
url: url.trim(),
key: key.trim(),
type,
albums: share ? [] : chosen(),
});
if (!res.ok) {
setCheckedAt('fail');
setProblem(why(res.error));
return;
}
onSaved(res.server, true);
} finally {
setBusy(false);
}
};
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" data-key="immich-modal" onSubmit={save}>
<h2>{editing ? t('lib.immich.albumsChoose') : t('lib.immich.title')}</h2>
{editing && server ? (
<p className="hint" data-key="immich-where">
{server.url} · {server.keyMasked}
</p>
) : (
<>
<label className="hint" htmlFor="immich-name">
{t('lib.immich.name')}
</label>
<input
id="immich-name"
className="field"
data-key="immich-name"
value={name}
maxLength={60}
placeholder={t('lib.immich.nameHint')}
onChange={(e) => setName(e.target.value)}
/>
<label className="hint" htmlFor="immich-url">
{t('lib.immich.url')}
</label>
<input
id="immich-url"
className="field"
data-key="immich-url"
value={url}
placeholder="https://photos.example.com"
onChange={(e) => setUrl(e.target.value)}
/>
<label className="hint" htmlFor="immich-key">
{t('lib.immich.key')}
</label>
<input
id="immich-key"
className="field"
data-key="immich-key"
type="password"
value={key}
autoComplete="off"
onChange={(e) => setKey(e.target.value)}
/>
<label className="hint">
<input
type="checkbox"
data-key="immich-share"
checked={share}
onChange={(e) => setType(e.target.checked ? 'share' : 'api')}
/>
{t('lib.immich.keyShare')}
</label>
<p className="hint" data-key="immich-perms">
{t('lib.immich.perms')}
</p>
<button
type="button"
className="btn"
data-key="immich-check"
disabled={busy || !url.trim() || !key.trim()}
onClick={() => void check()}
>
{busy ? t('lib.immich.checking') : t('lib.immich.keyCheck')}
</button>
{checkedAt !== 'none' ? (
<p className={checkedAt === 'ok' ? 'hint' : 'err'} data-key="immich-status">
{checkedAt === 'ok' ? t('lib.immich.ok') : problem}
</p>
) : null}
</>
)}
{albums && !share ? (
<>
<p className="hint" data-key="immich-albums-hint">
{albums.length ? t('lib.immich.albumsPick') : t('lib.immich.empty')}
</p>
<ul className="immich-albums" data-key="immich-albums">
{albums.map((a) => (
<li key={a.id}>
<label className="hint">
<input
type="checkbox"
data-key={`immich-album-${a.id}`}
checked={ticked.has(a.id)}
onChange={(e) =>
setTicked((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(a.id);
else next.delete(a.id);
return next;
})
}
/>
{a.albumName?.trim() || a.id.slice(0, 8)} ({a.assetCount})
</label>
</li>
))}
</ul>
</>
) : null}
{!share ? (
<>
<label className="hint" htmlFor="immich-ids">
{t('lib.immich.albumIds')}
</label>
<textarea
id="immich-ids"
className="field"
data-key="immich-ids"
rows={2}
value={ids}
placeholder={t('lib.immich.albumIdsHint')}
onChange={(e) => setIds(e.target.value)}
/>
</>
) : null}
{canDownload === false ? (
<p className="err" data-key="immich-nodownload">
{t('lib.immich.noDownload')}
</p>
) : null}
{problem && checkedAt === 'none' ? (
<p className="err" data-key="immich-error">
{problem}
</p>
) : null}
<button type="submit" className="btn primary" data-key="immich-save" disabled={busy}>
{t('name.save')}
</button>
<button type="button" className="btn ghost" data-key="immich-cancel" onClick={onClose}>
{t('name.cancel')}
</button>
</form>
</div>
);
}