feat(library): a cloud account is changed where the cloud accounts are listed

The dialog knew two shapes: an add form with the saved links above it, and an
album-only view that hid the form. So a saved server could be retick'd but never
renamed, and the duplicate-address warning was only readable from the add side.

One shape now. The list is always drawn, every row carries SỬA beside
REMOVE THE LINK, and SỬA puts that server's name and address in the fields below
— prefill, edit, save. A blank key is the one that server already answers by, so
a rename does not ask for it again, and the backend's single PUT probes before it
stores either way. The desktop bar's button says TÀI KHOẢN CLOUD once there is an
account to manage, and THÊM IMMICH while there is not.
This commit is contained in:
2026-10-12 14:57:00 +07:00
parent a00c9992d7
commit adcc6eb67b
4 changed files with 160 additions and 123 deletions
+46 -22
View File
@@ -394,7 +394,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const [servers, setServers] = useState<ImmichServer[]>([]);
const [serverYears, setServerYears] = useState<number[]>([]);
const [addImmich, setAddImmich] = useState(false);
const [editAlbums, setEditAlbums] = useState<LibraryFolder | null>(null);
const [editServer, setEditServer] = useState<string | null>(null);
useEffect(() => {
if (!servers.length) return;
@@ -1371,24 +1371,28 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
[folders, reload]
);
// A server that has just been saved, or one whose albums have been reticked. A
// new server becomes a folder on the column the moment it is saved: nothing
// walks it, so there is nothing to wait for. An existing node keeps its name and
// its frames, which the sync below reads again for the albums it now keeps.
// A server that has just been saved, or one whose name, address or albums have
// just been changed. A new server becomes a folder on the column the moment it is
// saved: nothing walks it, so there is nothing to wait for. An existing one keeps
// its frames, and every node reading by it is walked again for the albums it now
// keeps — which is why the folder's own copy of that list has to be written before
// the sync reads it.
const serverSaved = useCallback(
async (saved: ImmichServer, isNew: boolean) => {
setAddImmich(false);
const edited = editAlbums;
setEditAlbums(null);
setEditServer(null);
// The saved list is read back rather than patched: the backend is what
// holds the key, and what it says is what the dialog will show next time.
const config = await immichConfig(true);
setServers(config.servers);
if (!isNew && edited) {
const next: LibraryFolder = { ...edited, albums: saved.albums };
await putFolder(next);
setFolders((prev) => prev.map((f) => (f.name === next.name ? next : f)));
await syncServer(next);
if (!isNew) {
for (const f of await listFolders()) {
if (f.server !== saved.id) continue;
const next: LibraryFolder = { ...f, albums: saved.albums };
await putFolder(next);
await syncServer(next);
}
setFolders(await listFolders());
return;
}
const current = await listFolders();
@@ -1398,7 +1402,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
setNode(folder.name);
await syncServer(folder);
},
[editAlbums, syncServer]
[syncServer]
);
// The shelf's search box, run. What is typed is a name or a tag, and Immich is
@@ -2509,7 +2513,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
admin={admin}
onOpenStudio={open}
onAddImmich={() => setAddImmich(true)}
onEditAlbums={(folder) => setEditAlbums(folder)}
onEditAlbums={(folder) => setEditServer(folder.server ?? null)}
onSyncServer={syncServer}
onDisconnectServer={disconnect}
onFindImmich={findImmich}
@@ -2528,16 +2532,26 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
{addImmich ? (
<ImmichDialog
servers={servers}
onEdit={(id) => {
setAddImmich(false);
setEditServer(id);
}}
onForget={(id) => void forgetLink(id)}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
) : null}
{editAlbums ? (
{editServer ? (
<ImmichDialog
server={servers.find((s) => s.id === editAlbums.server) ?? null}
server={servers.find((s) => s.id === editServer) ?? null}
servers={servers}
onEdit={setEditServer}
onForget={(id) => {
setEditServer(null);
void forgetLink(id);
}}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setEditAlbums(null)}
onClose={() => setEditServer(null)}
/>
) : null}
</>
@@ -2599,7 +2613,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
data-key="lib-add-immich"
onClick={() => setAddImmich(true)}
>
{t('lib.immich.add')}
{servers.length ? t('lib.cloud.manage') : t('lib.immich.add')}
</button>
{live || scanQueue().length ? (
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
@@ -3403,7 +3417,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
className="lib-menu-item"
data-key={`lib-immich-albums-${menu.folder.name}`}
onClick={() => {
setEditAlbums(menu.folder!);
setEditServer(menu.folder!.server ?? null);
setMenu(null);
}}
>
@@ -3726,16 +3740,26 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
{addImmich ? (
<ImmichDialog
servers={servers}
onEdit={(id) => {
setAddImmich(false);
setEditServer(id);
}}
onForget={(id) => void forgetLink(id)}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
) : null}
{editAlbums ? (
{editServer ? (
<ImmichDialog
server={servers.find((s) => s.id === editAlbums.server) ?? null}
server={servers.find((s) => s.id === editServer) ?? null}
servers={servers}
onEdit={setEditServer}
onForget={(id) => {
setEditServer(null);
void forgetLink(id);
}}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setEditAlbums(null)}
onClose={() => setEditServer(null)}
/>
) : null}
+3
View File
@@ -366,6 +366,9 @@ export const en: Dict = {
// the backend's own routes. The key never reaches this page.
'lib.immich.add': 'ADD IMMICH',
'lib.immich.title': 'ADD AN IMMICH SERVER',
'lib.cloud.manage': 'CLOUD ACCOUNTS',
'lib.immich.editTitle': 'EDIT A CLOUD ACCOUNT',
'lib.immich.edit': 'EDIT',
'lib.immich.albumsChoose': 'CHOOSE ALBUMS',
'lib.immich.name': 'Name shown',
'lib.immich.nameHint': 'Leave blank to use the hostname',
+3
View File
@@ -378,6 +378,9 @@ export const vi = {
// lưu, đọc qua các route của backend. Khoá không bao giờ xuống trang này.
'lib.immich.add': 'THÊM IMMICH',
'lib.immich.title': 'THÊM MÁY CHỦ IMMICH',
'lib.cloud.manage': 'TÀI KHOẢN CLOUD',
'lib.immich.editTitle': 'SỬA TÀI KHOẢN CLOUD',
'lib.immich.edit': 'SỬA',
'lib.immich.albumsChoose': 'CHỌN ALBUM',
'lib.immich.name': 'Tên hiển thị',
'lib.immich.nameHint': 'Bỏ trống để lấy tên miền',
+108 -101
View File
@@ -3,7 +3,6 @@ import { useI18n } from '../i18n/I18nProvider';
import {
listImmichAlbums,
probeImmich,
saveImmichAlbums,
saveImmichServer,
type ImmichAlbum,
type ImmichError,
@@ -33,12 +32,14 @@ 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,
servers = [],
onEdit,
onForget,
onSaved,
onClose,
}: {
server?: ImmichServer | null;
servers?: ImmichServer[];
onEdit?: (id: string) => void;
onForget?: (id: string) => void;
onSaved: (saved: ImmichServer, isNew: boolean) => void;
onClose: () => void;
@@ -48,10 +49,10 @@ export function ImmichDialog({
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
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('');
// The key the form is holding. For a server being added that is the key itself;
// for one already saved it is the *new* key, and blank means the question was not
// asked — the node keeps the key it already answered with — so a reader who only
// came to change a name or an album never has to paste one again.
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 ?? []));
@@ -151,20 +152,23 @@ export function ImmichDialog({
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;
// One call for the whole change: the name, the address, the albums, and the
// key when one was given. The backend probes what it is about to store, so a
// key that does not answer, or an address that is not there, leaves this open
// with the node it had. A blank name or address is the one that server
// already answers by, not an empty answer.
const res = await saveImmichServer({
id: server.id,
name: name.trim() || server.name,
url: url.trim() || server.url,
key: key.trim() || undefined,
albums: share ? [] : chosen(),
});
if (!res.ok) {
setProblem(why(res.error));
return;
}
const kept = await saveImmichAlbums(server.id, chosen());
onSaved({ ...(saved ?? server), albums: kept }, false);
onSaved(res.server, false);
} finally {
setBusy(false);
}
@@ -197,78 +201,98 @@ export function ImmichDialog({
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>
<h2>{editing ? t('lib.immich.editTitle') : servers.length ? t('lib.cloud.manage') : t('lib.immich.title')}</h2>
{editing && server ? (
<p className="hint" data-key="immich-where">
{server.url} · {server.keyMasked}
</p>
) : (
) : null}
{/* What the account already reads by: one row per saved address, and the
address is what makes two rows one server. Two links to the same Immich
read the same library twice, so the row says so and the way out is here
rather than in a node's own menu — a duplicate is a fact about the list,
not about a folder. The row is also the way back into a server that is
already saved: SỬA puts it in the fields below, address and all. */}
{servers.length ? (
<div data-key="immich-links">
<p className="hint">{t('lib.immich.links')}</p>
<ul className="immich-albums">
{servers.map((s) => {
const twin = servers.some((o) => o.id !== s.id && o.url === s.url);
return (
<li key={s.id} data-key={`immich-link-${s.id}`}>
{s.name} · {s.url} ({s.albums?.length ?? 0})
{twin ? <span className="err" data-key="immich-dupe"> {t('lib.immich.dup')}</span> : null}
<button
type="button"
className="btn ghost"
data-key={`immich-edit-${s.id}`}
disabled={s.id === server?.id}
onClick={() => onEdit?.(s.id)}
>
{t('lib.immich.edit')}
</button>
<button
type="button"
className="btn ghost"
data-key={`immich-forget-${s.id}`}
onClick={() => onForget?.(s.id)}
>
{t('lib.immich.linkForget')}
</button>
</li>
);
})}
</ul>
</div>
) : null}
{/* The three answers are the same at the door and behind it: a server being
added and one being changed are the same name, address and key. The one
difference is the key — a saved server already has one, so what is typed
here is the *new* one, and blank means the question was not asked. */}
<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(editing ? 'lib.immich.newKey' : '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)}
/>
{/* Checking a key is the door's own question — a saved server has already
answered it, and the save below probes again anyway. */}
{editing ? null : (
<>
{/* What the account already reads by: one row per saved address, and
the address is what makes two rows one server. Two links to the
same Immich read the same library twice, so the row says so and
the way out is here rather than in a node's own menu — a duplicate
is a fact about the list, not about a folder. */}
{servers.length ? (
<div data-key="immich-links">
<p className="hint">{t('lib.immich.links')}</p>
<ul className="immich-albums">
{servers.map((s) => {
const twin = servers.some((o) => o.id !== s.id && o.url === s.url);
return (
<li key={s.id} data-key={`immich-link-${s.id}`}>
{s.name} · {s.url} ({s.albums?.length ?? 0})
{twin ? <span className="err" data-key="immich-dupe"> {t('lib.immich.dup')}</span> : null}
<button
type="button"
className="btn ghost"
data-key={`immich-forget-${s.id}`}
onClick={() => onForget?.(s.id)}
>
{t('lib.immich.linkForget')}
</button>
</li>
);
})}
</ul>
</div>
) : null}
<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"
@@ -355,23 +379,6 @@ export function ImmichDialog({
</>
) : 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')}