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. +

    + )} +