Monorepo with Express + SQLite backend and Vite + React frontend. Features: item CRUD, file uploads with thumbnails, soft delete, item duplication with file copying, autocomplete inputs, stats dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
import type { Item, CreateItemBody, UpdateItemBody, StatsResponse, ItemFile } from './types';
|
|
|
|
const BASE = '/api';
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...init,
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// Items
|
|
listItems(params?: Record<string, string>): Promise<Item[]> {
|
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
|
return request(`/items${qs}`);
|
|
},
|
|
getItem(id: string): Promise<Item & { files: ItemFile[] }> {
|
|
return request(`/items/${id}`);
|
|
},
|
|
createItem(body: CreateItemBody): Promise<Item> {
|
|
return request('/items', { method: 'POST', body: JSON.stringify(body) });
|
|
},
|
|
updateItem(id: string, body: UpdateItemBody): Promise<Item> {
|
|
return request(`/items/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
},
|
|
deleteItem(id: string): Promise<void> {
|
|
return request(`/items/${id}`, { method: 'DELETE' });
|
|
},
|
|
restoreItem(id: string): Promise<Item> {
|
|
return request(`/items/${id}/restore`, { method: 'POST' });
|
|
},
|
|
permanentDeleteItem(id: string): Promise<void> {
|
|
return request(`/items/${id}/permanent`, { method: 'DELETE' });
|
|
},
|
|
duplicateItem(id: string): Promise<Item> {
|
|
return request(`/items/${id}/duplicate`, { method: 'POST' });
|
|
},
|
|
|
|
bulkUpdateItems(ids: string[], updates: { category?: string; location?: string; status?: string; flagged?: number }): Promise<{ updated: number }> {
|
|
return request('/items/bulk', { method: 'PATCH', body: JSON.stringify({ ids, updates }) });
|
|
},
|
|
|
|
// Suggestions
|
|
getCategories(): Promise<string[]> {
|
|
return request('/suggestions/categories');
|
|
},
|
|
getLocations(): Promise<string[]> {
|
|
return request('/suggestions/locations');
|
|
},
|
|
|
|
// Files
|
|
async uploadFiles(itemId: string, files: File[], type: 'photo' | 'receipt'): Promise<ItemFile[]> {
|
|
const form = new FormData();
|
|
form.append('type', type);
|
|
for (const f of files) form.append('files', f);
|
|
const res = await fetch(`${BASE}/items/${itemId}/files`, { method: 'POST', body: form });
|
|
if (!res.ok) throw new Error('Upload failed');
|
|
return res.json();
|
|
},
|
|
deleteFile(fileId: string): Promise<void> {
|
|
return request(`/files/${fileId}`, { method: 'DELETE' });
|
|
},
|
|
setFeaturedPhoto(itemId: string, fileId: string): Promise<void> {
|
|
return request(`/items/${itemId}/files/${fileId}/featured`, { method: 'PUT' });
|
|
},
|
|
|
|
// Stats
|
|
getStats(): Promise<StatsResponse> {
|
|
return request('/stats');
|
|
},
|
|
};
|
|
|
|
// Image variant URL helpers
|
|
function variantUrl(storedName: string, suffix: string): string {
|
|
const dot = storedName.lastIndexOf('.');
|
|
return `/api/uploads/${storedName.slice(0, dot)}${suffix}${storedName.slice(dot)}`;
|
|
}
|
|
|
|
export function thumbUrl(storedName: string): string {
|
|
return variantUrl(storedName, '_thumb');
|
|
}
|
|
|
|
export function largeUrl(storedName: string): string {
|
|
return variantUrl(storedName, '_large');
|
|
}
|
|
|
|
export function uploadUrl(storedName: string): string {
|
|
return `/api/uploads/${storedName}`;
|
|
}
|