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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
145
CLAUDE.md
Normal file
145
CLAUDE.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Grocy2
|
||||
|
||||
Modern household management frontend for Grocy. Context-aware, delightful, mobile-first.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Backend:** Grocy server at `http://192.168.0.5:9283`
|
||||
**Full Spec:** `docs/DESIGN_SPEC.md`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- TanStack Router
|
||||
- Zustand (state)
|
||||
- React Query (data fetching) - TO ADD
|
||||
- Lucide icons
|
||||
- CSS custom properties (design tokens)
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Context-Aware** - Dashboard adapts to time of day
|
||||
2. **Progressive Disclosure** - Simple surface, power underneath
|
||||
3. **Forgiving** - Everything undoable
|
||||
4. **Glanceable** - Status in 1s, action in 2s, explore in 3s
|
||||
5. **Rewarding** - Satisfying feedback on actions
|
||||
6. **Calm** - Only demands attention when needed
|
||||
|
||||
## Dashboard Components
|
||||
|
||||
### Pulse Ring
|
||||
Ambient status indicator (green/amber/red glow). Tap to expand.
|
||||
|
||||
### Smart Cards (priority order)
|
||||
1. **Expiring Soon** - Swipe left=use, right=add to list
|
||||
2. **Shopping List** - Quick view, "Start Shopping Trip" mode
|
||||
3. **Low Stock** - Progress bars, "Add All to List"
|
||||
4. **Recipe Suggestions** - Evening priority, shows what you can make
|
||||
|
||||
### Quick Action Bar (bottom)
|
||||
- **[+] Add** - Bottom sheet with recent items + search
|
||||
- **[Scan]** - Barcode camera
|
||||
- **[Voice]** - Speech recognition
|
||||
|
||||
## Design Tokens
|
||||
|
||||
```css
|
||||
/* Colors */
|
||||
--color-primary: #6B8E6B; /* Sage green */
|
||||
--color-accent: #E8927C; /* Soft coral */
|
||||
--color-urgent: #C75B39; /* Terracotta */
|
||||
--color-warning: #E4A853; /* Amber */
|
||||
--color-background: #FAFAF8; /* Warm white */
|
||||
--color-text-primary: #2D3436; /* Warm charcoal */
|
||||
|
||||
/* Spacing: 4, 8, 16, 24, 32, 48 */
|
||||
/* Radius: 4, 8, 12, 16 */
|
||||
/* Transitions: 150ms (fast), 250ms (normal), 350ms (slow) */
|
||||
```
|
||||
|
||||
## Breakpoints
|
||||
|
||||
- Mobile: < 640px (single column, bottom nav)
|
||||
- Tablet: 640-1024px (two column, side tabs)
|
||||
- Desktop: > 1024px (sidebar, three column, keyboard shortcuts)
|
||||
|
||||
## Key Interactions
|
||||
|
||||
| Gesture | Action |
|
||||
|---------|--------|
|
||||
| Pull down | Refresh |
|
||||
| Swipe left | Use/Delete |
|
||||
| Swipe right | Add to list |
|
||||
| Long press | Context menu |
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API client + React Query hooks
|
||||
├── components/
|
||||
│ ├── ui/ # Button, Card, Input, etc.
|
||||
│ ├── dashboard/ # PulseRing, SmartCard, QuickActionBar
|
||||
│ ├── stock/
|
||||
│ ├── shopping/
|
||||
│ └── layout/
|
||||
├── hooks/ # useVoiceInput, useSwipeGesture
|
||||
├── lib/ # cardPriority, voiceParser, utils
|
||||
├── pages/
|
||||
├── store/
|
||||
├── styles/ # tokens.css, global.css
|
||||
└── types/
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [x] Project setup
|
||||
- [ ] Design tokens (CSS custom properties)
|
||||
- [ ] Base UI components
|
||||
- [ ] Responsive layout shell
|
||||
- [ ] Bottom nav (mobile) / Sidebar (desktop)
|
||||
|
||||
### Phase 2: Smart Dashboard
|
||||
- [ ] PulseRing component
|
||||
- [ ] SmartCard system
|
||||
- [ ] Card priority algorithm
|
||||
- [ ] Swipeable rows
|
||||
- [ ] QuickActionBar
|
||||
|
||||
### Phase 3: Quick Actions
|
||||
- [ ] AddItem bottom sheet
|
||||
- [ ] Barcode scanning
|
||||
- [ ] Voice input
|
||||
- [ ] Optimistic updates
|
||||
|
||||
### Phase 4: Full Pages
|
||||
- [ ] Stock page (filter, search, detail)
|
||||
- [ ] Shopping mode
|
||||
- [ ] Recipes page
|
||||
- [ ] Settings
|
||||
|
||||
### Phase 5: Polish
|
||||
- [ ] PWA setup
|
||||
- [ ] Offline support
|
||||
- [ ] Siri Shortcuts docs
|
||||
- [ ] Performance optimization
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
## Siri Integration (Future)
|
||||
|
||||
URL scheme: `grocy://add?item=milk&list=shopping`
|
||||
|
||||
Voice intents to parse:
|
||||
- "Add X to shopping list"
|
||||
- "Used X" / "Consumed X"
|
||||
- "Do we have X?"
|
||||
- "What's expiring?"
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
1000
docs/DESIGN_SPEC.md
Normal file
1000
docs/DESIGN_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA -->
|
||||
<meta name="theme-color" content="#6B8E6B" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Stashpile" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
|
||||
<title>Stashpile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8491
package-lock.json
generated
Normal file
8491
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "grocy-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.19",
|
||||
"@tanstack/react-router": "^1.151.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
4
public/icons/icon.svg
Normal file
4
public/icons/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#6B8E6B"/>
|
||||
<text x="256" y="340" font-family="system-ui, sans-serif" font-size="280" font-weight="700" fill="white" text-anchor="middle">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
85
src/App.tsx
Normal file
85
src/App.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createRouter, createRootRoute, createRoute, RouterProvider, Outlet } from '@tanstack/react-router';
|
||||
import { useStore } from './store';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Connect, Dashboard, Stock, Shopping, Recipes, Settings } from './pages';
|
||||
|
||||
// Root route with layout
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => {
|
||||
const { isConnected } = useStore();
|
||||
|
||||
if (!isConnected) {
|
||||
return <Connect />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Define routes
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
const stockRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/stock',
|
||||
component: Stock,
|
||||
});
|
||||
|
||||
const shoppingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/shopping',
|
||||
component: Shopping,
|
||||
});
|
||||
|
||||
const recipesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/recipes',
|
||||
component: Recipes,
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
});
|
||||
|
||||
// Create route tree
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
stockRoute,
|
||||
shoppingRoute,
|
||||
recipesRoute,
|
||||
settingsRoute,
|
||||
]);
|
||||
|
||||
// Create router
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
// Register router for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { checkConnection } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we have saved credentials and can connect
|
||||
checkConnection();
|
||||
}, []);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
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;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
134
src/components/Layout.tsx
Normal file
134
src/components/Layout.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
import { Package, ShoppingCart, UtensilsCrossed, Settings, LayoutDashboard } from 'lucide-react';
|
||||
import { useStore } from '../store';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Home', icon: LayoutDashboard },
|
||||
{ path: '/stock', label: 'Inventory', icon: Package },
|
||||
{ path: '/shopping', label: 'Shopping', icon: ShoppingCart },
|
||||
{ path: '/recipes', label: 'Recipes', icon: UtensilsCrossed },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { systemInfo, user } = useStore();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen min-h-[100dvh] bg-[var(--color-background)]">
|
||||
{/* Desktop Sidebar - hidden on mobile */}
|
||||
<aside className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0 bg-[var(--color-surface)] border-r border-[var(--color-border)]">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Stashpile</h1>
|
||||
{systemInfo && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
v{systemInfo.grocy_version.Version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map(({ path, label, icon: Icon }) => {
|
||||
const isActive = location.pathname === path;
|
||||
return (
|
||||
<li key={path}>
|
||||
<Link
|
||||
to={path}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5
|
||||
rounded-[var(--radius-md)]
|
||||
text-sm font-medium
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
${
|
||||
isActive
|
||||
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary-dark)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-background)] hover:text-[var(--color-text-primary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
{user && (
|
||||
<div className="p-4 border-t border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-[var(--color-primary-dark)]">
|
||||
{user.display_name?.[0] || user.username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{user.display_name || user.username || 'User'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 lg:pl-64">
|
||||
{/* Mobile header - shown only on mobile */}
|
||||
<header className="lg:hidden sticky top-0 z-[var(--z-sticky)] bg-[var(--color-surface)] border-b border-[var(--color-border)] safe-top">
|
||||
<div className="flex items-center justify-between px-4 h-14">
|
||||
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">Stashpile</h1>
|
||||
{user && (
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-[var(--color-primary-dark)]">
|
||||
{user.display_name?.[0] || user.username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="pb-20 lg:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-[var(--z-sticky)] bg-[var(--color-surface)] border-t border-[var(--color-border)] safe-bottom">
|
||||
<ul className="flex items-center justify-around h-16">
|
||||
{navItems.map(({ path, label, icon: Icon }) => {
|
||||
const isActive = location.pathname === path;
|
||||
return (
|
||||
<li key={path}>
|
||||
<Link
|
||||
to={path}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
min-w-[64px] py-2 px-3
|
||||
text-xs font-medium
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
touch-target
|
||||
${
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 mb-1 ${isActive ? 'stroke-[2.5]' : ''}`} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
503
src/components/dashboard/AddItemSheet.tsx
Normal file
503
src/components/dashboard/AddItemSheet.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Search, Plus, ShoppingCart, Package, Sparkles, List } from 'lucide-react';
|
||||
import { useStore } from '../../store';
|
||||
import type { Product } from '../../types/grocy';
|
||||
|
||||
interface AddItemSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
type AddMode = 'stock' | 'shopping';
|
||||
type ViewMode = 'single' | 'bulk';
|
||||
|
||||
export function AddItemSheet({ isOpen, onClose, onSuccess, onError }: AddItemSheetProps) {
|
||||
const { products, addProduct, addToShoppingList, createAndAddToStock, createAndAddToShoppingList } = useStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [mode, setMode] = useState<AddMode>('stock');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('single');
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [amount, setAmount] = useState(1);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [bulkItems, setBulkItems] = useState<string[]>([]);
|
||||
const [bulkInput, setBulkInput] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const bulkInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Filter products based on search
|
||||
const searchLower = search.toLowerCase().trim();
|
||||
const filteredProducts = searchLower.length > 0
|
||||
? products.filter(p => p.name.toLowerCase().includes(searchLower))
|
||||
: products.slice(0, 6);
|
||||
|
||||
// Check if exact match exists
|
||||
const exactMatch = products.find(p => p.name.toLowerCase() === searchLower);
|
||||
const showCreateOption = searchLower.length > 0 && !exactMatch;
|
||||
|
||||
// Focus input when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
if (viewMode === 'bulk') {
|
||||
bulkInputRef.current?.focus();
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
// Reset state on close
|
||||
setSearch('');
|
||||
setSelectedProduct(null);
|
||||
setAmount(1);
|
||||
setIsCreating(false);
|
||||
setBulkItems([]);
|
||||
setBulkInput('');
|
||||
}
|
||||
}, [isOpen, viewMode]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSearch('');
|
||||
setSelectedProduct(null);
|
||||
setAmount(1);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const handleAddExisting = async () => {
|
||||
if (!selectedProduct) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
if (mode === 'stock') {
|
||||
await addProduct(selectedProduct.id, amount);
|
||||
onSuccess?.(`Added ${amount} ${selectedProduct.name} to stock`);
|
||||
} else {
|
||||
await addToShoppingList(selectedProduct.id, amount);
|
||||
onSuccess?.(`Added ${selectedProduct.name} to shopping list`);
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to add item';
|
||||
console.error('Failed to add item:', msg);
|
||||
onError?.(msg);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = async () => {
|
||||
if (!search.trim()) return;
|
||||
const name = search.trim();
|
||||
setIsCreating(true);
|
||||
try {
|
||||
if (mode === 'stock') {
|
||||
await createAndAddToStock(name, amount);
|
||||
onSuccess?.(`Created "${name}" and added ${amount} to stock`);
|
||||
} else {
|
||||
await createAndAddToShoppingList(name, amount);
|
||||
onSuccess?.(`Created "${name}" and added to shopping list`);
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create item';
|
||||
console.error('Failed to create item:', msg);
|
||||
onError?.(msg);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductSelect = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setSearch(product.name);
|
||||
};
|
||||
|
||||
const handleBulkAdd = async () => {
|
||||
const items = bulkInput
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setBulkItems(items);
|
||||
|
||||
try {
|
||||
let created = 0;
|
||||
let added = 0;
|
||||
|
||||
for (const itemName of items) {
|
||||
// Check if product exists
|
||||
const existing = products.find(p => p.name.toLowerCase() === itemName.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
if (mode === 'stock') {
|
||||
await addProduct(existing.id, 1);
|
||||
} else {
|
||||
await addToShoppingList(existing.id, 1);
|
||||
}
|
||||
added++;
|
||||
} else {
|
||||
// Create new product
|
||||
if (mode === 'stock') {
|
||||
await createAndAddToStock(itemName, 1);
|
||||
} else {
|
||||
await createAndAddToShoppingList(itemName, 1);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
|
||||
// Update UI to show progress
|
||||
setBulkItems(prev => prev.filter(i => i !== itemName));
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (created > 0) parts.push(`${created} new`);
|
||||
if (added > 0) parts.push(`${added} existing`);
|
||||
onSuccess?.(`Added ${parts.join(' + ')} items to ${mode === 'stock' ? 'stock' : 'shopping list'}`);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to add items';
|
||||
console.error('Failed to bulk add items:', msg);
|
||||
onError?.(msg);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
setBulkItems([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-[var(--z-overlay)] transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-[var(--z-modal)] animate-slide-up">
|
||||
<div className="bg-[var(--color-surface)] rounded-t-[var(--radius-xl)] shadow-[var(--shadow-lg)] max-h-[90vh] overflow-hidden safe-bottom">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Add Item
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-[var(--color-background)] flex items-center justify-center"
|
||||
>
|
||||
<X className="w-5 h-5 text-[var(--color-text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode toggles */}
|
||||
<div className="p-4 pb-2 space-y-3">
|
||||
{/* Stock vs Shopping */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMode('stock')}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[var(--radius-md)]
|
||||
text-sm font-medium transition-colors
|
||||
${mode === 'stock'
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--color-background)] text-[var(--color-text-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
Stock
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('shopping')}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[var(--radius-md)]
|
||||
text-sm font-medium transition-colors
|
||||
${mode === 'shopping'
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--color-background)] text-[var(--color-text-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
Shopping List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Single vs Bulk */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('single')}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2 rounded-[var(--radius-md)]
|
||||
text-xs font-medium transition-colors
|
||||
${viewMode === 'single'
|
||||
? 'bg-[var(--color-border)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Single
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('bulk')}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2 rounded-[var(--radius-md)]
|
||||
text-xs font-medium transition-colors
|
||||
${viewMode === 'bulk'
|
||||
? 'bg-[var(--color-border)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
Bulk Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'single' ? (
|
||||
<>
|
||||
{/* Search input */}
|
||||
<div className="px-4 py-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
placeholder="Type a product name..."
|
||||
className="
|
||||
w-full h-12 pl-10 pr-4
|
||||
bg-[var(--color-background)]
|
||||
border border-[var(--color-border)]
|
||||
rounded-[var(--radius-md)]
|
||||
text-[var(--color-text-primary)]
|
||||
placeholder:text-[var(--color-text-tertiary)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product list / Create option */}
|
||||
<div className="px-4 py-2 max-h-[35vh] overflow-y-auto">
|
||||
{/* Create new option - shown prominently when no exact match */}
|
||||
{showCreateOption && (
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
disabled={isCreating}
|
||||
className="
|
||||
w-full flex items-center gap-3 p-4 mb-2
|
||||
rounded-[var(--radius-lg)]
|
||||
bg-[var(--color-primary)]/10
|
||||
border-2 border-dashed border-[var(--color-primary)]
|
||||
text-left transition-all
|
||||
hover:bg-[var(--color-primary)]/20
|
||||
disabled:opacity-50
|
||||
"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)] flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[var(--color-primary-dark)]">
|
||||
Create "{search.trim()}"
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">
|
||||
New product • Add {amount} to {mode === 'stock' ? 'stock' : 'shopping list'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Existing products */}
|
||||
{filteredProducts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{showCreateOption && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] py-2">
|
||||
Or choose existing:
|
||||
</p>
|
||||
)}
|
||||
{filteredProducts.slice(0, 6).map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => handleProductSelect(product)}
|
||||
className={`
|
||||
w-full flex items-center justify-between p-3
|
||||
rounded-[var(--radius-md)]
|
||||
text-left transition-colors
|
||||
${selectedProduct?.id === product.id
|
||||
? 'bg-[var(--color-primary)]/10 border border-[var(--color-primary)]'
|
||||
: 'hover:bg-[var(--color-background)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{product.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProducts.length === 0 && !showCreateOption && (
|
||||
<p className="text-center text-[var(--color-text-tertiary)] py-8">
|
||||
Start typing to search or create
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount and confirm for existing product */}
|
||||
{selectedProduct && (
|
||||
<div className="p-4 border-t border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">Amount:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAmount(Math.max(1, amount - 1))}
|
||||
className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--color-background)] text-[var(--color-text-primary)] font-bold"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center text-lg font-semibold">{amount}</span>
|
||||
<button
|
||||
onClick={() => setAmount(amount + 1)}
|
||||
className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--color-background)] text-[var(--color-text-primary)] font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddExisting}
|
||||
disabled={isCreating}
|
||||
className="
|
||||
w-full py-3 rounded-[var(--radius-md)]
|
||||
bg-[var(--color-primary)] text-white
|
||||
font-medium
|
||||
hover:bg-[var(--color-primary-dark)]
|
||||
active:scale-[0.98]
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
flex items-center justify-center gap-2
|
||||
disabled:opacity-50
|
||||
"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add {amount} {selectedProduct.name}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add for create option (amount selector) */}
|
||||
{showCreateOption && !selectedProduct && (
|
||||
<div className="p-4 border-t border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">Amount:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAmount(Math.max(1, amount - 1))}
|
||||
className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--color-background)] text-[var(--color-text-primary)] font-bold"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center text-lg font-semibold">{amount}</span>
|
||||
<button
|
||||
onClick={() => setAmount(amount + 1)}
|
||||
className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--color-background)] text-[var(--color-text-primary)] font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Bulk add mode */
|
||||
<>
|
||||
<div className="px-4 py-2">
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-2">
|
||||
Enter one item per line. New items will be created automatically.
|
||||
</p>
|
||||
<textarea
|
||||
ref={bulkInputRef}
|
||||
value={bulkInput}
|
||||
onChange={(e) => setBulkInput(e.target.value)}
|
||||
placeholder={`Sugar\nFlour\nMilk\nEggs`}
|
||||
rows={8}
|
||||
className="
|
||||
w-full p-3
|
||||
bg-[var(--color-background)]
|
||||
border border-[var(--color-border)]
|
||||
rounded-[var(--radius-md)]
|
||||
text-[var(--color-text-primary)]
|
||||
placeholder:text-[var(--color-text-tertiary)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]
|
||||
resize-none
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{isCreating && bulkItems.length > 0 && (
|
||||
<div className="px-4 py-2">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||
Adding: {bulkItems[0]}...
|
||||
</p>
|
||||
<div className="h-1 bg-[var(--color-border-light)] rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)] transition-all"
|
||||
style={{
|
||||
width: `${((bulkInput.split('\n').filter(l => l.trim()).length - bulkItems.length) / bulkInput.split('\n').filter(l => l.trim()).length) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t border-[var(--color-border)]">
|
||||
<button
|
||||
onClick={handleBulkAdd}
|
||||
disabled={isCreating || !bulkInput.trim()}
|
||||
className="
|
||||
w-full py-3 rounded-[var(--radius-md)]
|
||||
bg-[var(--color-primary)] text-white
|
||||
font-medium
|
||||
hover:bg-[var(--color-primary-dark)]
|
||||
active:scale-[0.98]
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
flex items-center justify-center gap-2
|
||||
disabled:opacity-50
|
||||
"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add All to {mode === 'stock' ? 'Stock' : 'Shopping List'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
src/components/dashboard/ExpiringCard.tsx
Normal file
116
src/components/dashboard/ExpiringCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Clock } from 'lucide-react';
|
||||
import { SmartCard } from './SmartCard';
|
||||
import { SwipeableRow, swipeActions } from '../ui';
|
||||
import type { StockProduct } from '../../types/grocy';
|
||||
|
||||
interface ExpiringCardProps {
|
||||
items: StockProduct[];
|
||||
onItemTap?: (item: StockProduct) => void;
|
||||
onUseItem?: (item: StockProduct) => void;
|
||||
onAddToList?: (item: StockProduct) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
function getDaysUntilExpiry(dateStr: string): number {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatExpiry(days: number): string {
|
||||
if (days <= 0) return 'Expired';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
if (days <= 7) return `${days} days`;
|
||||
return `${Math.ceil(days / 7)} weeks`;
|
||||
}
|
||||
|
||||
export function ExpiringCard({ items, onItemTap, onUseItem, onAddToList, onViewAll }: ExpiringCardProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Sort by expiry date (soonest first)
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const daysA = getDaysUntilExpiry(a.best_before_date);
|
||||
const daysB = getDaysUntilExpiry(b.best_before_date);
|
||||
return daysA - daysB;
|
||||
});
|
||||
|
||||
const soonestDays = getDaysUntilExpiry(sorted[0].best_before_date);
|
||||
const priority = soonestDays <= 0 ? 'urgent' : soonestDays <= 3 ? 'attention' : 'normal';
|
||||
|
||||
const subtitle = soonestDays <= 0
|
||||
? 'Items need attention now'
|
||||
: soonestDays === 1
|
||||
? 'Something expires tomorrow'
|
||||
: `Soonest in ${soonestDays} days`;
|
||||
|
||||
return (
|
||||
<SmartCard
|
||||
title="Expiring Soon"
|
||||
subtitle={subtitle}
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
priority={priority}
|
||||
count={items.length}
|
||||
action={onViewAll ? { label: 'View All', onClick: onViewAll } : undefined}
|
||||
>
|
||||
<div className="space-y-1 -mx-4">
|
||||
{sorted.slice(0, 4).map((item) => {
|
||||
const days = getDaysUntilExpiry(item.best_before_date);
|
||||
const isUrgent = days <= 1;
|
||||
const isWarning = days <= 3 && days > 1;
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between py-3 px-4
|
||||
${onItemTap ? 'cursor-pointer active:bg-[var(--color-background)]' : ''}
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
`}
|
||||
onClick={() => onItemTap?.(item)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || `Product #${item.product_id}`}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{item.amount} {item.amount === 1 ? 'unit' : 'units'}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
text-sm font-medium ml-3 shrink-0
|
||||
${isUrgent ? 'text-[var(--color-urgent)]' : ''}
|
||||
${isWarning ? 'text-[var(--color-warning)]' : ''}
|
||||
${!isUrgent && !isWarning ? 'text-[var(--color-text-secondary)]' : ''}
|
||||
`}
|
||||
>
|
||||
{formatExpiry(days)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only use swipeable if actions are provided
|
||||
if (onUseItem || onAddToList) {
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={`${item.product_id}-${item.best_before_date}`}
|
||||
leftAction={onUseItem ? swipeActions.use(() => onUseItem(item)) : undefined}
|
||||
rightAction={onAddToList ? swipeActions.addToList(() => onAddToList(item)) : undefined}
|
||||
>
|
||||
{rowContent}
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${item.product_id}-${item.best_before_date}`}>
|
||||
{rowContent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length > 4 && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-2 text-center">
|
||||
+{items.length - 4} more items
|
||||
</p>
|
||||
)}
|
||||
</SmartCard>
|
||||
);
|
||||
}
|
||||
105
src/components/dashboard/LowStockCard.tsx
Normal file
105
src/components/dashboard/LowStockCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { SmartCard } from './SmartCard';
|
||||
import type { StockProduct } from '../../types/grocy';
|
||||
|
||||
interface LowStockCardProps {
|
||||
items: StockProduct[];
|
||||
onAddAllToList?: () => void;
|
||||
onItemTap?: (item: StockProduct) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
export function LowStockCard({ items, onAddAllToList, onItemTap, onViewAll }: LowStockCardProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Check if any items are completely out
|
||||
const outOfStock = items.filter(i => i.amount === 0);
|
||||
const priority = outOfStock.length > 0 ? 'attention' : 'normal';
|
||||
|
||||
const subtitle = outOfStock.length > 0
|
||||
? `${outOfStock.length} item${outOfStock.length !== 1 ? 's' : ''} out of stock`
|
||||
: `${items.length} item${items.length !== 1 ? 's' : ''} running low`;
|
||||
|
||||
return (
|
||||
<SmartCard
|
||||
title="Low Stock"
|
||||
subtitle={subtitle}
|
||||
icon={<AlertTriangle className="w-5 h-5" />}
|
||||
priority={priority}
|
||||
count={items.length}
|
||||
action={onViewAll ? { label: 'View All', onClick: onViewAll } : undefined}
|
||||
>
|
||||
{/* Add All Button */}
|
||||
{onAddAllToList && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAllToList();
|
||||
}}
|
||||
className="
|
||||
w-full flex items-center justify-center gap-2
|
||||
py-3 mb-3 rounded-[var(--radius-md)]
|
||||
bg-[var(--color-background)] text-[var(--color-text-primary)]
|
||||
font-medium text-sm
|
||||
border border-[var(--color-border)]
|
||||
hover:bg-[var(--color-border-light)]
|
||||
active:bg-[var(--color-border)]
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
"
|
||||
>
|
||||
Add All to Shopping List
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Items list */}
|
||||
<div className="space-y-1">
|
||||
{items.slice(0, 4).map((item) => {
|
||||
const minStock = item.product?.min_stock_amount || 0;
|
||||
const percentage = minStock > 0 ? Math.round((item.amount / minStock) * 100) : 0;
|
||||
const isOut = item.amount === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.product_id}
|
||||
className={`
|
||||
py-2.5 px-3 -mx-3
|
||||
rounded-[var(--radius-md)]
|
||||
${onItemTap ? 'cursor-pointer hover:bg-[var(--color-background)] active:bg-[var(--color-border-light)]' : ''}
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
`}
|
||||
onClick={() => onItemTap?.(item)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || `Product #${item.product_id}`}
|
||||
</p>
|
||||
<span
|
||||
className={`
|
||||
text-sm font-medium ml-3 shrink-0
|
||||
${isOut ? 'text-[var(--color-urgent)]' : 'text-[var(--color-warning)]'}
|
||||
`}
|
||||
>
|
||||
{isOut ? 'Out' : `${item.amount}/${minStock}`}
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-[var(--color-border-light)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-[var(--transition-normal)] ${
|
||||
isOut ? 'bg-[var(--color-urgent)]' : 'bg-[var(--color-warning)]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length > 4 && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-2 text-center">
|
||||
+{items.length - 4} more items
|
||||
</p>
|
||||
)}
|
||||
</SmartCard>
|
||||
);
|
||||
}
|
||||
112
src/components/dashboard/PulseRing.tsx
Normal file
112
src/components/dashboard/PulseRing.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, AlertTriangle, AlertCircle } from 'lucide-react';
|
||||
|
||||
type Status = 'good' | 'attention' | 'urgent';
|
||||
|
||||
interface PulseRingProps {
|
||||
status: Status;
|
||||
summary?: string;
|
||||
onTap?: () => void;
|
||||
}
|
||||
|
||||
const statusConfig: Record<Status, {
|
||||
icon: typeof Check;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
glowColor: string;
|
||||
label: string;
|
||||
}> = {
|
||||
good: {
|
||||
icon: Check,
|
||||
color: 'var(--color-primary)',
|
||||
bgColor: 'var(--color-primary)',
|
||||
glowColor: 'var(--shadow-glow-good)',
|
||||
label: 'All good',
|
||||
},
|
||||
attention: {
|
||||
icon: AlertTriangle,
|
||||
color: 'var(--color-warning)',
|
||||
bgColor: 'var(--color-warning)',
|
||||
glowColor: 'var(--shadow-glow-attention)',
|
||||
label: 'Needs attention',
|
||||
},
|
||||
urgent: {
|
||||
icon: AlertCircle,
|
||||
color: 'var(--color-urgent)',
|
||||
bgColor: 'var(--color-urgent)',
|
||||
glowColor: 'var(--shadow-glow-urgent)',
|
||||
label: 'Urgent',
|
||||
},
|
||||
};
|
||||
|
||||
export function PulseRing({ status, summary, onTap }: PulseRingProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
const handleTap = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
onTap?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* The ring */}
|
||||
<button
|
||||
onClick={handleTap}
|
||||
className="
|
||||
relative w-20 h-20 rounded-full
|
||||
flex items-center justify-center
|
||||
transition-all duration-[var(--transition-normal)]
|
||||
active:scale-95
|
||||
"
|
||||
style={{
|
||||
boxShadow: config.glowColor,
|
||||
}}
|
||||
aria-label={`Household status: ${config.label}`}
|
||||
>
|
||||
{/* Outer pulse animation */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full animate-pulse-ring opacity-30"
|
||||
style={{ backgroundColor: config.bgColor }}
|
||||
/>
|
||||
|
||||
{/* Inner circle */}
|
||||
<div
|
||||
className="
|
||||
relative w-16 h-16 rounded-full
|
||||
flex items-center justify-center
|
||||
bg-[var(--color-surface)]
|
||||
shadow-[var(--shadow-md)]
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
className="w-8 h-8 transition-colors"
|
||||
style={{ color: config.color }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded info */}
|
||||
<div
|
||||
className={`
|
||||
mt-3 text-center overflow-hidden
|
||||
transition-all duration-[var(--transition-normal)]
|
||||
${isExpanded ? 'max-h-20 opacity-100' : 'max-h-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.label}
|
||||
</p>
|
||||
{summary && (
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mt-1">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/dashboard/QuickActionBar.tsx
Normal file
74
src/components/dashboard/QuickActionBar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Plus, Camera, Mic } from 'lucide-react';
|
||||
|
||||
interface QuickActionBarProps {
|
||||
onAdd: () => void;
|
||||
onScan?: () => void;
|
||||
onVoice?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionBar({ onAdd, onScan, onVoice }: QuickActionBarProps) {
|
||||
return (
|
||||
<div className="fixed bottom-20 left-0 right-0 lg:bottom-8 z-[var(--z-sticky)] pointer-events-none">
|
||||
<div className="max-w-3xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center gap-3 pointer-events-auto">
|
||||
{/* Voice button (optional) */}
|
||||
{onVoice && (
|
||||
<button
|
||||
onClick={onVoice}
|
||||
className="
|
||||
w-12 h-12 rounded-full
|
||||
bg-[var(--color-surface)] text-[var(--color-text-secondary)]
|
||||
shadow-[var(--shadow-md)]
|
||||
border border-[var(--color-border)]
|
||||
flex items-center justify-center
|
||||
hover:bg-[var(--color-background)]
|
||||
active:scale-95
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
"
|
||||
aria-label="Voice input"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main Add button */}
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="
|
||||
w-14 h-14 rounded-full
|
||||
bg-[var(--color-primary)] text-white
|
||||
shadow-[var(--shadow-lg)]
|
||||
flex items-center justify-center
|
||||
hover:bg-[var(--color-primary-dark)]
|
||||
active:scale-95
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
"
|
||||
aria-label="Add item"
|
||||
>
|
||||
<Plus className="w-7 h-7" />
|
||||
</button>
|
||||
|
||||
{/* Scan button (optional) */}
|
||||
{onScan && (
|
||||
<button
|
||||
onClick={onScan}
|
||||
className="
|
||||
w-12 h-12 rounded-full
|
||||
bg-[var(--color-surface)] text-[var(--color-text-secondary)]
|
||||
shadow-[var(--shadow-md)]
|
||||
border border-[var(--color-border)]
|
||||
flex items-center justify-center
|
||||
hover:bg-[var(--color-background)]
|
||||
active:scale-95
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
"
|
||||
aria-label="Scan barcode"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/dashboard/ShoppingCard.tsx
Normal file
81
src/components/dashboard/ShoppingCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ShoppingCart, Play } from 'lucide-react';
|
||||
import { SmartCard } from './SmartCard';
|
||||
import type { ShoppingListItem } from '../../types/grocy';
|
||||
|
||||
interface ShoppingCardProps {
|
||||
items: ShoppingListItem[];
|
||||
onStartTrip?: () => void;
|
||||
onItemTap?: (item: ShoppingListItem) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
export function ShoppingCard({ items, onStartTrip, onItemTap, onViewAll }: ShoppingCardProps) {
|
||||
const pendingItems = items.filter(i => !i.done);
|
||||
|
||||
if (pendingItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SmartCard
|
||||
title="Shopping List"
|
||||
subtitle={`${pendingItems.length} item${pendingItems.length !== 1 ? 's' : ''} to get`}
|
||||
icon={<ShoppingCart className="w-5 h-5" />}
|
||||
priority="normal"
|
||||
count={pendingItems.length}
|
||||
action={onViewAll ? { label: 'View Full List', onClick: onViewAll } : undefined}
|
||||
>
|
||||
{/* Start Trip Button */}
|
||||
{onStartTrip && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTrip();
|
||||
}}
|
||||
className="
|
||||
w-full flex items-center justify-center gap-2
|
||||
py-3 mb-3 rounded-[var(--radius-md)]
|
||||
bg-[var(--color-primary)] text-white
|
||||
font-medium text-sm
|
||||
hover:bg-[var(--color-primary-dark)]
|
||||
active:bg-[var(--color-primary-dark)]
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Shopping Trip
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Quick preview of items */}
|
||||
<div className="space-y-1">
|
||||
{pendingItems.slice(0, 4).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
flex items-center justify-between py-2.5 px-3 -mx-3
|
||||
rounded-[var(--radius-md)]
|
||||
${onItemTap ? 'cursor-pointer hover:bg-[var(--color-background)] active:bg-[var(--color-border-light)]' : ''}
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
`}
|
||||
onClick={() => onItemTap?.(item)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || item.note || `Item #${item.id}`}
|
||||
</p>
|
||||
</div>
|
||||
{item.amount > 1 && (
|
||||
<span className="text-sm text-[var(--color-text-secondary)] ml-3 shrink-0">
|
||||
x{item.amount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pendingItems.length > 4 && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-2 text-center">
|
||||
+{pendingItems.length - 4} more items
|
||||
</p>
|
||||
)}
|
||||
</SmartCard>
|
||||
);
|
||||
}
|
||||
122
src/components/dashboard/SmartCard.tsx
Normal file
122
src/components/dashboard/SmartCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
type CardPriority = 'urgent' | 'attention' | 'normal' | 'low';
|
||||
|
||||
interface SmartCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: ReactNode;
|
||||
priority?: CardPriority;
|
||||
count?: number;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
onTap?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const priorityStyles: Record<CardPriority, { border: string; iconBg: string; iconColor: string }> = {
|
||||
urgent: {
|
||||
border: 'border-l-4 border-l-[var(--color-urgent)]',
|
||||
iconBg: 'bg-[var(--color-urgent)]/10',
|
||||
iconColor: 'text-[var(--color-urgent)]',
|
||||
},
|
||||
attention: {
|
||||
border: 'border-l-4 border-l-[var(--color-warning)]',
|
||||
iconBg: 'bg-[var(--color-warning)]/10',
|
||||
iconColor: 'text-[var(--color-warning)]',
|
||||
},
|
||||
normal: {
|
||||
border: '',
|
||||
iconBg: 'bg-[var(--color-primary)]/10',
|
||||
iconColor: 'text-[var(--color-primary)]',
|
||||
},
|
||||
low: {
|
||||
border: '',
|
||||
iconBg: 'bg-[var(--color-border-light)]',
|
||||
iconColor: 'text-[var(--color-text-tertiary)]',
|
||||
},
|
||||
};
|
||||
|
||||
export function SmartCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
priority = 'normal',
|
||||
count,
|
||||
action,
|
||||
onTap,
|
||||
children,
|
||||
}: SmartCardProps) {
|
||||
const styles = priorityStyles[priority];
|
||||
const isInteractive = !!onTap;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-[var(--color-surface)] rounded-[var(--radius-lg)]
|
||||
shadow-[var(--shadow-sm)]
|
||||
${styles.border}
|
||||
${isInteractive ? 'cursor-pointer active:scale-[0.98] transition-transform duration-[var(--transition-fast)]' : ''}
|
||||
`}
|
||||
onClick={onTap}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 pb-2">
|
||||
<div className={`w-10 h-10 rounded-[var(--radius-md)] ${styles.iconBg} flex items-center justify-center`}>
|
||||
<span className={styles.iconColor}>{icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${priority === 'urgent' ? 'bg-[var(--color-urgent)] text-white' : ''}
|
||||
${priority === 'attention' ? 'bg-[var(--color-warning)] text-white' : ''}
|
||||
${priority === 'normal' ? 'bg-[var(--color-primary)] text-white' : ''}
|
||||
${priority === 'low' ? 'bg-[var(--color-border)] text-[var(--color-text-secondary)]' : ''}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{isInteractive && (
|
||||
<ChevronRight className="w-5 h-5 text-[var(--color-text-tertiary)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{action && (
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
className="
|
||||
w-full py-2.5 rounded-[var(--radius-md)]
|
||||
text-sm font-medium
|
||||
bg-[var(--color-background)] text-[var(--color-text-primary)]
|
||||
hover:bg-[var(--color-border-light)]
|
||||
transition-colors duration-[var(--transition-fast)]
|
||||
"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/components/dashboard/index.ts
Normal file
7
src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { SmartCard } from './SmartCard';
|
||||
export { ExpiringCard } from './ExpiringCard';
|
||||
export { ShoppingCard } from './ShoppingCard';
|
||||
export { LowStockCard } from './LowStockCard';
|
||||
export { QuickActionBar } from './QuickActionBar';
|
||||
export { AddItemSheet } from './AddItemSheet';
|
||||
export { PulseRing } from './PulseRing';
|
||||
49
src/components/ui/Badge.tsx
Normal file
49
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'urgent' | 'info';
|
||||
type BadgeSize = 'sm' | 'md';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: 'bg-[var(--color-border-light)] text-[var(--color-text-secondary)]',
|
||||
success: 'bg-[var(--color-primary)]/10 text-[var(--color-primary-dark)]',
|
||||
warning: 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
|
||||
urgent: 'bg-[var(--color-urgent)]/10 text-[var(--color-urgent)]',
|
||||
info: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<BadgeSize, string> = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-sm',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 font-medium
|
||||
rounded-[var(--radius-full)]
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
92
src/components/ui/Button.tsx
Normal file
92
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: ReactNode;
|
||||
iconRight?: ReactNode;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
bg-[var(--color-primary)] text-[var(--color-text-inverse)]
|
||||
hover:bg-[var(--color-primary-dark)]
|
||||
active:bg-[var(--color-primary-dark)]
|
||||
`,
|
||||
secondary: `
|
||||
bg-[var(--color-surface)] text-[var(--color-text-primary)]
|
||||
border border-[var(--color-border)]
|
||||
hover:bg-[var(--color-background)]
|
||||
active:bg-[var(--color-border-light)]
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent text-[var(--color-text-secondary)]
|
||||
hover:bg-[var(--color-background)]
|
||||
active:bg-[var(--color-border-light)]
|
||||
`,
|
||||
danger: `
|
||||
bg-[var(--color-urgent)] text-[var(--color-text-inverse)]
|
||||
hover:bg-[var(--color-accent-dark)]
|
||||
active:bg-[var(--color-accent-dark)]
|
||||
`,
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[32px]',
|
||||
md: 'px-4 py-2 text-base gap-2 min-h-[44px]',
|
||||
lg: 'px-6 py-3 text-lg gap-2.5 min-h-[52px]',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconRight,
|
||||
loading,
|
||||
fullWidth,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
font-medium rounded-[var(--radius-md)]
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
icon && <span className="shrink-0">{icon}</span>
|
||||
)}
|
||||
{children && <span>{children}</span>}
|
||||
{iconRight && !loading && <span className="shrink-0">{iconRight}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
91
src/components/ui/Card.tsx
Normal file
91
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type CardVariant = 'default' | 'elevated' | 'outlined';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CardVariant;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<CardVariant, string> = {
|
||||
default: 'bg-[var(--color-surface)] shadow-[var(--shadow-sm)]',
|
||||
elevated: 'bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]',
|
||||
outlined: 'bg-[var(--color-surface)] border border-[var(--color-border)]',
|
||||
};
|
||||
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
export function Card({
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-[var(--radius-lg)]
|
||||
${variantStyles[variant]}
|
||||
${paddingStyles[padding]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action, className = '', ...props }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = '', ...props }: CardContentProps) {
|
||||
return (
|
||||
<div className={`mt-3 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = '', ...props }: CardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mt-4 pt-4 border-t border-[var(--color-border-light)] ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/components/ui/Input.tsx
Normal file
118
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
icon?: ReactNode;
|
||||
iconRight?: ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, hint, icon, iconRight, className = '', id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-[var(--color-text-primary)] mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full min-h-[44px] px-3 py-2
|
||||
bg-[var(--color-surface)]
|
||||
border rounded-[var(--radius-md)]
|
||||
text-[var(--color-text-primary)]
|
||||
placeholder:text-[var(--color-text-tertiary)]
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--color-background)]
|
||||
${icon ? 'pl-10' : ''}
|
||||
${iconRight ? 'pr-10' : ''}
|
||||
${error ? 'border-[var(--color-urgent)]' : 'border-[var(--color-border)]'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{iconRight && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]">
|
||||
{iconRight}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-[var(--color-urgent)]">{error}</p>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<p className="mt-1.5 text-sm text-[var(--color-text-tertiary)]">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ label, error, hint, className = '', id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-[var(--color-text-primary)] mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full min-h-[100px] px-3 py-2
|
||||
bg-[var(--color-surface)]
|
||||
border rounded-[var(--radius-md)]
|
||||
text-[var(--color-text-primary)]
|
||||
placeholder:text-[var(--color-text-tertiary)]
|
||||
transition-all duration-[var(--transition-fast)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--color-background)]
|
||||
resize-y
|
||||
${error ? 'border-[var(--color-urgent)]' : 'border-[var(--color-border)]'}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-[var(--color-urgent)]">{error}</p>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<p className="mt-1.5 text-sm text-[var(--color-text-tertiary)]">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
181
src/components/ui/SwipeableRow.tsx
Normal file
181
src/components/ui/SwipeableRow.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { Check, ShoppingCart, Trash2 } from 'lucide-react';
|
||||
|
||||
interface SwipeAction {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: ReactNode;
|
||||
leftAction?: SwipeAction;
|
||||
rightAction?: SwipeAction;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
|
||||
export function SwipeableRow({
|
||||
children,
|
||||
leftAction,
|
||||
rightAction,
|
||||
disabled = false,
|
||||
}: SwipeableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startX = useRef(0);
|
||||
const currentX = useRef(0);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (disabled) return;
|
||||
startX.current = e.touches[0].clientX;
|
||||
currentX.current = startX.current;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (disabled || !isDragging) return;
|
||||
currentX.current = e.touches[0].clientX;
|
||||
const diff = currentX.current - startX.current;
|
||||
|
||||
// Limit swipe based on available actions
|
||||
let newTranslateX = diff;
|
||||
if (!leftAction && diff > 0) newTranslateX = 0;
|
||||
if (!rightAction && diff < 0) newTranslateX = 0;
|
||||
|
||||
// Add resistance at edges
|
||||
const maxSwipe = 120;
|
||||
if (Math.abs(newTranslateX) > maxSwipe) {
|
||||
const overflow = Math.abs(newTranslateX) - maxSwipe;
|
||||
const resistance = 0.3;
|
||||
newTranslateX = (newTranslateX > 0 ? 1 : -1) * (maxSwipe + overflow * resistance);
|
||||
}
|
||||
|
||||
setTranslateX(newTranslateX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (disabled) return;
|
||||
setIsDragging(false);
|
||||
|
||||
const swipedRight = translateX > SWIPE_THRESHOLD;
|
||||
const swipedLeft = translateX < -SWIPE_THRESHOLD;
|
||||
|
||||
if (swipedRight && leftAction) {
|
||||
// Animate out then trigger action
|
||||
setTranslateX(300);
|
||||
setTimeout(() => {
|
||||
leftAction.onAction();
|
||||
setTranslateX(0);
|
||||
}, 200);
|
||||
} else if (swipedLeft && rightAction) {
|
||||
// Animate out then trigger action
|
||||
setTranslateX(-300);
|
||||
setTimeout(() => {
|
||||
rightAction.onAction();
|
||||
setTranslateX(0);
|
||||
}, 200);
|
||||
} else {
|
||||
// Snap back
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate action visibility
|
||||
const leftProgress = Math.min(translateX / SWIPE_THRESHOLD, 1);
|
||||
const rightProgress = Math.min(-translateX / SWIPE_THRESHOLD, 1);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative overflow-hidden">
|
||||
{/* Left action (swipe right to reveal) */}
|
||||
{leftAction && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 flex items-center justify-start pl-4 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: leftAction.bgColor,
|
||||
width: Math.max(translateX, 0),
|
||||
opacity: leftProgress,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 text-white font-medium"
|
||||
style={{
|
||||
transform: `scale(${0.8 + leftProgress * 0.2})`,
|
||||
opacity: leftProgress,
|
||||
}}
|
||||
>
|
||||
{leftAction.icon}
|
||||
{leftProgress > 0.5 && (
|
||||
<span className="text-sm">{leftAction.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right action (swipe left to reveal) */}
|
||||
{rightAction && (
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-end pr-4 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: rightAction.bgColor,
|
||||
width: Math.max(-translateX, 0),
|
||||
opacity: rightProgress,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 text-white font-medium"
|
||||
style={{
|
||||
transform: `scale(${0.8 + rightProgress * 0.2})`,
|
||||
opacity: rightProgress,
|
||||
}}
|
||||
>
|
||||
{rightProgress > 0.5 && (
|
||||
<span className="text-sm">{rightAction.label}</span>
|
||||
)}
|
||||
{rightAction.icon}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`relative bg-[var(--color-surface)] ${isDragging ? '' : 'transition-transform duration-200'}`}
|
||||
style={{ transform: `translateX(${translateX}px)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-configured actions for common use cases
|
||||
export const swipeActions = {
|
||||
use: (onAction: () => void): SwipeAction => ({
|
||||
icon: <Check className="w-5 h-5" />,
|
||||
label: 'Use',
|
||||
color: 'white',
|
||||
bgColor: 'var(--color-primary)',
|
||||
onAction,
|
||||
}),
|
||||
addToList: (onAction: () => void): SwipeAction => ({
|
||||
icon: <ShoppingCart className="w-5 h-5" />,
|
||||
label: 'Add to list',
|
||||
color: 'white',
|
||||
bgColor: 'var(--color-info)',
|
||||
onAction,
|
||||
}),
|
||||
delete: (onAction: () => void): SwipeAction => ({
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: 'Delete',
|
||||
color: 'white',
|
||||
bgColor: 'var(--color-urgent)',
|
||||
onAction,
|
||||
}),
|
||||
};
|
||||
100
src/components/ui/Toast.tsx
Normal file
100
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(toast.id);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onDismiss]);
|
||||
|
||||
const icons = {
|
||||
success: <Check className="w-5 h-5" />,
|
||||
error: <X className="w-5 h-5" />,
|
||||
info: <AlertTriangle className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-[var(--color-primary)] text-white',
|
||||
error: 'bg-[var(--color-urgent)] text-white',
|
||||
info: 'bg-[var(--color-text-primary)] text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3
|
||||
rounded-[var(--radius-lg)]
|
||||
shadow-[var(--shadow-lg)]
|
||||
animate-toast-in
|
||||
${colors[toast.type]}
|
||||
`}
|
||||
>
|
||||
{icons[toast.type]}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast container that renders at the top of the screen
|
||||
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-4 right-4 z-[var(--z-toast)] flex flex-col items-center gap-2 pointer-events-none safe-top">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} onDismiss={onDismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple hook for managing toasts
|
||||
let toastId = 0;
|
||||
let globalSetToasts: React.Dispatch<React.SetStateAction<Toast[]>> | null = null;
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
globalSetToasts = setToasts;
|
||||
return () => {
|
||||
globalSetToasts = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const show = (message: string, type: ToastType = 'success') => {
|
||||
const id = `toast-${++toastId}`;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
};
|
||||
|
||||
return { toasts, show, dismiss };
|
||||
}
|
||||
|
||||
// Global toast function for use outside React components
|
||||
export function showToast(message: string, type: ToastType = 'success') {
|
||||
if (globalSetToasts) {
|
||||
const id = `toast-${++toastId}`;
|
||||
globalSetToasts((prev) => [...prev, { id, message, type }]);
|
||||
}
|
||||
}
|
||||
6
src/components/ui/index.ts
Normal file
6
src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Button } from './Button';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Input, TextArea } from './Input';
|
||||
export { Badge } from './Badge';
|
||||
export { SwipeableRow, swipeActions } from './SwipeableRow';
|
||||
export { ToastContainer, useToast, showToast, type Toast, type ToastType } from './Toast';
|
||||
234
src/index.css
Normal file
234
src/index.css
Normal file
@@ -0,0 +1,234 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================
|
||||
GROCY DESIGN TOKENS
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Colors - Primary Palette */
|
||||
--color-primary: #6B8E6B; /* Sage green - calm, organic */
|
||||
--color-primary-light: #8BA88B;
|
||||
--color-primary-dark: #4A6B4A;
|
||||
|
||||
--color-accent: #E8927C; /* Soft coral - warmth, action */
|
||||
--color-accent-light: #F0B0A0;
|
||||
--color-accent-dark: #C75B39;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #6B8E6B; /* Matches primary */
|
||||
--color-warning: #E4A853; /* Amber */
|
||||
--color-urgent: #C75B39; /* Terracotta */
|
||||
--color-info: #5B8FB9; /* Soft blue */
|
||||
|
||||
/* Neutrals */
|
||||
--color-background: #FAFAF8; /* Warm white */
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-elevated: #FFFFFF;
|
||||
--color-border: #E8E6E3;
|
||||
--color-border-light: #F0EEEB;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #2D3436; /* Warm charcoal */
|
||||
--color-text-secondary: #636E72;
|
||||
--color-text-tertiary: #9CA3A8;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
/* Pulse Ring States */
|
||||
--pulse-good: #6B8E6B;
|
||||
--pulse-attention: #E4A853;
|
||||
--pulse-urgent: #C75B39;
|
||||
|
||||
/* Spacing Scale (4px base) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
--transition-bounce: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
--shadow-glow-good: 0 0 20px rgba(107, 142, 107, 0.3);
|
||||
--shadow-glow-attention: 0 0 20px rgba(228, 168, 83, 0.3);
|
||||
--shadow-glow-urgent: 0 0 20px rgba(199, 91, 57, 0.3);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 0;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 500;
|
||||
|
||||
/* Safe Areas (iOS) */
|
||||
--safe-top: env(safe-area-inset-top);
|
||||
--safe-bottom: env(safe-area-inset-bottom);
|
||||
--safe-left: env(safe-area-inset-left);
|
||||
--safe-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Dark mode (future) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Will implement later */
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh; /* Dynamic viewport height for mobile */
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove default button styles */
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Safe area padding */
|
||||
.safe-bottom {
|
||||
padding-bottom: max(var(--space-4), var(--safe-bottom));
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: max(var(--space-4), var(--safe-top));
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-toast-in {
|
||||
animation: toast-in 0.2s ease-out;
|
||||
}
|
||||
144
src/lib/cardPriority.ts
Normal file
144
src/lib/cardPriority.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { StockProduct, ShoppingListItem } from '../types/grocy';
|
||||
|
||||
export type CardType = 'expiring' | 'shopping' | 'lowStock' | 'recipes';
|
||||
|
||||
interface CardData {
|
||||
expiring: StockProduct[];
|
||||
shopping: ShoppingListItem[];
|
||||
lowStock: StockProduct[];
|
||||
}
|
||||
|
||||
interface PrioritizedCard {
|
||||
type: CardType;
|
||||
priority: number;
|
||||
urgency: 'urgent' | 'attention' | 'normal' | 'low';
|
||||
}
|
||||
|
||||
function getDaysUntilExpiry(dateStr: string): number {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function getTimeOfDay(): 'morning' | 'afternoon' | 'evening' {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'morning';
|
||||
if (hour < 17) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate priority score for each card type.
|
||||
* Higher score = shows first.
|
||||
*
|
||||
* Factors:
|
||||
* - Urgency of items (expired/expiring soon = higher priority)
|
||||
* - Count of items needing attention
|
||||
* - Time of day (recipes higher in evening, shopping higher on weekends)
|
||||
*/
|
||||
export function calculateCardPriorities(data: CardData): PrioritizedCard[] {
|
||||
const cards: PrioritizedCard[] = [];
|
||||
const timeOfDay = getTimeOfDay();
|
||||
const isWeekend = [0, 6].includes(new Date().getDay());
|
||||
|
||||
// Expiring items priority
|
||||
if (data.expiring.length > 0) {
|
||||
const soonestDays = Math.min(
|
||||
...data.expiring.map(item => getDaysUntilExpiry(item.best_before_date))
|
||||
);
|
||||
|
||||
let priority = 50; // Base priority
|
||||
let urgency: PrioritizedCard['urgency'] = 'normal';
|
||||
|
||||
if (soonestDays <= 0) {
|
||||
priority = 100; // Expired items are highest priority
|
||||
urgency = 'urgent';
|
||||
} else if (soonestDays <= 1) {
|
||||
priority = 90;
|
||||
urgency = 'urgent';
|
||||
} else if (soonestDays <= 3) {
|
||||
priority = 75;
|
||||
urgency = 'attention';
|
||||
}
|
||||
|
||||
// Bonus for more items
|
||||
priority += Math.min(data.expiring.length * 2, 10);
|
||||
|
||||
cards.push({ type: 'expiring', priority, urgency });
|
||||
}
|
||||
|
||||
// Shopping list priority
|
||||
if (data.shopping.length > 0) {
|
||||
let priority = 40; // Base priority
|
||||
const urgency: PrioritizedCard['urgency'] = 'normal';
|
||||
|
||||
// Higher priority on weekends (shopping day)
|
||||
if (isWeekend) {
|
||||
priority += 20;
|
||||
}
|
||||
|
||||
// Higher priority in morning (plan your day)
|
||||
if (timeOfDay === 'morning') {
|
||||
priority += 10;
|
||||
}
|
||||
|
||||
// Bonus for more items
|
||||
priority += Math.min(data.shopping.length, 10);
|
||||
|
||||
cards.push({ type: 'shopping', priority, urgency });
|
||||
}
|
||||
|
||||
// Low stock priority
|
||||
if (data.lowStock.length > 0) {
|
||||
let priority = 35; // Base priority
|
||||
let urgency: PrioritizedCard['urgency'] = 'normal';
|
||||
|
||||
// Check for completely out of stock items
|
||||
const outOfStock = data.lowStock.filter(item => item.amount === 0);
|
||||
if (outOfStock.length > 0) {
|
||||
priority += 25;
|
||||
urgency = 'attention';
|
||||
}
|
||||
|
||||
// Bonus for more items
|
||||
priority += Math.min(data.lowStock.length * 2, 10);
|
||||
|
||||
cards.push({ type: 'lowStock', priority, urgency });
|
||||
}
|
||||
|
||||
// Recipes priority (future - for when we add recipe suggestions)
|
||||
// if (hasRecipeSuggestions) {
|
||||
// let priority = 30;
|
||||
// if (timeOfDay === 'evening') priority += 20; // Dinner planning
|
||||
// if (timeOfDay === 'afternoon') priority += 10; // Lunch/dinner planning
|
||||
// cards.push({ type: 'recipes', priority, urgency: 'low' });
|
||||
// }
|
||||
|
||||
// Sort by priority (highest first)
|
||||
return cards.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the overall household status based on card priorities.
|
||||
* Used for the PulseRing indicator.
|
||||
*/
|
||||
export function getOverallStatus(data: CardData): 'good' | 'attention' | 'urgent' {
|
||||
// Check for expired items
|
||||
const hasExpired = data.expiring.some(item => getDaysUntilExpiry(item.best_before_date) <= 0);
|
||||
if (hasExpired) return 'urgent';
|
||||
|
||||
// Check for items expiring tomorrow
|
||||
const hasExpiringTomorrow = data.expiring.some(item => getDaysUntilExpiry(item.best_before_date) <= 1);
|
||||
if (hasExpiringTomorrow) return 'urgent';
|
||||
|
||||
// Check for items expiring in 3 days
|
||||
const hasExpiringSoon = data.expiring.some(item => getDaysUntilExpiry(item.best_before_date) <= 3);
|
||||
if (hasExpiringSoon) return 'attention';
|
||||
|
||||
// Check for out of stock items
|
||||
const hasOutOfStock = data.lowStock.some(item => item.amount === 0);
|
||||
if (hasOutOfStock) return 'attention';
|
||||
|
||||
// Check for significant low stock
|
||||
if (data.lowStock.length >= 5) return 'attention';
|
||||
|
||||
return 'good';
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
126
src/pages/Connect.tsx
Normal file
126
src/pages/Connect.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { Server, Key, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function Connect() {
|
||||
const { connect, isLoading, error } = useStore();
|
||||
const [serverUrl, setServerUrl] = useState('http://localhost:8080');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if (!serverUrl.trim()) {
|
||||
setLocalError('Server URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
setLocalError('API Key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await connect(serverUrl.trim(), apiKey.trim());
|
||||
} catch (err) {
|
||||
// Error is handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = localError || error;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Server className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Connect to Grocy</h1>
|
||||
<p className="text-gray-500 mt-2">Enter your Grocy server details to get started</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="serverUrl" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Server className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="serverUrl"
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={e => setServerUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
The URL where your Grocy instance is running
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="apiKey" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
placeholder="Your API key"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Find this in Grocy under Settings → Manage API Keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
'Connect'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Don't have Grocy?{' '}
|
||||
<a
|
||||
href="https://grocy.info"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
444
src/pages/Dashboard.tsx
Normal file
444
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useStore } from '../store';
|
||||
import { PulseRing, QuickActionBar, AddItemSheet } from '../components/dashboard';
|
||||
import { ToastContainer, useToast } from '../components/ui';
|
||||
import { getOverallStatus } from '../lib/cardPriority';
|
||||
import { Clock, AlertTriangle, Check, Circle, Package, MapPin, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
stock,
|
||||
shoppingList,
|
||||
locations,
|
||||
chores,
|
||||
toggleShoppingListItem,
|
||||
executeChore,
|
||||
} = useStore();
|
||||
const [isAddSheetOpen, setIsAddSheetOpen] = useState(false);
|
||||
const { toasts, show: showToast, dismiss: dismissToast } = useToast();
|
||||
|
||||
// Alerts data
|
||||
const expiringItems = stock.filter(s => {
|
||||
if (!s.best_before_date) return false;
|
||||
const days = Math.ceil(
|
||||
(new Date(s.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
return days <= 7;
|
||||
});
|
||||
|
||||
const lowStockItems = stock.filter(s => {
|
||||
const minStock = s.product?.min_stock_amount || 0;
|
||||
return minStock > 0 && s.amount <= minStock;
|
||||
});
|
||||
|
||||
const pendingShoppingItems = shoppingList.filter(i => !i.done);
|
||||
const doneShoppingItems = shoppingList.filter(i => i.done);
|
||||
|
||||
const overallStatus = getOverallStatus({
|
||||
expiring: expiringItems,
|
||||
shopping: pendingShoppingItems,
|
||||
lowStock: lowStockItems,
|
||||
});
|
||||
|
||||
// Time-based greeting
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
const handleToggleItem = async (itemId: number, done: boolean) => {
|
||||
try {
|
||||
await toggleShoppingListItem(itemId, done);
|
||||
if (done) showToast('Checked off', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const alerts = [
|
||||
...(expiringItems.length > 0
|
||||
? [{ icon: Clock, label: `${expiringItems.length} expiring`, color: 'var(--color-warning)', to: '/stock' as const }]
|
||||
: []),
|
||||
...(lowStockItems.length > 0
|
||||
? [{ icon: AlertTriangle, label: `${lowStockItems.length} low stock`, color: 'var(--color-urgent)', to: '/stock' as const }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 lg:p-8 max-w-3xl mx-auto pb-32">
|
||||
{/* Header with PulseRing */}
|
||||
<header className="flex items-center gap-4 mb-6">
|
||||
<PulseRing
|
||||
status={overallStatus}
|
||||
summary={
|
||||
overallStatus === 'good'
|
||||
? 'All good'
|
||||
: `${alerts.length} thing${alerts.length !== 1 ? 's' : ''}`
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
{getGreeting()}
|
||||
</h1>
|
||||
{alerts.length > 0 && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{alerts.map((alert, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => navigate({ to: alert.to })}
|
||||
className="flex items-center gap-1 text-xs font-medium transition-opacity hover:opacity-70"
|
||||
style={{ color: alert.color }}
|
||||
>
|
||||
<alert.icon className="w-3.5 h-3.5" />
|
||||
{alert.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{alerts.length === 0 && (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-0.5">
|
||||
Everything looks good
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stock Summary Strip */}
|
||||
{stock.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/stock' })}
|
||||
className="
|
||||
flex-1 flex items-center gap-3 p-3
|
||||
bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]
|
||||
hover:shadow-[var(--shadow-md)] transition-shadow
|
||||
"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-lg font-bold text-[var(--color-text-primary)]">{stock.length}</p>
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">Products</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate({ to: '/stock' })}
|
||||
className="
|
||||
flex-1 flex items-center gap-3 p-3
|
||||
bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]
|
||||
hover:shadow-[var(--shadow-md)] transition-shadow
|
||||
"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--color-info)]/10 flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-[var(--color-info)]" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-lg font-bold text-[var(--color-text-primary)]">{locations.length}</p>
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">Locations</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shopping List - Main Content */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Shopping List
|
||||
</h2>
|
||||
{pendingShoppingItems.length > 0 && (
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">
|
||||
{pendingShoppingItems.length} item{pendingShoppingItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingShoppingItems.length === 0 && doneShoppingItems.length === 0 ? (
|
||||
<div className="bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] p-8 text-center">
|
||||
<p className="text-[var(--color-text-secondary)]">
|
||||
Your shopping list is empty
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsAddSheetOpen(true)}
|
||||
className="mt-3 text-sm font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-dark)]"
|
||||
>
|
||||
Add something
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
{/* Pending items */}
|
||||
{pendingShoppingItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border-light)] last:border-b-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleToggleItem(item.id, true)}
|
||||
className="
|
||||
w-6 h-6 rounded-full
|
||||
border-2 border-[var(--color-border)]
|
||||
flex items-center justify-center
|
||||
hover:border-[var(--color-primary)]
|
||||
transition-colors
|
||||
shrink-0
|
||||
"
|
||||
>
|
||||
<Circle className="w-4 h-4 text-transparent" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || item.note || `Item #${item.id}`}
|
||||
</p>
|
||||
</div>
|
||||
{item.amount > 1 && (
|
||||
<span className="text-sm text-[var(--color-text-secondary)] shrink-0">
|
||||
x{item.amount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Done items (dimmed) */}
|
||||
{doneShoppingItems.length > 0 && (
|
||||
<>
|
||||
{pendingShoppingItems.length > 0 && (
|
||||
<div className="px-4 py-2 bg-[var(--color-background)]">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">Done</p>
|
||||
</div>
|
||||
)}
|
||||
{doneShoppingItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border-light)] last:border-b-0 opacity-50"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleToggleItem(item.id, false)}
|
||||
className="
|
||||
w-6 h-6 rounded-full
|
||||
bg-[var(--color-primary)]
|
||||
flex items-center justify-center
|
||||
shrink-0
|
||||
"
|
||||
>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[var(--color-text-secondary)] line-through truncate">
|
||||
{item.product?.name || item.note || `Item #${item.id}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Chores Due */}
|
||||
{chores.length > 0 && (() => {
|
||||
const dueChores = chores.filter(c => {
|
||||
if (!c.next_estimated_execution_time) return true; // No schedule = overdue
|
||||
return new Date(c.next_estimated_execution_time) <= new Date();
|
||||
});
|
||||
const upcomingChores = chores.filter(c => {
|
||||
if (!c.next_estimated_execution_time) return false;
|
||||
const diff = Math.ceil((new Date(c.next_estimated_execution_time).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return diff > 0 && diff <= 3;
|
||||
});
|
||||
const displayChores = [...dueChores, ...upcomingChores].slice(0, 5);
|
||||
|
||||
if (displayChores.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Chores
|
||||
</h2>
|
||||
{dueChores.length > 0 && (
|
||||
<span className="text-sm text-[var(--color-warning)] font-medium">
|
||||
{dueChores.length} due
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
{displayChores.map((chore) => {
|
||||
const isDue = !chore.next_estimated_execution_time || new Date(chore.next_estimated_execution_time) <= new Date();
|
||||
return (
|
||||
<div
|
||||
key={chore.chore_id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border-light)] last:border-b-0"
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await executeChore(chore.chore_id);
|
||||
showToast(`Done: ${chore.chore_name}`, 'success');
|
||||
} catch (e) {
|
||||
showToast('Failed to complete chore', 'error');
|
||||
}
|
||||
}}
|
||||
className="
|
||||
w-7 h-7 rounded-[var(--radius-md)]
|
||||
border-2 flex items-center justify-center shrink-0
|
||||
hover:bg-[var(--color-primary)]/10
|
||||
transition-colors
|
||||
border-[var(--color-border)]
|
||||
"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4 text-transparent" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{chore.chore_name || `Chore #${chore.chore_id}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-medium shrink-0 ${
|
||||
isDue ? 'text-[var(--color-warning)]' : 'text-[var(--color-text-tertiary)]'
|
||||
}`}>
|
||||
{isDue ? 'Due' : (() => {
|
||||
const days = Math.ceil((new Date(chore.next_estimated_execution_time!).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return days === 1 ? 'Tomorrow' : `${days} days`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Expiring Soon - Compact */}
|
||||
{expiringItems.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Expiring Soon
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/stock' })}
|
||||
className="text-sm text-[var(--color-primary)] font-medium"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
{expiringItems
|
||||
.sort((a, b) => {
|
||||
const daysA = Math.ceil((new Date(a.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const daysB = Math.ceil((new Date(b.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return daysA - daysB;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map((item) => {
|
||||
const days = Math.ceil((new Date(item.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = days <= 1;
|
||||
const isWarning = days <= 3 && days > 1;
|
||||
return (
|
||||
<div
|
||||
key={`${item.product_id}-${item.best_before_date}`}
|
||||
className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-light)] last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || `Product #${item.product_id}`}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{item.amount} in stock
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ml-3 shrink-0 ${
|
||||
isUrgent ? 'text-[var(--color-urgent)]' :
|
||||
isWarning ? 'text-[var(--color-warning)]' :
|
||||
'text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{days <= 0 ? 'Expired' : days === 1 ? 'Tomorrow' : `${days} days`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Low Stock - Compact */}
|
||||
{lowStockItems.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Low Stock
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/stock' })}
|
||||
className="text-sm text-[var(--color-primary)] font-medium"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-surface)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
{lowStockItems.slice(0, 4).map((item) => {
|
||||
const minStock = item.product?.min_stock_amount || 0;
|
||||
const pct = minStock > 0 ? Math.round((item.amount / minStock) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
key={item.product_id}
|
||||
className="px-4 py-3 border-b border-[var(--color-border-light)] last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{item.product?.name || `Product #${item.product_id}`}
|
||||
</p>
|
||||
<span className={`text-sm font-medium ml-3 shrink-0 ${
|
||||
item.amount === 0 ? 'text-[var(--color-urgent)]' : 'text-[var(--color-warning)]'
|
||||
}`}>
|
||||
{item.amount === 0 ? 'Out' : `${item.amount}/${minStock}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[var(--color-border-light)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
item.amount === 0 ? 'bg-[var(--color-urgent)]' : 'bg-[var(--color-warning)]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Action Bar */}
|
||||
<QuickActionBar
|
||||
onAdd={() => setIsAddSheetOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Add Item Sheet */}
|
||||
<AddItemSheet
|
||||
isOpen={isAddSheetOpen}
|
||||
onClose={() => setIsAddSheetOpen(false)}
|
||||
onSuccess={(message) => showToast(message, 'success')}
|
||||
onError={(message) => showToast(message, 'error')}
|
||||
/>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/pages/Recipes.tsx
Normal file
15
src/pages/Recipes.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { UtensilsCrossed } from 'lucide-react';
|
||||
|
||||
export function Recipes() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">Recipes</h1>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<UtensilsCrossed className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Recipes Coming Soon</h2>
|
||||
<p className="text-gray-500">Recipe management and meal planning will be added here.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/pages/Settings.tsx
Normal file
79
src/pages/Settings.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useStore } from '../store';
|
||||
import { Server, User, LogOut } from 'lucide-react';
|
||||
|
||||
export function Settings() {
|
||||
const { systemInfo, user, disconnect } = useStore();
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">Settings</h1>
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Server Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Server className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Server</h2>
|
||||
</div>
|
||||
{systemInfo && (
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Grocy Version</dt>
|
||||
<dd className="font-medium text-gray-900">{systemInfo.grocy_version.Version}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Release Date</dt>
|
||||
<dd className="font-medium text-gray-900">{systemInfo.grocy_version.ReleaseDate}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">PHP Version</dt>
|
||||
<dd className="font-medium text-gray-900">{systemInfo.php_version}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">SQLite Version</dt>
|
||||
<dd className="font-medium text-gray-900">{systemInfo.sqlite_version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">User</h2>
|
||||
</div>
|
||||
{user && (
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Username</dt>
|
||||
<dd className="font-medium text-gray-900">{user.username}</dd>
|
||||
</div>
|
||||
{user.display_name && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Display Name</dt>
|
||||
<dd className="font-medium text-gray-900">{user.display_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disconnect */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Connection</h2>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Disconnect from the current Grocy server. You'll need to reconnect to use the app.
|
||||
</p>
|
||||
<button
|
||||
onClick={disconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
src/pages/Shopping.tsx
Normal file
184
src/pages/Shopping.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { Check, Trash2, Plus, ShoppingCart } from 'lucide-react';
|
||||
|
||||
export function Shopping() {
|
||||
const { shoppingList, products, toggleShoppingListItem, removeFromShoppingList, addToShoppingList } = useStore();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||
const [amount, setAmount] = useState(1);
|
||||
|
||||
const pendingItems = shoppingList.filter(item => !item.done);
|
||||
const doneItems = shoppingList.filter(item => item.done);
|
||||
|
||||
const getProductName = (productId: number | undefined) => {
|
||||
if (!productId) return null;
|
||||
return products.find(p => p.id === productId)?.name;
|
||||
};
|
||||
|
||||
const handleToggle = async (itemId: number, currentDone: boolean) => {
|
||||
try {
|
||||
await toggleShoppingListItem(itemId, !currentDone);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (itemId: number) => {
|
||||
try {
|
||||
await removeFromShoppingList(itemId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedProduct) return;
|
||||
try {
|
||||
await addToShoppingList(Number(selectedProduct), amount);
|
||||
setShowAddModal(false);
|
||||
setSelectedProduct('');
|
||||
setAmount(1);
|
||||
} catch (error) {
|
||||
console.error('Failed to add item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shopping List</h1>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending items */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 mb-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-900">To Buy ({pendingItems.length})</h2>
|
||||
</div>
|
||||
{pendingItems.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
<ShoppingCart className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>Your shopping list is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{pendingItems.map(item => (
|
||||
<li key={item.id} className="px-6 py-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => handleToggle(item.id, item.done)}
|
||||
className="w-6 h-6 rounded-full border-2 border-gray-300 hover:border-green-500 hover:bg-green-50 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Check className="w-4 h-4 text-transparent" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">
|
||||
{item.product_id ? getProductName(item.product_id) : item.note || 'Unknown item'}
|
||||
</p>
|
||||
{item.note && item.product_id && (
|
||||
<p className="text-sm text-gray-500">{item.note}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-600 font-medium">{item.amount}x</span>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Done items */}
|
||||
{doneItems.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-500">Completed ({doneItems.length})</h2>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{doneItems.map(item => (
|
||||
<li key={item.id} className="px-6 py-4 flex items-center gap-4 opacity-60">
|
||||
<button
|
||||
onClick={() => handleToggle(item.id, item.done)}
|
||||
className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center"
|
||||
>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-500 line-through">
|
||||
{item.product_id ? getProductName(item.product_id) : item.note || 'Unknown item'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-gray-400">{item.amount}x</span>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Add to Shopping List</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={e => setSelectedProduct(e.target.value ? Number(e.target.value) : '')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{products.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={amount}
|
||||
onChange={e => setAmount(Number(e.target.value))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedProduct}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/pages/Stock.tsx
Normal file
169
src/pages/Stock.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../store';
|
||||
import { Search, Plus, Minus, Package } from 'lucide-react';
|
||||
|
||||
export function Stock() {
|
||||
const { stock, locations, quantityUnits, consumeProduct, addProduct } = useStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedLocation, setSelectedLocation] = useState<number | 'all'>('all');
|
||||
|
||||
const filteredStock = stock.filter(item => {
|
||||
const matchesSearch = item.product?.name?.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesLocation = selectedLocation === 'all' || item.product?.location_id === selectedLocation;
|
||||
return matchesSearch && matchesLocation;
|
||||
});
|
||||
|
||||
const getQuantityUnit = (quId: number) => {
|
||||
return quantityUnits.find(qu => qu.id === quId);
|
||||
};
|
||||
|
||||
const getLocation = (locationId: number | undefined) => {
|
||||
if (!locationId) return null;
|
||||
return locations.find(l => l.id === locationId);
|
||||
};
|
||||
|
||||
const handleConsume = async (productId: number) => {
|
||||
try {
|
||||
await consumeProduct(productId, 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to consume:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (productId: number) => {
|
||||
try {
|
||||
await addProduct(productId, 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to add:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getDaysUntilExpiry = (date: string | undefined) => {
|
||||
if (!date) return null;
|
||||
return Math.ceil((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inventory</h1>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedLocation}
|
||||
onChange={e => setSelectedLocation(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Locations</option>
|
||||
{locations.map(loc => (
|
||||
<option key={loc.id} value={loc.id}>{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stock table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Product</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Best Before</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredStock.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No products found</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredStock.map(item => {
|
||||
const qu = getQuantityUnit(item.product?.qu_id_stock || 0);
|
||||
const location = getLocation(item.product?.location_id);
|
||||
const daysUntilExpiry = getDaysUntilExpiry(item.best_before_date);
|
||||
|
||||
return (
|
||||
<tr key={item.product_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{item.product?.name || `Product #${item.product_id}`}</p>
|
||||
{item.product?.description && (
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{item.product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{location?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{item.amount}</span>
|
||||
{qu && <span className="text-gray-500 ml-1">{item.amount === 1 ? qu.name : qu.name_plural || qu.name}</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{daysUntilExpiry !== null ? (
|
||||
<span className={`text-sm ${
|
||||
daysUntilExpiry < 0 ? 'text-red-600 font-medium' :
|
||||
daysUntilExpiry <= 3 ? 'text-red-600' :
|
||||
daysUntilExpiry <= 7 ? 'text-amber-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{daysUntilExpiry < 0
|
||||
? `Expired ${Math.abs(daysUntilExpiry)} days ago`
|
||||
: daysUntilExpiry === 0
|
||||
? 'Expires today'
|
||||
: `${daysUntilExpiry} days`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleConsume(item.product_id)}
|
||||
disabled={item.amount <= 0}
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Consume"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAdd(item.product_id)}
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Add"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/pages/index.ts
Normal file
6
src/pages/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Connect } from './Connect';
|
||||
export { Dashboard } from './Dashboard';
|
||||
export { Stock } from './Stock';
|
||||
export { Shopping } from './Shopping';
|
||||
export { Recipes } from './Recipes';
|
||||
export { Settings } from './Settings';
|
||||
253
src/store/index.ts
Normal file
253
src/store/index.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { create } from 'zustand';
|
||||
import grocyApi from '../api/client';
|
||||
import type { StockProduct, Product, Location, QuantityUnit, ProductGroup, ShoppingListItem, SystemInfo, User, ChoreEntry } from '../types/grocy';
|
||||
|
||||
interface AppState {
|
||||
// Connection
|
||||
isConnected: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
systemInfo: SystemInfo | null;
|
||||
user: User | null;
|
||||
|
||||
// Stock data
|
||||
stock: StockProduct[];
|
||||
products: Product[];
|
||||
locations: Location[];
|
||||
quantityUnits: QuantityUnit[];
|
||||
productGroups: ProductGroup[];
|
||||
|
||||
// Shopping list
|
||||
shoppingList: ShoppingListItem[];
|
||||
|
||||
// Chores
|
||||
chores: ChoreEntry[];
|
||||
|
||||
// Actions
|
||||
connect: (serverUrl: string, apiKey: string) => Promise<void>;
|
||||
disconnect: () => void;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
|
||||
fetchStock: () => Promise<void>;
|
||||
fetchMasterData: () => Promise<void>;
|
||||
fetchShoppingList: () => Promise<void>;
|
||||
fetchChores: () => Promise<void>;
|
||||
executeChore: (choreId: number) => Promise<void>;
|
||||
|
||||
addProduct: (productId: number, amount: number, options?: { best_before_date?: string; price?: number; location_id?: number }) => Promise<void>;
|
||||
consumeProduct: (productId: number, amount: number, options?: { spoiled?: boolean }) => Promise<void>;
|
||||
|
||||
addToShoppingList: (productId: number, amount: number, note?: string) => Promise<void>;
|
||||
removeFromShoppingList: (itemId: number) => Promise<void>;
|
||||
toggleShoppingListItem: (itemId: number, done: boolean) => Promise<void>;
|
||||
|
||||
createProduct: (name: string, options?: Partial<Product>) => Promise<Product>;
|
||||
createAndAddToStock: (name: string, amount: number, options?: { best_before_date?: string }) => Promise<void>;
|
||||
createAndAddToShoppingList: (name: string, amount: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
isConnected: grocyApi.isConfigured(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
systemInfo: null,
|
||||
user: null,
|
||||
|
||||
stock: [],
|
||||
products: [],
|
||||
locations: [],
|
||||
quantityUnits: [],
|
||||
productGroups: [],
|
||||
shoppingList: [],
|
||||
chores: [],
|
||||
|
||||
connect: async (serverUrl: string, apiKey: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
grocyApi.configure(serverUrl, apiKey);
|
||||
|
||||
// Verify connection
|
||||
const [systemInfo, user] = await Promise.all([
|
||||
grocyApi.getSystemInfo(),
|
||||
grocyApi.getCurrentUser(),
|
||||
]);
|
||||
|
||||
set({ isConnected: true, systemInfo, user, isLoading: false });
|
||||
|
||||
// Fetch initial data
|
||||
await Promise.all([
|
||||
get().fetchMasterData(),
|
||||
get().fetchStock(),
|
||||
get().fetchShoppingList(),
|
||||
get().fetchChores(),
|
||||
]);
|
||||
} catch (error) {
|
||||
grocyApi.clearConfig();
|
||||
set({
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Connection failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
grocyApi.clearConfig();
|
||||
set({
|
||||
isConnected: false,
|
||||
systemInfo: null,
|
||||
user: null,
|
||||
stock: [],
|
||||
products: [],
|
||||
locations: [],
|
||||
quantityUnits: [],
|
||||
productGroups: [],
|
||||
shoppingList: [],
|
||||
chores: [],
|
||||
});
|
||||
},
|
||||
|
||||
checkConnection: async () => {
|
||||
if (!grocyApi.isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [systemInfo, user] = await Promise.all([
|
||||
grocyApi.getSystemInfo(),
|
||||
grocyApi.getCurrentUser(),
|
||||
]);
|
||||
set({ isConnected: true, systemInfo, user });
|
||||
|
||||
// Fetch initial data
|
||||
await Promise.all([
|
||||
get().fetchMasterData(),
|
||||
get().fetchStock(),
|
||||
get().fetchShoppingList(),
|
||||
get().fetchChores(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
set({ isConnected: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchStock: async () => {
|
||||
try {
|
||||
const stock = await grocyApi.getStock();
|
||||
set({ stock });
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to fetch stock' });
|
||||
}
|
||||
},
|
||||
|
||||
fetchMasterData: async () => {
|
||||
try {
|
||||
const [products, locations, quantityUnits, productGroups] = await Promise.all([
|
||||
grocyApi.getObjects<Product>('products'),
|
||||
grocyApi.getObjects<Location>('locations'),
|
||||
grocyApi.getObjects<QuantityUnit>('quantity_units'),
|
||||
grocyApi.getObjects<ProductGroup>('product_groups'),
|
||||
]);
|
||||
set({ products, locations, quantityUnits, productGroups });
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to fetch master data' });
|
||||
}
|
||||
},
|
||||
|
||||
fetchShoppingList: async () => {
|
||||
try {
|
||||
const shoppingList = await grocyApi.getObjects<ShoppingListItem>('shopping_list');
|
||||
set({ shoppingList });
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to fetch shopping list' });
|
||||
}
|
||||
},
|
||||
|
||||
fetchChores: async () => {
|
||||
try {
|
||||
const chores = await grocyApi.getChores();
|
||||
set({ chores: chores as unknown as ChoreEntry[] });
|
||||
} catch (error) {
|
||||
// Chores might not be configured - don't block the app
|
||||
console.warn('Failed to fetch chores:', error);
|
||||
set({ chores: [] });
|
||||
}
|
||||
},
|
||||
|
||||
executeChore: async (choreId) => {
|
||||
await grocyApi.executeChore(choreId);
|
||||
await get().fetchChores();
|
||||
},
|
||||
|
||||
addProduct: async (productId, amount, options) => {
|
||||
await grocyApi.addProduct(productId, amount, options);
|
||||
await get().fetchStock();
|
||||
},
|
||||
|
||||
consumeProduct: async (productId, amount, options) => {
|
||||
await grocyApi.consumeProduct(productId, amount, options);
|
||||
await get().fetchStock();
|
||||
},
|
||||
|
||||
addToShoppingList: async (productId, amount, note) => {
|
||||
await grocyApi.addToShoppingList(productId, amount, 1, note);
|
||||
await get().fetchShoppingList();
|
||||
},
|
||||
|
||||
removeFromShoppingList: async (itemId) => {
|
||||
await grocyApi.deleteObject('shopping_list', itemId);
|
||||
await get().fetchShoppingList();
|
||||
},
|
||||
|
||||
toggleShoppingListItem: async (itemId, done) => {
|
||||
await grocyApi.updateObject('shopping_list', itemId, { done });
|
||||
await get().fetchShoppingList();
|
||||
},
|
||||
|
||||
createProduct: async (name, options = {}) => {
|
||||
// Use first available quantity unit, or 1 as fallback
|
||||
const defaultQuId = get().quantityUnits[0]?.id || 1;
|
||||
|
||||
const result = await grocyApi.createObject<Product>('products', {
|
||||
name,
|
||||
min_stock_amount: 0,
|
||||
default_best_before_days: 0,
|
||||
default_best_before_days_after_open: 0,
|
||||
default_best_before_days_after_freezing: 0,
|
||||
default_best_before_days_after_thawing: 0,
|
||||
qu_id_purchase: defaultQuId,
|
||||
qu_id_stock: defaultQuId,
|
||||
enable_tare_weight_handling: false,
|
||||
tare_weight: 0,
|
||||
not_check_stock_fulfillment_for_recipes: false,
|
||||
cumulate_min_stock_amount_of_sub_products: false,
|
||||
due_type: 1,
|
||||
quick_consume_amount: 1,
|
||||
hide_on_stock_overview: false,
|
||||
...options,
|
||||
});
|
||||
// Refresh products list
|
||||
await get().fetchMasterData();
|
||||
// Return the created product
|
||||
const products = get().products;
|
||||
const created = products.find(p => p.id === result.created_object_id);
|
||||
if (!created) throw new Error('Failed to find created product');
|
||||
return created;
|
||||
},
|
||||
|
||||
createAndAddToStock: async (name, amount, options = {}) => {
|
||||
const product = await get().createProduct(name);
|
||||
await grocyApi.addProduct(product.id, amount, options);
|
||||
await get().fetchStock();
|
||||
},
|
||||
|
||||
createAndAddToShoppingList: async (name, amount) => {
|
||||
const product = await get().createProduct(name);
|
||||
await grocyApi.addToShoppingList(product.id, amount);
|
||||
await get().fetchShoppingList();
|
||||
},
|
||||
}));
|
||||
179
src/types/grocy.ts
Normal file
179
src/types/grocy.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Core Grocy types derived from the API
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
location_id?: number;
|
||||
qu_id_purchase: number;
|
||||
qu_id_stock: number;
|
||||
qu_factor_purchase_to_stock: number;
|
||||
barcode?: string;
|
||||
min_stock_amount: number;
|
||||
default_best_before_days: number;
|
||||
default_best_before_days_after_open: number;
|
||||
default_best_before_days_after_freezing: number;
|
||||
default_best_before_days_after_thawing: number;
|
||||
picture_file_name?: string;
|
||||
enable_tare_weight_handling: boolean;
|
||||
tare_weight: number;
|
||||
not_check_stock_fulfillment_for_recipes: boolean;
|
||||
parent_product_id?: number;
|
||||
calories?: number;
|
||||
cumulate_min_stock_amount_of_sub_products: boolean;
|
||||
due_type: number;
|
||||
quick_consume_amount: number;
|
||||
hide_on_stock_overview: boolean;
|
||||
product_group_id?: number;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface StockEntry {
|
||||
id: number;
|
||||
product_id: number;
|
||||
amount: number;
|
||||
best_before_date: string;
|
||||
purchased_date: string;
|
||||
stock_id: string;
|
||||
price?: number;
|
||||
open: boolean;
|
||||
opened_date?: string;
|
||||
location_id?: number;
|
||||
shopping_location_id?: number;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface StockProduct {
|
||||
product_id: number;
|
||||
product: Product;
|
||||
amount: number;
|
||||
amount_aggregated: number;
|
||||
amount_opened: number;
|
||||
amount_opened_aggregated: number;
|
||||
best_before_date: string;
|
||||
is_aggregated_amount: boolean;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
is_freezer: boolean;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface QuantityUnit {
|
||||
id: number;
|
||||
name: string;
|
||||
name_plural?: string;
|
||||
description?: string;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProductGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
id: number;
|
||||
shopping_list_id: number;
|
||||
product_id?: number;
|
||||
note?: string;
|
||||
amount: number;
|
||||
done: boolean;
|
||||
qu_id?: number;
|
||||
row_created_timestamp: string;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
export interface ShoppingList {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
picture_file_name?: string;
|
||||
base_servings: number;
|
||||
desired_servings: number;
|
||||
not_check_shoppinglist: boolean;
|
||||
type: string;
|
||||
product_id?: number;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface Chore {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
period_type: string;
|
||||
period_days?: number;
|
||||
period_config?: string;
|
||||
track_date_only: boolean;
|
||||
rollover: boolean;
|
||||
assignment_type?: string;
|
||||
assignment_config?: string;
|
||||
next_execution_assigned_to_user_id?: number;
|
||||
consume_product_on_execution?: boolean;
|
||||
product_id?: number;
|
||||
product_amount?: number;
|
||||
period_interval?: number;
|
||||
active: boolean;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChoreEntry {
|
||||
id: number;
|
||||
chore_id: number;
|
||||
chore_name?: string;
|
||||
next_estimated_execution_time?: string;
|
||||
next_execution_assigned_to_user_id?: number;
|
||||
last_tracked_time?: string;
|
||||
track_count?: number;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
grocy_version: {
|
||||
Version: string;
|
||||
ReleaseDate: string;
|
||||
};
|
||||
php_version: string;
|
||||
sqlite_version: string;
|
||||
os: string;
|
||||
client: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
display_name?: string;
|
||||
picture_file_name?: string;
|
||||
row_created_timestamp: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
export type EntityName =
|
||||
| 'products'
|
||||
| 'locations'
|
||||
| 'quantity_units'
|
||||
| 'product_groups'
|
||||
| 'shopping_list'
|
||||
| 'shopping_lists'
|
||||
| 'recipes'
|
||||
| 'recipes_pos'
|
||||
| 'chores'
|
||||
| 'tasks'
|
||||
| 'users';
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
65
vite.config.ts
Normal file
65
vite.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png'],
|
||||
manifest: {
|
||||
name: 'Stashpile',
|
||||
short_name: 'Stashpile',
|
||||
description: 'Smart household management',
|
||||
theme_color: '#6B8E6B',
|
||||
background_color: '#FAFAF8',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
// Proxy API requests to local Grocy instance during development
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user