Initial commit - Stashpile v0.1
Smart household management frontend for Grocy servers. Features: - Dashboard with PulseRing status indicator - Shopping list with checkable items - Expiring soon / low stock alerts - Chores summary - Quick add with create product flow - PWA installable Tech: React 19, TypeScript, Vite, TanStack Router, Zustand, Tailwind CSS Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
206
src/api/client.ts
Normal file
206
src/api/client.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { EntityName, ApiError, StockProduct, Product, SystemInfo, User } from '../types/grocy';
|
||||
|
||||
const STORAGE_KEY_SERVER = 'grocy_server_url';
|
||||
const STORAGE_KEY_API = 'grocy_api_key';
|
||||
|
||||
class GrocyApiClient {
|
||||
private serverUrl: string | null = null;
|
||||
private apiKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Load from localStorage on init
|
||||
this.serverUrl = localStorage.getItem(STORAGE_KEY_SERVER);
|
||||
this.apiKey = localStorage.getItem(STORAGE_KEY_API);
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!(this.serverUrl && this.apiKey);
|
||||
}
|
||||
|
||||
getServerUrl(): string | null {
|
||||
return this.serverUrl;
|
||||
}
|
||||
|
||||
configure(serverUrl: string, apiKey: string): void {
|
||||
// Normalize URL - remove trailing slash
|
||||
const normalizedUrl = serverUrl.replace(/\/+$/, '');
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_SERVER, normalizedUrl);
|
||||
localStorage.setItem(STORAGE_KEY_API, apiKey);
|
||||
|
||||
this.serverUrl = normalizedUrl;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
clearConfig(): void {
|
||||
localStorage.removeItem(STORAGE_KEY_SERVER);
|
||||
localStorage.removeItem(STORAGE_KEY_API);
|
||||
this.serverUrl = null;
|
||||
this.apiKey = null;
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
// In development, use Vite proxy (relative URL)
|
||||
// In production or when configured, use absolute URL
|
||||
if (import.meta.env.DEV && !this.serverUrl) {
|
||||
return '';
|
||||
}
|
||||
return this.serverUrl || '';
|
||||
}
|
||||
|
||||
private async request<T>(method: string, endpoint: string, body?: unknown): Promise<T> {
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const url = `${baseUrl}/api${endpoint}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
// Only add API key header if configured
|
||||
if (this.apiKey) {
|
||||
headers['GROCY-API-KEY'] = this.apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(() => ({
|
||||
error_message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}));
|
||||
throw new Error(error.error_message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Generic CRUD
|
||||
async getObjects<T>(entity: EntityName, query?: string): Promise<T[]> {
|
||||
const endpoint = `/objects/${entity}${query ? `?${query}` : ''}`;
|
||||
return this.request<T[]>('GET', endpoint);
|
||||
}
|
||||
|
||||
async getObject<T>(entity: EntityName, id: number): Promise<T> {
|
||||
return this.request<T>('GET', `/objects/${entity}/${id}`);
|
||||
}
|
||||
|
||||
async createObject<T>(entity: EntityName, data: Partial<T>): Promise<{ created_object_id: number }> {
|
||||
return this.request('POST', `/objects/${entity}`, data);
|
||||
}
|
||||
|
||||
async updateObject<T>(entity: EntityName, id: number, data: Partial<T>): Promise<void> {
|
||||
return this.request('PUT', `/objects/${entity}/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteObject(entity: EntityName, id: number): Promise<void> {
|
||||
return this.request('DELETE', `/objects/${entity}/${id}`);
|
||||
}
|
||||
|
||||
// Stock
|
||||
async getStock(): Promise<StockProduct[]> {
|
||||
return this.request<StockProduct[]>('GET', '/stock');
|
||||
}
|
||||
|
||||
async getStockProduct(productId: number): Promise<StockProduct> {
|
||||
return this.request<StockProduct>('GET', `/stock/products/${productId}`);
|
||||
}
|
||||
|
||||
async getProductByBarcode(barcode: string): Promise<{ product: Product }> {
|
||||
return this.request('GET', `/stock/products/by-barcode/${barcode}`);
|
||||
}
|
||||
|
||||
async addProduct(
|
||||
productId: number,
|
||||
amount: number,
|
||||
options?: {
|
||||
best_before_date?: string;
|
||||
price?: number;
|
||||
location_id?: number;
|
||||
}
|
||||
) {
|
||||
return this.request('POST', `/stock/products/${productId}/add`, {
|
||||
amount,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async consumeProduct(
|
||||
productId: number,
|
||||
amount: number,
|
||||
options?: {
|
||||
spoiled?: boolean;
|
||||
stock_entry_id?: string;
|
||||
location_id?: number;
|
||||
}
|
||||
) {
|
||||
return this.request('POST', `/stock/products/${productId}/consume`, {
|
||||
amount,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async openProduct(productId: number, amount: number, stockEntryId?: string) {
|
||||
return this.request('POST', `/stock/products/${productId}/open`, {
|
||||
amount,
|
||||
stock_entry_id: stockEntryId,
|
||||
});
|
||||
}
|
||||
|
||||
// Shopping list
|
||||
async addToShoppingList(productId: number, amount: number, listId = 1, note?: string) {
|
||||
return this.createObject('shopping_list', {
|
||||
product_id: productId,
|
||||
amount,
|
||||
shopping_list_id: listId,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
async clearShoppingList(listId = 1) {
|
||||
return this.request('POST', `/stock/shoppinglist/clear`, { list_id: listId });
|
||||
}
|
||||
|
||||
async addMissingProductsToShoppingList(listId = 1) {
|
||||
return this.request('POST', `/stock/shoppinglist/add-missing-products`, { list_id: listId });
|
||||
}
|
||||
|
||||
// Chores
|
||||
async getChores(): Promise<Array<{ chore_id: number; chore_name: string; next_estimated_execution_time: string | null; next_execution_assigned_to_user_id: number | null; last_tracked_time: string | null; track_count: number }>> {
|
||||
return this.request('GET', '/chores');
|
||||
}
|
||||
|
||||
async executeChore(choreId: number, trackedTime?: string): Promise<void> {
|
||||
return this.request('POST', `/chores/${choreId}/execute`, {
|
||||
tracked_time: trackedTime || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// System
|
||||
async getSystemInfo(): Promise<SystemInfo> {
|
||||
return this.request<SystemInfo>('GET', '/system/info');
|
||||
}
|
||||
|
||||
async getDbChangedTime(): Promise<{ changed_time: string }> {
|
||||
return this.request('GET', '/system/db-changed-time');
|
||||
}
|
||||
|
||||
// User
|
||||
async getCurrentUser(): Promise<User> {
|
||||
return this.request<User>('GET', '/user');
|
||||
}
|
||||
|
||||
async getUserSettings(): Promise<Record<string, unknown>> {
|
||||
return this.request('GET', '/user/settings');
|
||||
}
|
||||
}
|
||||
|
||||
export const grocyApi = new GrocyApiClient();
|
||||
export default grocyApi;
|
||||
Reference in New Issue
Block a user