refactor(web): two panels on the left, the work on the right

Four panels became three, and the arrangement carries the change: everything
that organises or configures is stacked in one narrow column, and the whole of
the rest is one surface for looking at images.

Collections is a LIST, not a grid of covers — it sits in a narrow column beside
the gallery, where two cards per row meant four collections visible. A row is a
name, a count, and rename/delete on hover. Create is a dialog rather than an
always-present input, and a filter field replaces it in that space, since
finding one is the frequent act and making one is not.

Selecting a collection now shows it in the GALLERY. It used to replace the list
inside its own panel, which meant the selector disappeared the moment it was
used, and both panels kept their own copy of one collection's images and
disagreed after any edit. `?collection=` is read by the gallery now; the
collections hook stopped fetching detail entirely.

Deleting asks the question that cannot be undone: keep the images, or delete
them too. That needed a server route — DELETE /collections/:id?images=delete —
which reads the members BEFORE the collection goes, since deleting it takes the
memberships with it. Unchecked by default: the recoverable answer is the one
you get by pressing Enter without reading.

Providers stopped being a panel. It was a card per provider, each with a key
field and three buttons, filling a quarter of the screen for something set once
— it is a dropdown at the top of Generate now, listing "adapter · model" with a
delete on each, and adding one opens a dialog. The form and the dropdown share
one provider list (useProviders); they were two fetches, so adding a provider
left the form showing the old models until a reload.
This commit is contained in:
2026-08-20 21:34:34 +01:00
parent 4533ef03ec
commit 82ca696ccf
15 changed files with 834 additions and 516 deletions
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useState } from 'react';
import { useAtelierClient } from '../lib/api';
import type { Adapter, ProviderStatus } from '../lib/api';
/**
* Every provider, and the verbs that change them.
*
* Owned here rather than inside useGenerateManager because two things need the same list and it must
* not exist twice: the dropdown at the top of the panel, and the form below it whose model choices are
* derived from whichever providers actually have a key. Adding one used to mean reloading the page
* before the form noticed.
*/
export const useProviders = () => {
const client = useAtelierClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const [rows, catalogue] = await Promise.all([client.listProviders(), client.listAdapters()]);
setProviders(rows);
setAdapters(catalogue);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const guard = async (action: () => Promise<ProviderStatus[]>) => {
setBusy(true);
try {
setProviders(await action());
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setBusy(false);
}
};
/**
* Create, then set the key if one was typed.
*
* Two calls because they are two things on the server: a provider row is public configuration, a key
* is an encrypted secret that is never sent back. A provider added without a key is legitimate — the
* key may live in the environment — so the second call is skipped rather than required.
*/
const add = async (adapter: string, model: string, apiKey?: string) =>
guard(async () => {
const rows = await client.createProvider(adapter, model);
const created = rows.find((row) => row.adapter === adapter && row.model === model);
if (!apiKey?.trim() || !created) return rows;
return client.updateProvider(created.id, { apiKey: apiKey.trim() });
});
return {
providers,
adapters,
loading,
busy,
error,
refresh,
add,
setKey: (id: string, apiKey: string) => guard(() => client.updateProvider(id, { apiKey: apiKey.trim() })),
/** The one a generation will use when the request names only a model. */
makeDefault: (id: string) => guard(() => client.updateProvider(id, { isDefault: true, enabled: true })),
remove: (id: string) => guard(() => client.deleteProvider(id)),
modelsFor: (adapter: string) => adapters.find((entry) => entry.name === adapter)?.models ?? [],
adapterFor: (adapter: string) => adapters.find((entry) => entry.name === adapter),
};
};
export type ProvidersManager = ReturnType<typeof useProviders>;