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