web: three panels — Generate, Gallery, Collections — and a layout

ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel
contract only ever hands a component its panelId) or usePanelChannel-as-selection:

- Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names
  since both panels render on the same route at once
- Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes;
  Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel

images render through GET /files/:filename with a ?token= query param appended client-side
(lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this:
a browser cannot set an Authorization header on an <img src>.

Providers is not here — see PLUGIN.md for why it became a platform settings screen instead.

assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see
assets/PLACEHOLDER.md.
This commit is contained in:
2026-08-20 04:01:10 +00:00
parent f23ec1283b
commit 859fb5767c
14 changed files with 1211 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useAtelierRefresh } from 'officerdev';
import { useAtelierClient } from '../lib/api';
import type { JobResponse, ProviderStatus } from '../lib/api';
export const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
export const useGenerateManager = () => {
const client = useAtelierClient();
const [, bumpRefresh] = useAtelierRefresh();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState('');
const [n, setN] = useState(1);
const [size, setSize] = useState<string>('auto');
const [quality, setQuality] = useState<string>('auto');
const [background, setBackground] = useState<string>('auto');
const [job, setJob] = useState<JobResponse | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
client
.listProviders()
.then((rows) => {
setProviders(rows);
const usable =
rows.find((row) => row.is_default && row.configured && row.enabled) ??
rows.find((row) => row.configured && row.enabled);
if (usable) setModel(usable.model);
})
.catch((err: Error) => setError(err.message));
// Loaded once on mount — the providers screen (Settings → Integrations → Atelier) is where they
// are managed, and this panel does not need to react to edits made there in real time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Derived in the hook, not in the component.
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
// One model per configured provider, deduped: two providers on the same model is legitimate (two
// keys, two accounts) and the request picks between them by model alone.
const models = [...new Set(usableProviders.map((provider) => provider.model))];
const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
async function generate() {
if (!ready) return;
setRunning(true);
setError(null);
setJob(null);
try {
// Only what the user actually chose. `auto` is the provider's own default, so sending it would
// be inventing a parameter they did not set.
const body: Record<string, unknown> = { prompt: prompt.trim(), model, n };
if (size !== 'auto') body.size = size;
if (quality !== 'auto') body.quality = quality;
if (background !== 'auto') body.background = background;
const { id } = await client.startGeneration(body);
// Long-poll in a loop: each call waits up to 30s server-side, so this is one request per half
// minute rather than a busy poll, and it survives a generation that takes longer.
for (;;) {
const result = await client.awaitGeneration(id, 30);
if (result.atelier && ['succeeded', 'failed', 'partial'].includes(result.atelier.status)) {
setJob(result);
bumpRefresh();
return;
}
setJob(result);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunning(false);
}
}
return {
providers,
usableProviders,
models,
model,
setModel,
prompt,
setPrompt,
n,
setN,
size,
setSize,
quality,
setQuality,
background,
setBackground,
job,
running,
error,
ready,
generate,
};
};
export type GenerateManager = ReturnType<typeof useGenerateManager>;