From 82ca696ccf1742b7c493e9b6f8810f02802cb9e0 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 20 Aug 2026 21:34:34 +0100 Subject: [PATCH] refactor(web): two panels on the left, the work on the right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sidecar/api/collections.ts | 39 ++- sidecar/db/generations/queries.ts | 12 + web/Collections/Collections.tsx | 383 +++++++++++++++-------- web/Collections/useCollectionsManager.ts | 75 ++--- web/Gallery/Gallery.tsx | 88 ++++-- web/Gallery/useGalleryManager.ts | 39 ++- web/Generate/Generate.tsx | 25 +- web/Generate/ProvidersBar.tsx | 252 +++++++++++++++ web/Generate/useGenerateManager.ts | 28 +- web/Generate/useProviders.ts | 85 +++++ web/Providers/Providers.tsx | 258 --------------- web/Providers/index.ts | 1 - web/layout.ts | 25 +- web/lib/api.ts | 20 +- web/panels.ts | 20 +- 15 files changed, 834 insertions(+), 516 deletions(-) create mode 100644 web/Generate/ProvidersBar.tsx create mode 100644 web/Generate/useProviders.ts delete mode 100644 web/Providers/Providers.tsx delete mode 100644 web/Providers/index.ts diff --git a/sidecar/api/collections.ts b/sidecar/api/collections.ts index dce08aa..593fdc2 100644 --- a/sidecar/api/collections.ts +++ b/sidecar/api/collections.ts @@ -1,8 +1,11 @@ +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; import { addToCollection, collectionImages, createCollection, deleteCollection, + deleteGenerations, getCollection, getCollectionByName, listCollections, @@ -10,6 +13,7 @@ import { renameCollection, } from '../db'; import { createRouter } from '../create-router'; +import { IMAGES_DIR } from '../env'; import { openaiError, UUID_PATTERN } from '../errors'; import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize'; @@ -89,13 +93,44 @@ collectionsRouter.patch('/:id', async (ctx) => { return ctx.json({ data: updated ? serializeCollection(updated) : null }); }); -/** Removes the collection and its memberships. The images are untouched and stay in the gallery. */ +/** + * Delete a collection. `?images=delete` deletes what was in it as well; the default keeps them. + * + * The two are genuinely different intentions and only the caller knows which one this is. "I filed + * these wrongly" wants the folder gone and the images kept; "this whole afternoon was a dead end" + * wants the images gone too, and doing that by hand is one delete per image. Which is why the + * destructive one has to be asked for by name rather than inferred. + * + * Order matters: read the members BEFORE the collection goes, because deleting it takes the + * memberships with it and there is then nothing left that says which images were in it. + * + * A file that will not unlink is not an error the caller can act on — the row is already gone and + * retrying deletes nothing. It is left on disk and logged rather than failing a delete that, from + * every side the user can see, succeeded. + */ collectionsRouter.delete('/:id', async (ctx) => { const id = ctx.req.param('id'); if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404); + const alsoImages = ctx.req.query('images') === 'delete'; + const members = alsoImages ? await collectionImages(id) : []; + await deleteCollection(id); - return ctx.json({ data: { id, deleted: true } }); + + let deletedImages = 0; + if (alsoImages && members.length > 0) { + const removed = await deleteGenerations(members.map((row) => row.id)); + deletedImages = removed.length; + + for (const row of removed) { + if (!row.imagePath) continue; + await rm(join(IMAGES_DIR, row.imagePath), { force: true }).catch((err: unknown) => { + console.error(`[atelier] could not unlink ${row.imagePath}:`, err); + }); + } + } + + return ctx.json({ data: { id, deleted: true, deletedImages } }); }); /** Add images. Ones already in the collection are counted as added and change nothing. */ diff --git a/sidecar/db/generations/queries.ts b/sidecar/db/generations/queries.ts index 4568021..0b280f4 100644 --- a/sidecar/db/generations/queries.ts +++ b/sidecar/db/generations/queries.ts @@ -20,6 +20,18 @@ export async function listGenerations(limit = 50): Promise { return db.select().from(generations).orderBy(desc(generations.createdAt)).limit(limit); } +/** + * Delete generations outright, returning the rows that went — the caller needs `image_path` to unlink + * the files, and after the delete there is nowhere left to read it from. + * + * `collection_items` rows go with them by foreign key, so an image deleted while filed in three + * collections leaves none of the three holding a dangling membership. + */ +export async function deleteGenerations(ids: string[]): Promise { + if (ids.length === 0) return []; + return db.delete(generations).where(inArray(generations.id, ids)).returning(); +} + /** Every image of one request, in the order it was asked for. This is what polling a job reads. */ export async function getRequest(requestId: string): Promise { return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index)); diff --git a/web/Collections/Collections.tsx b/web/Collections/Collections.tsx index 2120155..fb31558 100644 --- a/web/Collections/Collections.tsx +++ b/web/Collections/Collections.tsx @@ -1,161 +1,274 @@ +import { useState } from 'react'; import { Link, useSearchParams } from 'react-router'; +import { Folder, FolderOpen, Pencil, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { fileUrl } from '../lib/api'; -import type { CollectionSummary, GenerationRow } from '../lib/api'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import type { CollectionSummary } from '../lib/api'; import { useCollectionsManager } from './useCollectionsManager'; import type { CollectionsManager } from './useCollectionsManager'; +/** + * The collection list: a filter, a create button, and one row per collection. + * + * Rows rather than a grid of covers. A grid of thumbnails is a browsing surface and this panel is a + * selector — it lives in a narrow column beside the gallery, where two cards per row means four + * collections visible and a lot of scrolling to find the one you meant. A row is a name, a count and + * its two actions. + * + * Selecting one is a LINK, writing `?collection=`. The Gallery panel reads that param and shows + * the images; nothing here fetches them. That is the whole reason the detail view left this file. + */ export const Collections = () => { + const manager = useCollectionsManager(); const [searchParams] = useSearchParams(); - const openId = searchParams.get('collection') ?? undefined; - const manager = useCollectionsManager(openId); - - if (manager.loading) return

Loading…

; - if (openId) return ; + const selectedId = searchParams.get('collection'); + const [creating, setCreating] = useState(false); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); return ( -
+
manager.setDraftName(ev.target.value)} - onKeyDown={(ev) => ev.key === 'Enter' && manager.create()} + placeholder="Filter collections" + value={manager.query} + onChange={(ev) => manager.setQuery(ev.target.value)} /> -
{manager.error &&

{manager.error}

} - {manager.collections.length === 0 ? ( -

- No collections yet. Create one here, or pass ?collection=name{' '} - when generating and it will be made for you. -

- ) : ( -
- {manager.collections.map((collection) => ( - manager.remove(collection.id)} - /> - ))} -
- )} -
- ); -}; - -type CardProps = { collection: CollectionSummary; searchParams: URLSearchParams; onDelete: () => void }; - -const Card = ({ collection, searchParams, onDelete }: CardProps) => { - const src = fileUrl(collection.cover_url); - const to = (() => { - const next = new URLSearchParams(searchParams); - next.set('collection', collection.id); - return { search: `?${next.toString()}` }; - })(); - - return ( -
- -
- {src ? ( - - ) : ( - empty - )} -
-
-

{collection.name}

-

- {collection.item_count} image{collection.item_count === 1 ? '' : 's'} +

+ {manager.loading ? ( +

Loading…

+ ) : manager.collections.length === 0 ? ( +

+ {manager.total === 0 ? ( + <> + No collections yet. Create one, or pass ?collection=name when + generating and it will be made for you. + + ) : ( + 'Nothing matches that filter.' + )}

-
- - - -
- ); -}; - -type DetailProps = { manager: CollectionsManager; searchParams: URLSearchParams }; - -const Detail = ({ manager, searchParams }: DetailProps) => { - const collection = manager.detail; - - const backTo = (() => { - const next = new URLSearchParams(searchParams); - next.delete('collection'); - const search = next.toString(); - return { search: search ? `?${search}` : '' }; - })(); - - if (!collection) return

Loading…

; - - return ( -
-
- - ‹ Collections - -

{collection.name}

- - {collection.images.length} image{collection.images.length === 1 ? '' : 's'} - -
- - {collection.images.length === 0 ? ( -

- Nothing here yet. Add images from the gallery, or generate with{' '} - ?collection={collection.name}. -

- ) : ( -
- {collection.images.map((image) => ( - manager.removeImage(collection.id, image.id)} /> - ))} -
- )} -
- ); -}; - -const Item = ({ image, onRemove }: { image: GenerationRow; onRemove: () => void }) => { - const src = fileUrl(image.url); - return ( -
-
- {src ? ( - {image.prompt} ) : ( - {image.error ?? image.status} +
    + {manager.collections.map((collection) => ( + setRenaming(collection)} + onDelete={() => setDeleting(collection)} + /> + ))} +
)}
- + setCreating(false)} + onSubmit={(name) => manager.create(name)} + /> -
{image.prompt}
-
+ setRenaming(null)} + onSubmit={(name) => manager.rename(renaming!.id, name)} + /> + + setDeleting(null)} /> +
+ ); +}; + +type RowProps = { + collection: CollectionSummary; + selected: boolean; + searchParams: URLSearchParams; + onRename: () => void; + onDelete: () => void; +}; + +const Row = ({ collection, selected, searchParams, onRename, onDelete }: RowProps) => { + const to = (() => { + const next = new URLSearchParams(searchParams); + next.set('collection', collection.id); + // The lightbox selection belongs to whatever was being looked at before; carrying it into another + // collection opens an image that is not in the list you just switched to. + next.delete('selected'); + return { search: `?${next.toString()}` }; + })(); + + const Icon = selected ? FolderOpen : Folder; + + return ( +
  • + + + {collection.name} + {collection.item_count} + + + {/* Visible on hover, and on keyboard focus — `opacity-0` alone would hide a button you can still + tab to, which is a control nobody can see but everybody can press. */} + + + + +
  • + ); +}; + +type NameDialogProps = { + open: boolean; + title: string; + description?: string; + initial?: string; + confirmLabel: string; + onClose: () => void; + onSubmit: (name: string) => Promise; +}; + +/** Create and rename are the same dialog: one text field, one verb. */ +const NameDialog = ({ open, title, description, initial = '', confirmLabel, onClose, onSubmit }: NameDialogProps) => { + const [name, setName] = useState(initial); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (!name.trim() || busy) return; + setBusy(true); + const ok = await onSubmit(name); + setBusy(false); + if (ok) onClose(); + }; + + return ( + { + if (next) setName(initial); + else onClose(); + }} + > + + + {title} + {description && {description}} + + + {/* A form, so Enter submits. A dialog whose only field ignores Enter reads as broken. */} +
    { + ev.preventDefault(); + void submit(); + }} + > + setName(ev.target.value)} placeholder="Name" /> + + + + +
    +
    +
    + ); +}; + +type DeleteDialogProps = { collection: CollectionSummary | null; manager: CollectionsManager; onClose: () => void }; + +/** + * Deleting a collection asks one further question, because there are two different intentions behind + * it and the wrong guess is unrecoverable: the folder was a mistake (keep the images), or the work in + * it was (take them too). Unchecked by default — the recoverable answer is the one you get by + * pressing Enter without reading. + */ +const DeleteDialog = ({ collection, manager, onClose }: DeleteDialogProps) => { + const [alsoImages, setAlsoImages] = useState(false); + const [busy, setBusy] = useState(false); + const count = collection?.item_count ?? 0; + + const confirm = async () => { + if (!collection || busy) return; + setBusy(true); + const ok = await manager.remove(collection.id, alsoImages); + setBusy(false); + if (ok) onClose(); + }; + + return ( + { + if (next) setAlsoImages(false); + else onClose(); + }} + > + + + Delete “{collection?.name}”? + + {count === 0 + ? 'This collection is empty.' + : `It holds ${count} image${count === 1 ? '' : 's'}. They stay in the gallery unless you say otherwise.`} + + + + {count > 0 && ( + + )} + + + + + + + ); }; diff --git a/web/Collections/useCollectionsManager.ts b/web/Collections/useCollectionsManager.ts index 9beeb47..40570b7 100644 --- a/web/Collections/useCollectionsManager.ts +++ b/web/Collections/useCollectionsManager.ts @@ -1,14 +1,20 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAtelierClient } from '../lib/api'; -import type { CollectionDetail, CollectionSummary } from '../lib/api'; +import type { CollectionSummary } from '../lib/api'; -export const useCollectionsManager = (openId?: string) => { +/** + * The collection LIST — no detail view any more. + * + * A selected collection is rendered by the Gallery panel, which reads the same `?collection=` param. + * This hook stopped fetching the detail when that moved: two panels both holding a copy of one + * collection's images is two things to keep in step, and the narrow list has no use for them. + */ +export const useCollectionsManager = () => { const client = useAtelierClient(); const [collections, setCollections] = useState([]); - const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [draftName, setDraftName] = useState(''); + const [query, setQuery] = useState(''); const refresh = useCallback(async () => { try { @@ -26,51 +32,38 @@ export const useCollectionsManager = (openId?: string) => { 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]); + // Filtering happens here rather than server-side: the list is small enough that a round trip per + // keystroke would be slower than the filter, and a search that cannot go stale needs no refresh. + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return collections; + return collections.filter((collection) => collection.name.toLowerCase().includes(needle)); + }, [collections, query]); - const create = async () => { - const name = draftName.trim(); - if (!name) return; + const guard = async (action: () => Promise) => { try { - await client.createCollection(name); - setDraftName(''); + await action(); + setError(null); await refresh(); + return true; } catch (err) { setError(err instanceof Error ? err.message : String(err)); + return false; } }; - const remove = async (id: string) => { - try { - await client.deleteCollection(id); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } + return { + collections: visible, + total: collections.length, + loading, + error, + query, + setQuery, + refresh, + create: (name: string) => guard(() => client.createCollection(name.trim())), + rename: (id: string, name: string) => guard(() => client.renameCollection(id, name.trim())), + remove: (id: string, deleteImages: boolean) => guard(() => client.deleteCollection(id, deleteImages)), }; - - 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; diff --git a/web/Gallery/Gallery.tsx b/web/Gallery/Gallery.tsx index 660ed67..18d0158 100644 --- a/web/Gallery/Gallery.tsx +++ b/web/Gallery/Gallery.tsx @@ -1,5 +1,5 @@ -import { Link } from 'react-router'; -import { useSearchParams } from 'react-router'; +import { Link, useSearchParams } from 'react-router'; +import { X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { fileUrl } from '../lib/api'; import type { GenerationRow } from '../lib/api'; @@ -7,43 +7,78 @@ import { Lightbox } from './Lightbox'; import { useLightboxManager } from './useLightboxManager'; import { useGalleryManager } from './useGalleryManager'; +/** + * The main surface: every image, or one collection's images when `?collection=` is set. + * + * The collection view is HERE rather than in the Collections panel because that panel is a narrow + * selector — showing a grid of images inside it meant two thumbnails per row, and it replaced the + * list you were selecting from with the thing you selected. + */ export const Gallery = () => { - const manager = useGalleryManager(); + const [searchParams] = useSearchParams(); + const collectionId = searchParams.get('collection') ?? undefined; + const manager = useGalleryManager(collectionId); // Hooks run before any early return, or the order changes between renders. const lightbox = useLightboxManager(manager.rows); - const [searchParams] = useSearchParams(); + + const allImagesTo = (() => { + const next = new URLSearchParams(searchParams); + next.delete('collection'); + next.delete('selected'); + const search = next.toString(); + return { search: search ? `?${search}` : '' }; + })(); if (manager.loading) return

    Loading…

    ; if (manager.error) return

    {manager.error}

    ; - if (manager.rows.length === 0) { - return

    Nothing generated yet.

    ; - } - return (
    -
    -

    - {manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'} - {manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`} -

    +
    +
    + {manager.collection && ( + <> +

    {manager.collection.name}

    + + All images + + + )} +

    + {manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'} + {manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`} +

    +
    -
    - {manager.rows.map((row) => ( - - ))} -
    + {manager.rows.length === 0 ? ( +

    + {manager.collection ? 'Nothing filed here yet.' : 'Nothing generated yet.'} +

    + ) : ( +
    + {manager.rows.map((row) => ( + void manager.removeFromCollection(row.id) : undefined} + /> + ))} +
    + )}
    ); }; -const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSearchParams }) => { +type TileProps = { row: GenerationRow; searchParams: URLSearchParams; onRemove?: () => void }; + +const Tile = ({ row, searchParams, onRemove }: TileProps) => { const src = fileUrl(row.url); const to = (() => { const next = new URLSearchParams(searchParams); @@ -52,7 +87,7 @@ const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSear })(); return ( -
    +
    {src ? ( // A real link, not a button with an onClick — the selection lives in the URL @@ -70,6 +105,19 @@ const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSear )}
    + {/* Only in collection mode: "remove" means remove from THIS collection, and on the all-images + view there is nothing to remove it from. */} + {onRemove && ( + + )} +

    {row.prompt}

    diff --git a/web/Gallery/useGalleryManager.ts b/web/Gallery/useGalleryManager.ts index 043b8c8..2dccf27 100644 --- a/web/Gallery/useGalleryManager.ts +++ b/web/Gallery/useGalleryManager.ts @@ -1,19 +1,33 @@ -import { useCallback, useEffect } from 'react'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useAtelierRefresh } from 'officerdev'; import { useAtelierClient } from '../lib/api'; -import type { GenerationRow } from '../lib/api'; +import type { CollectionDetail, GenerationRow } from '../lib/api'; -export const useGalleryManager = () => { +/** + * What the main panel is showing: everything, or one collection. + * + * One hook for both, because the panel below it is the same grid either way and the only difference is + * where the rows came from. The collection case used to live in the Collections panel, which meant two + * panels each holding their own copy of a collection's images and disagreeing after any edit. + */ +export const useGalleryManager = (collectionId?: string) => { const client = useAtelierClient(); const [rows, setRows] = useState([]); + const [collection, setCollection] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshSignal] = useAtelierRefresh(); const refresh = useCallback(async () => { try { - setRows(await client.listGenerations()); + if (collectionId) { + const detail = await client.getCollection(collectionId); + setCollection(detail); + setRows(detail.images); + } else { + setCollection(null); + setRows(await client.listGenerations()); + } setError(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -21,9 +35,10 @@ export const useGalleryManager = () => { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [collectionId]); useEffect(() => { + setLoading(true); void refresh(); }, [refresh]); @@ -33,10 +48,16 @@ export const useGalleryManager = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshSignal]); + /** Only meaningful in collection mode: takes the image out of this collection, not out of Atelier. */ + const removeFromCollection = async (generationId: string) => { + if (!collectionId) return; + const { images } = await client.removeFromCollection(collectionId, generationId); + setRows(images); + setCollection((current) => (current ? { ...current, images } : current)); + }; + const succeeded = rows.filter((row) => row.status === 'succeeded' && row.image_path); const totalCost = rows.reduce((sum, row) => sum + Number(row.cost_usd ?? 0), 0); - return { rows, succeeded, totalCost, loading, error, refresh }; + return { rows, succeeded, totalCost, collection, loading, error, refresh, removeFromCollection }; }; - -export type GalleryManager = ReturnType; diff --git a/web/Generate/Generate.tsx b/web/Generate/Generate.tsx index 3f1c133..437e861 100644 --- a/web/Generate/Generate.tsx +++ b/web/Generate/Generate.tsx @@ -1,28 +1,29 @@ import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { fileUrl } from '../lib/api'; +import { ProvidersBar } from './ProvidersBar'; +import { useProviders } from './useProviders'; import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager'; export const Generate = () => { - const manager = useGenerateManager(); - - if (manager.providers.length > 0 && manager.usableProviders.length === 0) { - return ( - - - No provider is ready. Add an API key in the Providers panel first. - - - ); - } + const providers = useProviders(); + const manager = useGenerateManager(providers.providers); return (

    + {/* Which provider, then what to make with it — one panel, in that order. */} + + + {providers.providers.length > 0 && manager.usableProviders.length === 0 && ( +

    + No provider is ready. Set an API key from the dropdown above. +

    + )} +