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/` 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/` 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(null); const [ticked, setTicked] = useState>(() => new Set(server?.albums ?? [])); const [ids, setIds] = useState(''); const [canDownload, setCanDownload] = useState(server?.canDownload ?? null); const [checkedAt, setCheckedAt] = useState<'none' | 'ok' | 'fail'>(editing ? 'ok' : 'none'); const [problem, setProblem] = useState(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 (
e.target === e.currentTarget && onClose()}>

{editing ? t('lib.immich.albumsChoose') : t('lib.immich.title')}

{editing && server ? (

{server.url} · {server.keyMasked}

) : ( <> setName(e.target.value)} /> setUrl(e.target.value)} /> setKey(e.target.value)} />

{t('lib.immich.perms')}

{checkedAt !== 'none' ? (

{checkedAt === 'ok' ? t('lib.immich.ok') : problem}

) : null} )} {editing && albums && !share ? ( <>

{albums.length ? t('lib.immich.albumsPick') : t('lib.immich.empty')}

    {albums.map((a) => (
  • ))}
) : null} {!share ? ( <>