One button per item in the inventory editor, beside the per-thumbnail delete buttons rather than on them, because an upload is one item and its photos are views of one thing. Its label is derived from the images rather than stored: Restore originals when every photo already carries an original, Remove backgrounds otherwise. The otherwise deliberately covers the mixed state a partial failure leaves behind — with two of four cut out it reads Remove backgrounds, which is the action that finishes the job, and pressing it skips the two that already worked. Rendering is gated on the feature being configured or every photo already being cut out, not on the flag alone. Gating on the flag would hide Restore originals the moment REMBG_URL is unset, stranding cut-out photos with no way back — the same reasoning the review queue's control already uses. The editor is a modal and this changes files on the server while it is open, so the item is re-read afterwards and the open modal updated. Without that the thumbnails keep showing the previous files and the button looks like it did nothing, which is the bug this was most likely to ship with. frontend/src/api.ts gains original_image_path on the shared Item type's images, since ADMIN_ITEM_SELECT's images aggregate carries it and the public catalogue's does not. It is added as optional rather than required because Item is the same type fetchItems() uses for the public storefront, and a required field the public response never sends would be a type that lies about what is actually there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
6.9 KiB
TypeScript
Executable File
210 lines
6.9 KiB
TypeScript
Executable File
import type { ItemFilters } from './filters';
|
|
import { filtersToSearchParams } from './filters';
|
|
import { setUploadsBase } from './uploadUrl';
|
|
|
|
export interface ItemTag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface Item {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
price_cents: number;
|
|
images: {
|
|
id: number;
|
|
image_path: string;
|
|
sort_order: number;
|
|
// Present on admin responses only — ADMIN_ITEM_SELECT's images aggregate
|
|
// carries it, PUBLIC_ITEM_SELECT's does not — so it stays optional on this
|
|
// shared type rather than a lie the public fetchItems() response can't back up.
|
|
original_image_path?: string | null;
|
|
}[];
|
|
status: 'pending' | 'available' | 'reserved' | 'sold';
|
|
category_id: number | null;
|
|
category_name: string | null;
|
|
tags: ItemTag[];
|
|
}
|
|
|
|
export interface Category {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
sort_order: number;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface Tag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface FilterOptions {
|
|
categories: Category[];
|
|
tags: Tag[];
|
|
priceRange: { min_cents: number; max_cents: number };
|
|
}
|
|
|
|
export interface SiteConfig {
|
|
paypalClientId: string | null;
|
|
demoMode: boolean;
|
|
currency: string;
|
|
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
|
uploadsBaseUrl: string;
|
|
}
|
|
|
|
export async function fetchConfig(): Promise<SiteConfig> {
|
|
const res = await fetch('/api/config');
|
|
const config = (await res.json()) as SiteConfig;
|
|
// Applied here rather than by each caller, so no caller can fetch the config
|
|
// and forget to — the uploads origin is a property of the deployment, not of
|
|
// whichever screen happened to ask for it.
|
|
setUploadsBase(config.uploadsBaseUrl);
|
|
return config;
|
|
}
|
|
|
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
|
// An error response still parses as JSON — as `{ error: ... }`, not an array.
|
|
// Returning that unchecked would set it as the item list and crash the grid
|
|
// on `.map`, so a failure has to surface as a rejection the caller can show.
|
|
if (!res.ok) throw new Error('failed to load items');
|
|
return res.json();
|
|
}
|
|
|
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
|
const res = await fetch('/api/filters');
|
|
if (!res.ok) throw new Error('failed to load filters');
|
|
return res.json();
|
|
}
|
|
|
|
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
|
|
// resolves — the caller then reports success for a write that never happened,
|
|
// which is worse than failing outright because nothing prompts the user to look
|
|
// for the missing row.
|
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
|
if (res.ok) return res;
|
|
const detail = await res.json().catch(() => null);
|
|
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
|
}
|
|
|
|
export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await expectOk(
|
|
await fetch(query ? `/api/admin/items?${query}` : '/api/admin/items'),
|
|
'failed to load items'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
|
|
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
|
|
const res = await expectOk(
|
|
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
|
|
'failed to save item'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function deleteItem(id: number): Promise<void> {
|
|
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
|
|
}
|
|
|
|
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
|
|
await expectOk(
|
|
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
|
|
'failed to remove image'
|
|
);
|
|
}
|
|
|
|
export async function markSold(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
|
|
'failed to mark sold'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Publishing a pending item is mark-available: it is the same transition and
|
|
// the same UPDATE, so the admin UI simply labels the button "Publish" when the
|
|
// item is pending rather than calling a second endpoint that does the same
|
|
// thing.
|
|
export async function markAvailable(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
|
|
'failed to mark available'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Not symmetrical with the above: the server refuses to unpublish a reserved or
|
|
// sold item and says which, so the message it returns is worth surfacing rather
|
|
// than replacing with a generic one.
|
|
export async function unpublishItem(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/unpublish`, { method: 'POST' }),
|
|
'failed to unpublish'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// The admin endpoints return a JSON error body on 4xx; surfacing its message
|
|
// lets the UI say "that name is already used here" instead of a generic
|
|
// failure.
|
|
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.json().catch(() => ({ error: 'request failed' }));
|
|
throw new Error(detail.error || 'request failed');
|
|
}
|
|
return res.status === 204 ? (undefined as T) : res.json();
|
|
}
|
|
|
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
|
const res = await fetch('/api/admin/categories');
|
|
return res.json();
|
|
}
|
|
|
|
export function createCategory(name: string, parentId: number | null): Promise<Category> {
|
|
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
|
|
}
|
|
|
|
export function updateCategory(
|
|
id: number,
|
|
changes: { name?: string; parent_id?: number | null }
|
|
): Promise<Category> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteCategory(
|
|
id: number
|
|
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
|
|
}
|
|
|
|
export async function fetchAdminTags(): Promise<Tag[]> {
|
|
const res = await fetch('/api/admin/tags');
|
|
return res.json();
|
|
}
|
|
|
|
export function createTag(name: string): Promise<Tag> {
|
|
return sendJson('/api/admin/tags', 'POST', { name });
|
|
}
|
|
|
|
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteTag(id: number): Promise<void> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
|
|
}
|