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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user