8901e0c378
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.
365 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|