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:
+68
-20
@@ -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 <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
|
||||
if (manager.error) return <p className="p-4 text-sm text-destructive">{manager.error}</p>;
|
||||
|
||||
if (manager.rows.length === 0) {
|
||||
return <p className="p-4 text-sm text-muted-foreground">Nothing generated yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-6 overflow-y-auto p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
|
||||
{manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
{manager.collection && (
|
||||
<>
|
||||
<h2 className="truncate text-lg font-semibold">{manager.collection.name}</h2>
|
||||
<Link to={allImagesTo} className="shrink-0 text-sm text-muted-foreground hover:text-foreground">
|
||||
All images
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<p className="shrink-0 text-sm text-muted-foreground">
|
||||
{manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
|
||||
{manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void manager.refresh()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{manager.rows.map((row) => (
|
||||
<Tile key={row.id} row={row} searchParams={searchParams} />
|
||||
))}
|
||||
</div>
|
||||
{manager.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{manager.collection ? 'Nothing filed here yet.' : 'Nothing generated yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{manager.rows.map((row) => (
|
||||
<Tile
|
||||
key={row.id}
|
||||
row={row}
|
||||
searchParams={searchParams}
|
||||
onRemove={manager.collection ? () => void manager.removeFromCollection(row.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Lightbox manager={lightbox} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<figure className="space-y-2">
|
||||
<figure className="group relative space-y-2">
|
||||
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-lg border border-border bg-muted/30">
|
||||
{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
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Only in collection mode: "remove" means remove from THIS collection, and on the all-images
|
||||
view there is nothing to remove it from. */}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove from this collection"
|
||||
className="absolute right-2 top-2 rounded bg-black/60 p-1.5 text-white/70 opacity-0 transition group-hover:opacity-100 hover:text-white"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<figcaption className="space-y-1">
|
||||
<p className="line-clamp-2 text-xs text-foreground/80">{row.prompt}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -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<GenerationRow[]>([]);
|
||||
const [collection, setCollection] = useState<CollectionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<typeof useGalleryManager>;
|
||||
|
||||
Reference in New Issue
Block a user