Files
RecipesCam/docker/frontend/src/ui/ImmichDialog.tsx
T
3dtours 8901e0c378 feat(immich): ask nothing of a server at the door
Adding a server no longer opens with a tick list: a fresh node keeps every
album its key can see — the empty album list the backend stores for "all of
them" — and narrowing is a change made on the node that is already there.
The album-ID field carries the warnings that were silent before it: a line
with no id in it, and an id this key never named, both said where they are
typed. A node's key can now be replaced in the dialog that narrows its
albums; the backend probes it first, so a key that does not answer leaves
the node and the dialog as they were.
2026-10-10 16:38:28 +07:00

365 lines
13 KiB
TypeScript

import { useEffect, useMemo, 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.
//
// Adding asks for as little as it can: the name, the address, the key, and
// "check the key" (which stores nothing, so a typo is never a row). A fresh
// server is **every** album its key can see — an empty album list is that
// decision, not a missing value — so there is nothing to tick; a reader who wants
// fewer names them in the album-ID field. The tick list belongs to the node that
// is already there, where narrowing is a change rather than a choice at the door.
//
// 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.
// The shape an album id has, wherever a reader copied it from: an id, or the
// whole `…/albums/<id>` address they were looking at when they copied it.
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
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('');
// The key a node is being given in place of the one it has. Blank means the
// question was not asked — the node keeps the key it already answered with — so
// a reader who only came to retick an album never has to paste one again.
const [newKey, setNewKey] = 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) return;
setAlbums(rows);
// A node that keeps every album keeps the empty list — that is what "all of
// them" is stored as — so the ticks it opens with are every name the key has,
// not none. Without this, opening the dialog to change one album would mean
// "untick all", which is the same list only spelled the other way.
if (!server.albums?.length) setTicked(new Set(rows.map((a) => a.id)));
});
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 hit of idField.hits) out.add(hit);
return [...out];
};
// What the field says, read once for both the answer and the two things worth
// saying about it. Ids the key cannot see are kept rather than dropped — a
// reader who pasted one knows something the list does not, and a key too narrow
// to read albums can only be told by the reader which albums it may open.
const idField = useMemo(() => {
const lines = ids
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean);
const hits = lines.map((s) => s.match(UUID)?.[0].toLowerCase()).filter((v): v is string => Boolean(v));
const seen = new Set((albums ?? []).map((a) => a.id.toLowerCase()));
return {
hits,
// A line with no id in it at all is a line that will not be part of the
// answer — said out loud, because a silently dropped pasting is a reader
// watching an album not appear.
bad: lines.length - hits.length,
// Only when the key's own list is in hand: an album a key cannot list is one
// this dialog has no word on, and a warning about it would be a guess.
unknown: albums?.length ? [...new Set(hits)].filter((id) => !seen.has(id)) : [],
};
}, [albums, ids]);
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);
// The list is not shown at the door — a fresh server keeps everything its key
// can see — it is only here so an id the reader pasted can be told apart from
// one this key never named.
setAlbums(res.albums);
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setProblem(null);
if (editing && server) {
setBusy(true);
try {
// The key, when one was given, goes up on its own call — the backend
// probes before it stores, so a key that does not answer leaves this open
// with the node it had.
let saved: ImmichServer | null = null;
if (newKey.trim()) {
const res = await saveImmichServer({ id: server.id, key: newKey.trim() });
if (!res.ok) {
setProblem(why(res.error));
return;
}
saved = res.server;
}
const kept = await saveImmichAlbums(server.id, chosen());
onSaved({ ...(saved ?? 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}
</>
)}
{editing && 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)}
/>
{idField.bad > 0 ? (
<p className="err" data-key="immich-bad-ids">
{t('lib.immich.badIds', { n: idField.bad })}
</p>
) : null}
{idField.unknown.length ? (
<p className="err" data-key="immich-unknown-ids">
{t('lib.immich.unknownAlbum', { id: idField.unknown[0].slice(0, 8) })}
</p>
) : null}
</>
) : null}
{editing && server ? (
<>
<label className="hint" htmlFor="immich-newkey">
{t('lib.immich.newKey')}
</label>
<input
id="immich-newkey"
className="field"
data-key="immich-newkey"
type="password"
value={newKey}
autoComplete="off"
onChange={(e) => setNewKey(e.target.value)}
/>
</>
) : null}
{canDownload === false ? (
<p className="err" data-key="immich-nodownload">
{t('lib.immich.noDownload')}
</p>
) : null}
{problem && checkedAt !== 'fail' ? (
<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>
);
}