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.
77 lines
2.4 KiB
TypeScript
77 lines
2.4 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useAtelierClient } from '../lib/api';
|
|
import type { CollectionDetail, CollectionSummary } from '../lib/api';
|
|
|
|
export const useCollectionsManager = (openId?: string) => {
|
|
const client = useAtelierClient();
|
|
const [collections, setCollections] = useState<CollectionSummary[]>([]);
|
|
const [detail, setDetail] = useState<CollectionDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [draftName, setDraftName] = useState('');
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
setCollections(await client.listCollections());
|
|
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]);
|
|
|
|
// The detail view is driven by the URL, so opening one is a navigation and this only follows it.
|
|
useEffect(() => {
|
|
if (!openId) {
|
|
setDetail(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
client
|
|
.getCollection(openId)
|
|
.then((data) => !cancelled && setDetail(data))
|
|
.catch((err: Error) => !cancelled && setError(err.message));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [openId]);
|
|
|
|
const create = async () => {
|
|
const name = draftName.trim();
|
|
if (!name) return;
|
|
try {
|
|
await client.createCollection(name);
|
|
setDraftName('');
|
|
await refresh();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
|
|
const remove = async (id: string) => {
|
|
try {
|
|
await client.deleteCollection(id);
|
|
await refresh();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
|
|
const removeImage = async (collectionId: string, generationId: string) => {
|
|
const { images } = await client.removeFromCollection(collectionId, generationId);
|
|
setDetail((current) => (current ? { ...current, images } : current));
|
|
void refresh();
|
|
};
|
|
|
|
return { collections, detail, loading, error, draftName, setDraftName, create, remove, removeImage, refresh };
|
|
};
|
|
|
|
export type CollectionsManager = ReturnType<typeof useCollectionsManager>;
|