Merge pull request 'dev' (#1) from dev into main
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m14s
Build & Deploy Costco Grocery List / deploy (push) Successful in 13s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s

Reviewed-on: https://git.nicosaya.com/nalalangan/costco-grocery-list/pulls/1
This commit is contained in:
nalalangan 2026-01-02 15:12:31 -10:00
commit 0b0283127b
77 changed files with 6710 additions and 505 deletions

91
.copilotignore Normal file
View File

@ -0,0 +1,91 @@
# Environment files
.env
.env.*
.env.local
.env.development
.env.production
.env.test
# Dependencies
node_modules/
vendor/
bower_components/
# Build outputs
dist/
build/
.next/
out/
.nuxt/
.cache/
.parcel-cache/
.vite/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Database files
*.sqlite
*.sqlite3
*.db
# Secrets and credentials
*.key
*.pem
*.cert
*.crt
secrets.json
credentials.json
# Coverage reports
coverage/
.nyc_output/
*.lcov
# Test artifacts
__tests__/__snapshots__/
.pytest_cache/
.jest/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# CI/CD workflows
.github/workflows/
.gitea/workflows/
# Compiled files
*.pyc
*.pyo
*.pyd
__pycache__/
*.so
*.dll
*.dylib
*.exe
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Docker
docker-compose.override.yml
# Large data files
*.csv
*.xlsx
*.zip
*.tar.gz
*.rar

197
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,197 @@
# Costco Grocery List - AI Agent Instructions
## Architecture Overview
This is a full-stack grocery list management app with **role-based access control (RBAC)**:
- **Backend**: Node.js + Express + PostgreSQL (port 5000)
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **Deployment**: Docker Compose with separate dev/prod configurations
### Key Design Patterns
**Three-tier RBAC system** (`viewer`, `editor`, `admin`):
- `viewer`: Read-only access to grocery lists
- `editor`: Can add items and mark as bought
- `admin`: Full user management via admin panel
- Roles defined in [backend/models/user.model.js](backend/models/user.model.js) and mirrored in [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
**Middleware chain pattern** for protected routes:
```javascript
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
```
- `auth` middleware extracts JWT from `Authorization: Bearer <token>` header
- `requireRole` checks if user's role matches allowed roles
- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples
**Frontend route protection**:
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
- `<RoleGuard allowed={[ROLES.ADMIN]}>`: Requires specific role(s), redirects to `/` if unauthorized
- Example in [frontend/src/App.jsx](frontend/src/App.jsx)
## Database Schema
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
**Tables** (inferred from models, no formal migrations):
- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role`
- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by`
- **grocery_history**: Junction table tracking which users added which items
**Important patterns**:
- No migration system - schema changes are manual SQL
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js))
## Development Workflow
### Local Development
```bash
# Start all services with hot-reload against LOCAL database
docker-compose -f docker-compose.dev.yml up
# Backend runs nodemon (watches backend/*.js)
# Frontend runs Vite dev server with HMR on port 3000
```
**Key dev setup details**:
- Volume mounts preserve `node_modules` in containers while syncing source code
- Backend uses `Dockerfile` (standard) with `npm run dev` override
- Frontend uses `Dockerfile.dev` with `CHOKIDAR_USEPOLLING=true` for file watching
- Both connect to **external PostgreSQL server** (configured in `backend/.env`)
- No database container in compose - DB is managed separately
### Production Build
```bash
# Local production build (for testing)
docker-compose -f docker-compose.prod.yml up --build
# Actual production uses pre-built images
docker-compose up # Pulls from private registry
```
### CI/CD Pipeline (Gitea Actions)
See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow:
**Build stage** (on push to `main`):
1. Run backend tests (`npm test --if-present`)
2. Build backend image with tags: `:latest` and `:<commit-sha>`
3. Build frontend image with tags: `:latest` and `:<commit-sha>`
4. Push both images to private registry
**Deploy stage**:
1. SSH to production server
2. Upload `docker-compose.yml` to deployment directory
3. Pull latest images and restart containers with `docker compose up -d`
4. Prune old images
**Notify stage**:
- Sends deployment status via webhook
**Required secrets**:
- `REGISTRY_USER`, `REGISTRY_PASS`: Docker registry credentials
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_KEY`: SSH deployment credentials
### Backend Scripts
- `npm run dev`: Start with nodemon
- `npm run build`: esbuild compilation + copy public assets to `dist/`
- `npm test`: Run Jest tests (currently no tests exist)
### Frontend Scripts
- `npm run dev`: Vite dev server (port 5173)
- `npm run build`: TypeScript compilation + Vite production build
### Docker Configurations
**docker-compose.yml** (production):
- Pulls pre-built images from private registry
- Backend on port 5000, frontend on port 3000 (nginx serves on port 80)
- Requires `backend.env` and `frontend.env` files
**docker-compose.dev.yml** (local development):
- Builds images locally from Dockerfile/Dockerfile.dev
- Volume mounts for hot-reload: `./backend:/app` and `./frontend:/app`
- Named volumes preserve `node_modules` between rebuilds
- Backend uses `backend/.env` directly
- Frontend uses `Dockerfile.dev` with polling enabled for cross-platform compatibility
**docker-compose.prod.yml** (local production testing):
- Builds images locally using production Dockerfiles
- Backend: Standard Node.js server
- Frontend: Multi-stage build with nginx serving static files
## Configuration & Environment
**Backend** ([backend/.env](backend/.env)):
- Database connection variables (host, user, password, database name)
- `JWT_SECRET`: Token signing key
- `ALLOWED_ORIGINS`: Comma-separated CORS whitelist (supports static origins + `192.168.*.*` IP ranges)
- `PORT`: Server port (default 5000)
**Frontend** (environment variables):
- `VITE_API_URL`: Backend base URL
**Config accessed via**:
- Backend: `process.env.VAR_NAME`
- Frontend: `import.meta.env.VITE_VAR_NAME` (see [frontend/src/config.ts](frontend/src/config.ts))
## Authentication Flow
1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx))
3. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
## Critical Conventions
### Security Practices
- **Never expose credentials**: Do not hardcode or document actual values for `JWT_SECRET`, database passwords, API keys, or any sensitive configuration
- **No infrastructure details**: Avoid documenting specific IP addresses, domain names, deployment paths, or server locations in code or documentation
- **Environment variables**: Reference `.env` files conceptually - never include actual contents
- **Secrets in CI/CD**: Document that secrets are required, not their values
- **Code review**: Scan all changes for accidentally committed credentials before pushing
### Backend
- **No SQL injection**: Always use parameterized queries (`$1`, `$2`, etc.) with [backend/db/pool.js](backend/db/pool.js)
- **Password hashing**: Use `bcryptjs` for hashing (see [backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
- **CORS**: Dynamic origin validation in [backend/app.js](backend/app.js) allows configured origins + local IPs
- **Error responses**: Return JSON with `{message: "..."}` structure
### Frontend
- **Mixed JSX/TSX**: Some components are `.jsx` (JavaScript), others `.tsx` (TypeScript) - maintain existing file extensions
- **API calls**: Use centralized `api` instance from [frontend/src/api/axios.js](frontend/src/api/axios.js), not raw axios
- **Role checks**: Access role from `AuthContext`, compare with constants from [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
- **Navigation**: Use React Router's `<Navigate>` for redirects, not `window.location` (except in interceptor)
## Common Tasks
**Add a new protected route**:
1. Backend: Add route with `auth` + `requireRole(...)` middleware
2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `<PrivateRoute>` and/or `<RoleGuard>`
**Access user info in backend controller**:
```javascript
const { id, role } = req.user; // Set by auth middleware
```
**Query grocery items with contributors**:
Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table.
## Testing
**Backend**:
- Jest configured at root level ([package.json](package.json))
- Currently **no test files exist** - testing infrastructure needs development
- CI/CD runs `npm test --if-present` but will pass if no tests found
- Focus area: API endpoint testing (use `supertest` with Express)
**Frontend**:
- ESLint only (see [frontend/eslint.config.js](frontend/eslint.config.js))
- No test runner configured
- Manual testing workflow in use
**To add backend tests**:
1. Create `backend/__tests__/` directory
2. Use Jest + Supertest pattern for API tests
3. Mock database calls or use test database

View File

@ -0,0 +1,336 @@
# Item Classification Implementation Guide
## Overview
This implementation adds a classification system to the grocery app allowing users to categorize items by type, group, and store zone. The system supports both creating new items with classification and editing existing items.
## Database Schema
### New Table: `item_classification`
```sql
CREATE TABLE item_classification (
id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL,
item_group VARCHAR(100) NOT NULL,
zone VARCHAR(100),
confidence DECIMAL(3,2) DEFAULT 1.0,
source VARCHAR(20) DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**Key Points:**
- One-to-one relationship with `grocery_list` (id is both PK and FK)
- Cascade delete ensures classification is removed when item is deleted
- Classification values are NOT enforced by DB - controlled at app layer
- `confidence`: 1.0 for user input, lower values reserved for future ML features
- `source`: 'user', 'ml', or 'default'
## Architecture
### Backend
**Constants** ([backend/constants/classifications.js](backend/constants/classifications.js)):
- `ITEM_TYPES`: 11 predefined types (produce, meat, dairy, etc.)
- `ITEM_GROUPS`: Nested object mapping types to their valid groups
- `ZONES`: 10 Costco store zones
- Validation helpers: `isValidItemType()`, `isValidItemGroup()`, `isValidZone()`
**Models** ([backend/models/list.model.js](backend/models/list.model.js)):
- `getClassification(itemId)`: Returns classification for an item or null
- `upsertClassification(itemId, classification)`: INSERT or UPDATE using ON CONFLICT
- `updateItem(id, itemName, quantity)`: Update item name/quantity
**Controllers** ([backend/controllers/lists.controller.js](backend/controllers/lists.controller.js)):
- `getClassification(req, res)`: GET endpoint to fetch classification
- `updateItemWithClassification(req, res)`: Validates and updates item + classification
**Routes** ([backend/routes/list.routes.js](backend/routes/list.routes.js)):
- `GET /list/item/:id/classification` - Fetch classification (all roles)
- `PUT /list/item/:id` - Update item + classification (editor/admin)
### Frontend
**Constants** ([frontend/src/constants/classifications.js](frontend/src/constants/classifications.js)):
- Mirrors backend constants for UI rendering
- `getItemTypeLabel()`: Converts type keys to display names
**API Methods** ([frontend/src/api/list.js](frontend/src/api/list.js)):
- `getClassification(id)`: Fetch classification for item
- `updateItemWithClassification(id, itemName, quantity, classification)`: Update item
**Components:**
1. **EditItemModal** ([frontend/src/components/EditItemModal.jsx](frontend/src/components/EditItemModal.jsx))
- Triggered by long-press (500ms) on any grocery item
- Prepopulates with existing item data + classification
- Cascading selects: item_type → item_group (filtered) → zone
- Validation: Requires item_group if item_type is selected
- Upserts classification with confidence=1.0, source='user'
2. **ItemClassificationModal** ([frontend/src/components/ItemClassificationModal.jsx](frontend/src/components/ItemClassificationModal.jsx))
- Shown after image upload in new item flow
- Required fields: item_type, item_group
- Optional: zone
- User can skip classification entirely
3. **GroceryListItem** ([frontend/src/components/GroceryListItem.jsx](frontend/src/components/GroceryListItem.jsx))
- Long-press detection with 500ms timer
- Supports both touch (mobile) and mouse (desktop) events
- Cancels long-press if finger/mouse moves >10px
**Main Page** ([frontend/src/pages/GroceryList.jsx](frontend/src/pages/GroceryList.jsx)):
- Orchestrates all modal flows
- Add flow: Name → Similar check → Image upload → **Classification** → Save
- Edit flow: Long-press → Load classification → Edit → Save
## User Flows
### FEATURE 1: Edit Existing Item
**Trigger:** Long-press (500ms) on any item in the list
**Steps:**
1. User long-presses an item
2. System fetches existing classification (if any)
3. EditItemModal opens with prepopulated data
4. User can edit:
- Item name
- Quantity
- Classification (type, group, zone)
5. On save:
- Updates `grocery_list` if name/quantity changed
- UPSERTS `item_classification` if classification provided
- Sets confidence=1.0, source='user' for user-edited classification
**Validation:**
- Item name required
- Quantity must be ≥ 1
- If item_type selected, item_group is required
- item_group options filtered by selected item_type
### FEATURE 2: Add New Item with Classification
**Enhanced flow:**
1. User enters item name
2. System checks for similar items (80% threshold)
3. User confirms/edits name
4. User uploads image (or skips)
5. **NEW:** ItemClassificationModal appears
- User selects type, group, zone (optional)
- Or skips classification
6. Item saved to `grocery_list`
7. If classification provided, saved to `item_classification`
## Data Flow Examples
### Adding Item with Classification
```javascript
// 1. Add item to grocery_list
const response = await addItem(itemName, quantity, imageFile);
// 2. Get item ID
const item = await getItemByName(itemName);
// 3. Add classification
await updateItemWithClassification(item.id, undefined, undefined, {
item_type: 'produce',
item_group: 'Fruits',
zone: 'Fresh Foods Right'
});
```
### Editing Item Classification
```javascript
// Update both item data and classification in one call
await updateItemWithClassification(itemId, newName, newQuantity, {
item_type: 'dairy',
item_group: 'Cheese',
zone: 'Dairy Cooler'
});
```
## Backend Request/Response Shapes
### GET /list/item/:id/classification
**Response:**
```json
{
"item_type": "produce",
"item_group": "Fruits",
"zone": "Fresh Foods Right",
"confidence": 1.0,
"source": "user"
}
```
Or `null` if no classification exists.
### PUT /list/item/:id
**Request Body:**
```json
{
"itemName": "Organic Apples",
"quantity": 5,
"classification": {
"item_type": "produce",
"item_group": "Organic Produce",
"zone": "Fresh Foods Right"
}
}
```
**Validation:**
- Validates item_type against allowed values
- Validates item_group is valid for the selected item_type
- Validates zone against allowed zones
- Returns 400 with error message if invalid
**Response:**
```json
{
"message": "Item updated successfully"
}
```
## Setup Instructions
### 1. Run Database Migration
```bash
psql -U your_user -d your_database -f backend/migrations/create_item_classification_table.sql
```
### 2. Restart Backend
The backend automatically loads the new classification constants and routes.
### 3. Test Flows
**Test Edit:**
1. Long-press any item in the list
2. Verify modal opens with item data
3. Select a type, then a group from filtered list
4. Save and verify item updates
**Test Add:**
1. Add a new item
2. Upload image (or skip)
3. Verify classification modal appears
4. Complete classification or skip
5. Verify item appears in list
## Validation Rules Summary
1. **Item Type → Item Group Dependency**
- Must select item_type before item_group becomes available
- Item group dropdown shows only groups for selected type
2. **Required Fields**
- When creating: item_type and item_group are required
- When editing: Classification is optional (can edit name/quantity only)
3. **No Free-Text**
- All classification values are select dropdowns
- Backend validates against predefined constants
4. **Graceful Handling**
- Items without classification display normally
- Edit modal works for both classified and unclassified items
- Classification is always optional (can be skipped)
## State Management (Frontend)
**GroceryList.jsx state:**
```javascript
{
showEditModal: false,
editingItem: null, // Item + classification data
showClassificationModal: false,
classificationPendingItem: {
itemName,
quantity,
imageFile
}
}
```
## Long-Press Implementation Details
**Timing:**
- Desktop (mouse): 500ms hold
- Mobile (touch): 500ms hold with <10px movement threshold
**Event Handlers:**
- `onTouchStart`: Start timer, record position
- `onTouchMove`: Cancel if movement >10px
- `onTouchEnd`: Clear timer
- `onMouseDown`: Start timer
- `onMouseUp`: Clear timer
- `onMouseLeave`: Clear timer (prevent stuck state)
## Future Enhancements
1. **ML Predictions**: Use confidence <1.0 and source='ml' for auto-classification
2. **Bulk Edit**: Select multiple items and apply same classification
3. **Smart Suggestions**: Learn from user's classification patterns
4. **Zone Optimization**: Suggest optimal shopping route based on zones
5. **Analytics**: Most common types/groups, zone coverage
## Troubleshooting
**Issue:** Classification not saving
- Check browser console for validation errors
- Verify item_type/item_group combination is valid
- Ensure item_classification table exists
**Issue:** Long-press not triggering
- Check that user has editor/admin role
- Verify onLongPress prop is passed to GroceryListItem
- Test on both mobile (touch) and desktop (mouse)
**Issue:** Item groups not filtering
- Verify item_type is selected first
- Check that ITEM_GROUPS constant has entries for the selected type
- Ensure state updates are triggering re-render
## Files Modified/Created
### Backend
- ✅ `backend/constants/classifications.js` (NEW)
- ✅ `backend/models/list.model.js` (MODIFIED)
- ✅ `backend/controllers/lists.controller.js` (MODIFIED)
- ✅ `backend/routes/list.routes.js` (MODIFIED)
- ✅ `backend/migrations/create_item_classification_table.sql` (NEW)
### Frontend
- ✅ `frontend/src/constants/classifications.js` (NEW)
- ✅ `frontend/src/api/list.js` (MODIFIED)
- ✅ `frontend/src/components/EditItemModal.jsx` (NEW)
- ✅ `frontend/src/components/ItemClassificationModal.jsx` (NEW)
- ✅ `frontend/src/components/GroceryListItem.jsx` (MODIFIED)
- ✅ `frontend/src/pages/GroceryList.jsx` (MODIFIED)
- ✅ `frontend/src/styles/EditItemModal.css` (NEW)
- ✅ `frontend/src/styles/ItemClassificationModal.css` (NEW)
## API Summary
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/list/item/:id/classification` | All roles | Get item classification |
| PUT | `/list/item/:id` | Editor/Admin | Update item + classification |
## Database Operations
**Upsert Pattern:**
```sql
INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id)
DO UPDATE SET
item_type = EXCLUDED.item_type,
item_group = EXCLUDED.item_group,
zone = EXCLUDED.zone,
confidence = EXCLUDED.confidence,
source = EXCLUDED.source
RETURNING *;
```
This ensures we INSERT if no classification exists, or UPDATE if it does.

View File

@ -0,0 +1,110 @@
# Image Storage Implementation - Complete
## ✅ Implementation Summary
Successfully implemented BYTEA-based image storage for verification purposes in the grocery list app.
## 🗃️ Database Changes
**Run this SQL on your PostgreSQL database:**
```sql
ALTER TABLE grocery_list
ADD COLUMN item_image BYTEA,
ADD COLUMN image_mime_type VARCHAR(50);
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
```
Location: `backend/migrations/add_image_columns.sql`
## 🔧 Backend Changes
### New Dependencies
- **multer**: Handles file uploads
- **sharp**: Compresses and resizes images to 800x800px, JPEG quality 85
### Files Created/Modified
1. **`backend/middleware/image.js`** - Image upload and processing middleware
2. **`backend/models/list.model.js`** - Updated to handle image storage/retrieval
3. **`backend/controllers/lists.controller.js`** - Modified to accept image uploads
4. **`backend/routes/list.routes.js`** - Added multer middleware to `/add` endpoint
### Image Processing
- Maximum size: 10MB upload
- Auto-resized to: 800x800px (fit inside, no enlargement)
- Compression: JPEG quality 85
- Estimated size: 300-500KB per image
## 🎨 Frontend Changes
### New Components
1. **`ImageModal.jsx`** - Click-to-enlarge modal with animations
2. **`ImageModal.css`** - Responsive modal styling
### Files Modified
1. **`AddItemForm.jsx`** - Added image upload with preview
2. **`GroceryListItem.jsx`** - Shows images, click to enlarge
3. **`GroceryList.css`** - Styling for image upload and display
4. **`api/list.js`** - Updated to send FormData with images
5. **`pages/GroceryList.jsx`** - Pass image file to addItem
### Features
- **Image upload** with live preview before submitting
- **Remove image** button on preview
- **Click to enlarge** any item image
- **Responsive modal** with close on ESC or background click
- **Placeholder** shows 📦 emoji for items without images
- **Visual feedback** - images have blue border, hover effects
## 🚀 How to Use
### 1. Run Database Migration
Connect to your PostgreSQL server and run the SQL in `backend/migrations/add_image_columns.sql`
### 2. Restart Backend
```bash
docker-compose -f docker-compose.dev.yml restart backend
```
### 3. Test the Feature
1. Navigate to the grocery list
2. Click the "+" button to add an item
3. Fill in item name and quantity
4. Click "📷 Add Image (Optional)"
5. Select an image (will be automatically compressed)
6. Preview shows before submitting
7. Click "Add Item"
8. The item now displays with the image
9. Click the image to view full-size in modal
## 📊 Storage Estimates
With 14GB allocated:
- **500 items** × 500KB = 250MB
- **1000 items** × 500KB = 500MB
- **2000 items** × 500KB = 1GB
You have plenty of headroom!
## 🔒 Security Features
- File type validation (images only)
- File size limit (10MB max)
- Auto-compression prevents oversized files
- RBAC enforced (editor/admin only can upload)
## 🎯 Next Steps (Optional Enhancements)
1. **Bulk upload** - Add multiple images at once
2. **Image cropping** - Let users crop before upload
3. **Default images** - Library of pre-set grocery item icons
4. **Image search** - Find items by image similarity
5. **Delete image** - Remove image from existing item
## 📝 Notes
- Images are stored as base64 in database responses
- Browser handles base64 → image display automatically
- Modal closes on ESC key or clicking outside
- Images maintain aspect ratio when displayed
- Compression happens server-side (user doesn't wait)

143
SETUP_CHECKLIST.md Normal file
View File

@ -0,0 +1,143 @@
# Item Classification - Setup Checklist
## 🚀 Quick Start
### Step 1: Run Database Migration
```bash
# Connect to your PostgreSQL database and run:
psql -U your_username -d your_database_name -f backend/migrations/create_item_classification_table.sql
# Or copy-paste the SQL directly into your database client
```
### Step 2: Restart Docker Containers
```bash
# From project root:
docker-compose -f docker-compose.dev.yml down
docker-compose -f docker-compose.dev.yml up --build
```
### Step 3: Test the Features
#### ✅ Test Edit Flow (Long-Press)
1. Open the app and navigate to your grocery list
2. **Long-press** (500ms) on any existing item
3. EditItemModal should open with prepopulated data
4. Try selecting:
- Item Type: "Produce"
- Item Group: "Fruits" (filtered by type)
- Zone: "Fresh Foods Right" (optional)
5. Click "Save Changes"
6. Item should update successfully
#### ✅ Test Add Flow (with Classification)
1. Click the "+" button to add a new item
2. Enter item name: "Organic Bananas"
3. Set quantity: 3
4. Click "Add Item"
5. Upload an image (or skip)
6. **NEW:** ItemClassificationModal appears
7. Select:
- Item Type: "Produce"
- Item Group: "Organic Produce"
- Zone: "Fresh Foods Right"
8. Click "Confirm"
9. Item should appear in list
#### ✅ Test Skip Classification
1. Add a new item
2. Upload/skip image
3. When ItemClassificationModal appears, click "Skip for Now"
4. Item should be added without classification
5. Long-press the item to add classification later
## 🐛 Common Issues
### Long-press not working?
- Make sure you're logged in as editor or admin
- Try both mobile (touch) and desktop (mouse)
- Check browser console for errors
### Classification not saving?
- Verify database migration was successful
- Check that item_classification table exists
- Look for validation errors in browser console
### Item groups not showing?
- Ensure you selected an item type first
- The groups are filtered by the selected type
## 📊 What Was Implemented
### Backend (Node.js + Express + PostgreSQL)
- ✅ Classification constants with validation helpers
- ✅ Database model methods (getClassification, upsertClassification, updateItem)
- ✅ API endpoints (GET /list/item/:id/classification, PUT /list/item/:id)
- ✅ Controller with validation logic
- ✅ Database migration script
### Frontend (React + TypeScript)
- ✅ Classification constants (mirrored from backend)
- ✅ EditItemModal component with cascading selects
- ✅ ItemClassificationModal for new items
- ✅ Long-press detection (500ms) on GroceryListItem
- ✅ Enhanced add flow with classification step
- ✅ API integration methods
## 📝 Key Features
1. **Edit Existing Items**
- Long-press any item (bought or unbought)
- Edit name, quantity, and classification
- Classification is optional
2. **Classify New Items**
- After image upload, classification step appears
- Can skip classification if desired
- Required: item_type and item_group
- Optional: zone
3. **Smart Filtering**
- Item groups filtered by selected item type
- Only valid combinations allowed
- No free-text entry
4. **Confidence Tracking**
- User-provided: confidence=1.0, source='user'
- Ready for future ML features (confidence<1.0, source='ml')
## 📖 Full Documentation
See [CLASSIFICATION_IMPLEMENTATION.md](CLASSIFICATION_IMPLEMENTATION.md) for:
- Complete architecture details
- API request/response examples
- Data flow diagrams
- Troubleshooting guide
- Future enhancement ideas
## 🎯 Next Steps
1. Run the database migration (Step 1 above)
2. Restart your containers (Step 2 above)
3. Test both flows (Step 3 above)
4. Optionally: Customize the classification constants in:
- `backend/constants/classifications.js`
- `frontend/src/constants/classifications.js`
## ⚡ Pro Tips
- **Long-press timing**: 500ms - not too fast, not too slow
- **Movement threshold**: Keep finger/mouse within 10px to trigger
- **Desktop testing**: Hold mouse button down for 500ms
- **Skip button**: Always available - classification is never forced
- **Edit anytime**: Long-press to edit classification after creation
## 🎨 UI/UX Notes
- **EditItemModal**: Full-screen on mobile, centered card on desktop
- **ItemClassificationModal**: Appears after image upload (seamless flow)
- **Long-press**: Provides haptic feedback on supported devices
- **Validation**: Inline validation with user-friendly error messages
- **Loading states**: "Saving..." text during API calls
Enjoy your new classification system! 🎉

View File

@ -0,0 +1,180 @@
// Backend classification constants (mirror of frontend)
const ITEM_TYPES = {
PRODUCE: "produce",
MEAT: "meat",
DAIRY: "dairy",
BAKERY: "bakery",
FROZEN: "frozen",
PANTRY: "pantry",
BEVERAGE: "beverage",
SNACK: "snack",
HOUSEHOLD: "household",
PERSONAL_CARE: "personal_care",
OTHER: "other",
};
const ITEM_GROUPS = {
[ITEM_TYPES.PRODUCE]: [
"Fruits",
"Vegetables",
"Salad Mix",
"Herbs",
"Organic Produce",
],
[ITEM_TYPES.MEAT]: [
"Beef",
"Pork",
"Chicken",
"Seafood",
"Deli Meat",
"Prepared Meat",
],
[ITEM_TYPES.DAIRY]: [
"Milk",
"Cheese",
"Yogurt",
"Butter",
"Eggs",
"Cream",
],
[ITEM_TYPES.BAKERY]: [
"Bread",
"Rolls",
"Pastries",
"Cakes",
"Bagels",
"Tortillas",
],
[ITEM_TYPES.FROZEN]: [
"Frozen Meals",
"Ice Cream",
"Frozen Vegetables",
"Frozen Meat",
"Pizza",
"Desserts",
],
[ITEM_TYPES.PANTRY]: [
"Canned Goods",
"Pasta",
"Rice",
"Cereal",
"Condiments",
"Spices",
"Baking",
"Oils",
],
[ITEM_TYPES.BEVERAGE]: [
"Water",
"Soda",
"Juice",
"Coffee",
"Tea",
"Alcohol",
"Sports Drinks",
],
[ITEM_TYPES.SNACK]: [
"Chips",
"Crackers",
"Nuts",
"Candy",
"Cookies",
"Protein Bars",
],
[ITEM_TYPES.HOUSEHOLD]: [
"Cleaning Supplies",
"Paper Products",
"Laundry",
"Kitchen Items",
"Storage",
],
[ITEM_TYPES.PERSONAL_CARE]: [
"Bath & Body",
"Hair Care",
"Oral Care",
"Skincare",
"Health",
],
[ITEM_TYPES.OTHER]: [
"Miscellaneous",
],
};
// Store zones - path-oriented physical shopping areas
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
const ZONES = {
ENTRANCE: "Entrance & Seasonal",
PRODUCE_SECTION: "Produce & Fresh Vegetables",
MEAT_SEAFOOD: "Meat & Seafood Counter",
DELI_PREPARED: "Deli & Prepared Foods",
BAKERY_SECTION: "Bakery",
DAIRY_SECTION: "Dairy & Refrigerated",
FROZEN_FOODS: "Frozen Foods",
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
BEVERAGES: "Beverages & Water",
SNACKS_CANDY: "Snacks & Candy",
HOUSEHOLD_CLEANING: "Household & Cleaning",
HEALTH_BEAUTY: "Health & Beauty",
CHECKOUT_AREA: "Checkout Area",
};
// Default zone mapping for each item type
// This determines where items are typically found in the store
const ITEM_TYPE_TO_ZONE = {
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
};
// Optimal walking flow through the store
// Represents a typical shopping path that minimizes backtracking
// Start with perimeter (fresh items), then move to center aisles, end at checkout
const ZONE_FLOW = [
ZONES.ENTRANCE,
ZONES.PRODUCE_SECTION,
ZONES.MEAT_SEAFOOD,
ZONES.DELI_PREPARED,
ZONES.BAKERY_SECTION,
ZONES.DAIRY_SECTION,
ZONES.FROZEN_FOODS,
ZONES.DRY_GOODS_CENTER,
ZONES.BEVERAGES,
ZONES.SNACKS_CANDY,
ZONES.HOUSEHOLD_CLEANING,
ZONES.HEALTH_BEAUTY,
ZONES.CHECKOUT_AREA,
];
// Validation helpers
const isValidItemType = (type) => Object.values(ITEM_TYPES).includes(type);
const isValidItemGroup = (type, group) => {
if (!isValidItemType(type)) return false;
return ITEM_GROUPS[type]?.includes(group) || false;
};
const isValidZone = (zone) => Object.values(ZONES).includes(zone);
const getSuggestedZone = (itemType) => {
return ITEM_TYPE_TO_ZONE[itemType] || null;
};
module.exports = {
ITEM_TYPES,
ITEM_GROUPS,
ZONES,
ITEM_TYPE_TO_ZONE,
ZONE_FLOW,
isValidItemType,
isValidItemGroup,
isValidZone,
getSuggestedZone,
};

View File

@ -1,4 +1,5 @@
const List = require("../models/list.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
exports.getList = async (req, res) => {
@ -15,17 +16,23 @@ exports.getItemByName = async (req, res) => {
exports.addItem = async (req, res) => {
const { itemName, quantity } = req.body;
const userId = req.user.id;
const id = await List.addOrUpdateItem(itemName, quantity);
// Get processed image if uploaded
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
await List.addHistoryRecord(id, quantity);
const id = await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType);
res.json({ message: "Item added/updated" });
await List.addHistoryRecord(id, quantity, userId);
res.json({ message: "Item added/updated", addedBy: userId });
};
exports.markBought = async (req, res) => {
await List.setBought(req.body.id);
const userId = req.user.id;
await List.setBought(req.body.id, userId);
res.json({ message: "Item marked bought" });
};
@ -34,4 +41,78 @@ exports.getSuggestions = async (req, res) => {
const { query } = req.query || "";
const suggestions = await List.getSuggestions(query);
res.json(suggestions);
};
exports.getRecentlyBought = async (req, res) => {
const items = await List.getRecentlyBoughtItems();
res.json(items);
};
exports.updateItemImage = async (req, res) => {
const { id, itemName, quantity } = req.body;
const userId = req.user.id;
// Get processed image
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
if (!imageBuffer) {
return res.status(400).json({ message: "No image provided" });
}
// Update the item with new image
await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType);
res.json({ message: "Image updated successfully" });
};
exports.getClassification = async (req, res) => {
const { id } = req.params;
const classification = await List.getClassification(id);
res.json(classification);
};
exports.updateItemWithClassification = async (req, res) => {
const { id } = req.params;
const { itemName, quantity, classification } = req.body;
const userId = req.user.id;
try {
// Update item name and quantity if changed
if (itemName !== undefined || quantity !== undefined) {
await List.updateItem(id, itemName, quantity);
}
// Update classification if provided
if (classification) {
const { item_type, item_group, zone } = classification;
// Validate classification data
if (item_type && !isValidItemType(item_type)) {
return res.status(400).json({ message: "Invalid item_type" });
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return res.status(400).json({ message: "Invalid item_group for selected item_type" });
}
if (zone && !isValidZone(zone)) {
return res.status(400).json({ message: "Invalid zone" });
}
// Upsert classification with confidence=1.0 and source='user'
await List.upsertClassification(id, {
item_type,
item_group,
zone: zone || null,
confidence: 1.0,
source: 'user'
});
}
res.json({ message: "Item updated successfully" });
} catch (error) {
console.error("Error updating item with classification:", error);
res.status(500).json({ message: "Failed to update item" });
}
};

View File

@ -0,0 +1,48 @@
const multer = require("multer");
const sharp = require("sharp");
// Configure multer for memory storage (we'll process before saving to DB)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (req, file, cb) => {
// Only accept images
if (file.mimetype.startsWith("image/")) {
cb(null, true);
} else {
cb(new Error("Only image files are allowed"), false);
}
},
});
// Middleware to process and compress images
const processImage = async (req, res, next) => {
if (!req.file) {
return next();
}
try {
// Compress and resize image to 800x800px, JPEG quality 85
const processedBuffer = await sharp(req.file.buffer)
.resize(800, 800, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 85 })
.toBuffer();
// Attach processed image to request
req.processedImage = {
buffer: processedBuffer,
mimeType: "image/jpeg",
};
next();
} catch (error) {
res.status(400).json({ message: "Error processing image: " + error.message });
}
};
module.exports = { upload, processImage };

View File

@ -0,0 +1,20 @@
# Database Migration: Add Image Support
Run these SQL commands on your PostgreSQL database:
```sql
-- Add image columns to grocery_list table
ALTER TABLE grocery_list
ADD COLUMN item_image BYTEA,
ADD COLUMN image_mime_type VARCHAR(50);
-- Optional: Add index for faster queries when filtering by items with images
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
```
## To Verify:
```sql
\d grocery_list
```
You should see the new columns `item_image` and `image_mime_type`.

View File

@ -0,0 +1,8 @@
-- Add modified_on column to grocery_list table
ALTER TABLE grocery_list
ADD COLUMN modified_on TIMESTAMP DEFAULT NOW();
-- Set modified_on to NOW() for existing records
UPDATE grocery_list
SET modified_on = NOW()
WHERE modified_on IS NULL;

View File

@ -0,0 +1,29 @@
-- Migration: Create item_classification table
-- This table stores classification data for items in the grocery_list table
-- Each row in grocery_list can have ONE corresponding classification row
CREATE TABLE IF NOT EXISTS item_classification (
id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL,
item_group VARCHAR(100) NOT NULL,
zone VARCHAR(100),
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index for faster lookups by type
CREATE INDEX IF NOT EXISTS idx_item_classification_type ON item_classification(item_type);
-- Index for zone-based queries
CREATE INDEX IF NOT EXISTS idx_item_classification_zone ON item_classification(zone);
-- Comments
COMMENT ON TABLE item_classification IS 'Stores classification metadata for grocery list items';
COMMENT ON COLUMN item_classification.id IS 'Foreign key to grocery_list.id (one-to-one relationship)';
COMMENT ON COLUMN item_classification.item_type IS 'High-level category (produce, meat, dairy, etc.)';
COMMENT ON COLUMN item_classification.item_group IS 'Subcategory within item_type (filtered by type)';
COMMENT ON COLUMN item_classification.zone IS 'Store zone/location (optional)';
COMMENT ON COLUMN item_classification.confidence IS 'Confidence score 0-1 (1.0 for user-provided, lower for ML-predicted)';
COMMENT ON COLUMN item_classification.source IS 'Source of classification: user, ml, or default';

View File

@ -3,7 +3,26 @@ const pool = require("../db/pool");
exports.getUnboughtItems = async () => {
const result = await pool.query(
"SELECT * FROM grocery_list WHERE bought = FALSE ORDER BY id ASC"
`SELECT
gl.id,
LOWER(gl.item_name) AS item_name,
gl.quantity,
gl.bought,
ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type,
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
gl.modified_on as last_added_on,
ic.item_type,
ic.item_group,
ic.zone
FROM grocery_list gl
LEFT JOIN users creator ON gl.added_by = creator.id
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
LEFT JOIN item_classification ic ON gl.id = ic.id
WHERE gl.bought = FALSE
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone
ORDER BY gl.id ASC`
);
return result.rows;
};
@ -18,43 +37,62 @@ exports.getItemByName = async (itemName) => {
};
exports.addOrUpdateItem = async (itemName, quantity) => {
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
const result = await pool.query(
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
[itemName]
);
if (result.rowCount > 0) {
await pool.query(
`UPDATE grocery_list
SET quantity = $1,
bought = FALSE
WHERE id = $2`,
[quantity, result.rows[0].id]
);
// Update existing item
if (imageBuffer && mimeType) {
await pool.query(
`UPDATE grocery_list
SET quantity = $1,
bought = FALSE,
item_image = $3,
image_mime_type = $4,
modified_on = NOW()
WHERE id = $2`,
[quantity, result.rows[0].id, imageBuffer, mimeType]
);
} else {
await pool.query(
`UPDATE grocery_list
SET quantity = $1,
bought = FALSE,
modified_on = NOW()
WHERE id = $2`,
[quantity, result.rows[0].id]
);
}
return result.rows[0].id;
} else {
// Insert new item
const insert = await pool.query(
`INSERT INTO grocery_list
(item_name, quantity)
VALUES ($1, $2) RETURNING id`,
[itemName, quantity]
(item_name, quantity, added_by, item_image, image_mime_type)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[itemName, quantity, userId, imageBuffer, mimeType]
);
return insert.rows[0].id;
}
};
exports.setBought = async (id) => {
await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]);
exports.setBought = async (id, userId) => {
await pool.query(
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[id]
);
};
exports.addHistoryRecord = async (itemId, quantity) => {
exports.addHistoryRecord = async (itemId, quantity, userId) => {
await pool.query(
`INSERT INTO grocery_history (list_item_id, quantity, added_on)
VALUES ($1, $2, NOW())`,
[itemId, quantity]
`INSERT INTO grocery_history (list_item_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, NOW())`,
[itemId, quantity, userId]
);
};
@ -71,3 +109,67 @@ exports.getSuggestions = async (query) => {
return result.rows;
};
exports.getRecentlyBoughtItems = async () => {
const result = await pool.query(
`SELECT
gl.id,
LOWER(gl.item_name) AS item_name,
gl.quantity,
gl.bought,
ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type,
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
gl.modified_on as last_added_on
FROM grocery_list gl
LEFT JOIN users creator ON gl.added_by = creator.id
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
WHERE gl.bought = TRUE
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on
ORDER BY gl.modified_on DESC`
);
return result.rows;
};
// Classification methods
exports.getClassification = async (itemId) => {
const result = await pool.query(
`SELECT item_type, item_group, zone, confidence, source
FROM item_classification
WHERE id = $1`,
[itemId]
);
return result.rows[0] || null;
};
exports.upsertClassification = async (itemId, classification) => {
const { item_type, item_group, zone, confidence, source } = classification;
const result = await pool.query(
`INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id)
DO UPDATE SET
item_type = EXCLUDED.item_type,
item_group = EXCLUDED.item_group,
zone = EXCLUDED.zone,
confidence = EXCLUDED.confidence,
source = EXCLUDED.source
RETURNING *`,
[itemId, item_type, item_group, zone, confidence, source]
);
return result.rows[0];
};
exports.updateItem = async (id, itemName, quantity) => {
const result = await pool.query(
`UPDATE grocery_list
SET item_name = $2, quantity = $3, modified_on = NOW()
WHERE id = $1
RETURNING *`,
[id, itemName, quantity]
);
return result.rows[0];
};

16
backend/nodemon.json Normal file
View File

@ -0,0 +1,16 @@
{
"watch": [
"**/*.js",
".env"
],
"ext": "js,json",
"ignore": [
"node_modules/**",
"dist/**"
],
"legacyWatch": true,
"verbose": true,
"execMap": {
"js": "node"
}
}

View File

@ -10,7 +10,9 @@
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.0"
"multer": "^2.0.2",
"pg": "^8.16.0",
"sharp": "^0.34.5"
},
"devDependencies": {
"cpx": "^1.5.0",
@ -19,6 +21,15 @@
"rimraf": "^6.0.1"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
@ -419,6 +430,446 @@
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -482,6 +933,11 @@
"normalize-path": "^2.0.0"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/arr-diff": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz",
@ -684,6 +1140,22 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -862,6 +1334,33 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@ -1023,6 +1522,14 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@ -2138,7 +2645,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -2181,7 +2687,6 @@
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"dependencies": {
"minimist": "^1.2.6"
},
@ -2194,6 +2699,62 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nan": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
@ -3503,6 +4064,49 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -3842,11 +4446,18 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@ -3854,8 +4465,7 @@
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/string-width": {
"version": "5.1.2",
@ -4055,6 +4665,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"optional": true
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@ -4068,6 +4684,11 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -4173,8 +4794,7 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vary": {
"version": "1.1.2",

View File

@ -5,7 +5,9 @@
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.0"
"multer": "^2.0.2",
"pg": "^8.16.0",
"sharp": "^0.34.5"
},
"devDependencies": {
"cpx": "^1.5.0",

View File

@ -4,16 +4,22 @@ const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const { ROLES } = require("../models/user.model");
const User = require("../models/user.model");
const { upload, processImage } = require("../middleware/image");
router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList);
router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName);
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
router.get("/recently-bought", auth, requireRole(...Object.values(ROLES)), controller.getRecentlyBought);
router.get("/item/:id/classification", auth, requireRole(...Object.values(ROLES)), controller.getClassification);
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem);
router.post("/update-image", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.updateItemImage);
router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought);
router.put("/item/:id", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.updateItemWithClassification);
module.exports = router;

8
dev-rebuild.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# Quick script to rebuild Docker Compose dev environment
echo "Stopping containers and removing volumes..."
docker-compose -f docker-compose.dev.yml down -v
echo "Rebuilding and starting containers..."
docker-compose -f docker-compose.dev.yml up --build

View File

@ -7,7 +7,7 @@ services:
- NODE_ENV=development
volumes:
- ./frontend:/app
- /app/node_modules
- frontend_node_modules:/app/node_modules
ports:
- "3000:5173"
depends_on:
@ -21,9 +21,13 @@ services:
command: npm run dev
volumes:
- ./backend:/app
- /app/node_modules
- backend_node_modules:/app/node_modules
ports:
- "5000:5000"
env_file:
- ./backend/.env
restart: always
volumes:
frontend_node_modules:
backend_node_modules:

View File

@ -0,0 +1,238 @@
# Frontend Component Organization
This document describes the organized structure of the frontend codebase, implemented to improve maintainability as the application grows.
## Directory Structure
```
frontend/src/
├── api/ # API client functions
│ ├── auth.js # Authentication endpoints
│ ├── axios.js # Axios instance with interceptors
│ ├── list.js # Grocery list endpoints
│ └── users.js # User management endpoints
├── components/ # React components (organized by function)
│ ├── common/ # Reusable UI components
│ │ ├── ErrorMessage.jsx
│ │ ├── FloatingActionButton.jsx
│ │ ├── FormInput.jsx
│ │ ├── SortDropdown.jsx
│ │ ├── UserRoleCard.jsx
│ │ └── index.js # Barrel exports
│ │
│ ├── modals/ # All modal/dialog components
│ │ ├── AddImageModal.jsx
│ │ ├── AddItemWithDetailsModal.jsx
│ │ ├── ConfirmBuyModal.jsx
│ │ ├── EditItemModal.jsx
│ │ ├── ImageModal.jsx
│ │ ├── ImageUploadModal.jsx
│ │ ├── ItemClassificationModal.jsx
│ │ ├── SimilarItemModal.jsx
│ │ └── index.js # Barrel exports
│ │
│ ├── forms/ # Form components and input sections
│ │ ├── AddItemForm.jsx
│ │ ├── ClassificationSection.jsx
│ │ ├── ImageUploadSection.jsx
│ │ └── index.js # Barrel exports
│ │
│ ├── items/ # Item display and list components
│ │ ├── GroceryItem.tsx
│ │ ├── GroceryListItem.jsx
│ │ ├── SuggestionList.tsx
│ │ └── index.js # Barrel exports
│ │
│ └── layout/ # Layout and navigation components
│ ├── AppLayout.jsx
│ ├── Navbar.jsx
│ └── index.js # Barrel exports
├── constants/ # Application constants
│ ├── classifications.js # Item types, groups, zones
│ └── roles.js # User roles (viewer, editor, admin)
├── context/ # React context providers
│ └── AuthContext.jsx # Authentication context
├── pages/ # Top-level page components
│ ├── AdminPanel.jsx # User management dashboard
│ ├── GroceryList.jsx # Main grocery list page
│ ├── Login.jsx # Login page
│ └── Register.jsx # Registration page
├── styles/ # CSS files (organized by type)
│ ├── pages/ # Page-specific styles
│ │ ├── GroceryList.css
│ │ ├── Login.css
│ │ └── Register.css
│ │
│ ├── components/ # Component-specific styles
│ │ ├── AddItemWithDetailsModal.css
│ │ ├── ClassificationSection.css
│ │ ├── EditItemModal.css
│ │ ├── ImageUploadSection.css
│ │ └── Navbar.css
│ │
│ ├── theme.css # **GLOBAL THEME VARIABLES** (colors, spacing, typography)
│ ├── THEME_USAGE_EXAMPLES.css # Examples of using theme variables
│ ├── App.css # Global app styles
│ └── index.css # Root styles (uses theme variables)
├── utils/ # Utility functions
│ ├── PrivateRoute.jsx # Authentication guard
│ ├── RoleGuard.jsx # Role-based access guard
│ └── stringSimilarity.js # String matching utilities
├── App.jsx # Root app component
├── main.tsx # Application entry point
├── config.ts # Configuration (API URL)
└── types.ts # TypeScript type definitions
```
## Import Patterns
### Using Barrel Exports (Recommended)
Barrel exports (`index.js` files) allow cleaner imports from component groups:
```javascript
// ✅ Clean barrel import
import { FloatingActionButton, SortDropdown } from '../components/common';
import { EditItemModal, SimilarItemModal } from '../components/modals';
import { AddItemForm, ClassificationSection } from '../components/forms';
```
### Direct Imports (Alternative)
You can also import components directly when needed:
```javascript
// Also valid
import FloatingActionButton from '../components/common/FloatingActionButton';
import EditItemModal from '../components/modals/EditItemModal';
```
## Component Categories
### `common/` - Reusable UI Components
- **Purpose**: Generic, reusable components used across multiple pages
- **Examples**: Buttons, dropdowns, form inputs, error messages
- **Characteristics**: Highly reusable, minimal business logic
### `modals/` - Dialog Components
- **Purpose**: All modal/dialog/popup components
- **Examples**: Confirmation dialogs, edit forms, image viewers
- **Characteristics**: Overlay UI, typically used for focused interactions
### `forms/` - Form Sections
- **Purpose**: Form-related components and input sections
- **Examples**: Multi-step forms, reusable form sections
- **Characteristics**: Handle user input, validation, form state
### `items/` - Item Display Components
- **Purpose**: Components specific to displaying grocery items
- **Examples**: Item cards, item lists, suggestion lists
- **Characteristics**: Domain-specific (grocery items)
### `layout/` - Layout Components
- **Purpose**: Application structure and navigation
- **Examples**: Navigation bars, page layouts, wrappers
- **Characteristics**: Define page structure, persistent UI elements
## Style Organization
### `styles/pages/`
Page-specific styles that apply to entire page components.
### `styles/components/`
Component-specific styles for individual reusable components.
## Benefits of This Structure
1. **Scalability**: Easy to add new components without cluttering directories
2. **Discoverability**: Intuitive naming makes components easy to find
3. **Maintainability**: Related code is grouped together
4. **Separation of Concerns**: Clear boundaries between different types of components
5. **Import Clarity**: Barrel exports reduce import statement complexity
6. **Team Collaboration**: Clear conventions for where new code should go
## Adding New Components
When adding a new component, ask:
1. **Is it reusable across pages?**`common/`
2. **Is it a modal/dialog?**`modals/`
3. **Is it form-related?**`forms/`
4. **Is it specific to grocery items?**`items/`
5. **Does it define page structure?**`layout/`
6. **Is it a full page?**`pages/`
Then:
1. Create the component in the appropriate subdirectory
2. Add the component to the subdirectory's `index.js` barrel export
3. Create corresponding CSS file in `styles/pages/` or `styles/components/`
## Migration Notes
This structure was implemented on January 2, 2026 to organize 20+ components and 10+ CSS files that were previously in flat directories. All import paths have been updated to reflect the new structure.
## Theming System
The application uses a centralized theming system via CSS custom properties (variables) defined in `styles/theme.css`.
### Theme Variables
All design tokens are defined in `theme.css` including:
- **Colors**: Primary, secondary, semantic (success, danger, warning), neutrals
- **Spacing**: Consistent spacing scale (xs, sm, md, lg, xl, 2xl, 3xl)
- **Typography**: Font families, sizes, weights, line heights
- **Borders**: Widths and radius values
- **Shadows**: Box shadow presets
- **Transitions**: Timing functions
- **Z-index**: Layering system for modals, dropdowns, etc.
### Using Theme Variables
```css
/* Instead of hardcoded values */
.button-old {
background: #007bff;
padding: 0.6em 1.2em;
border-radius: 4px;
}
/* Use theme variables */
.button-new {
background: var(--color-primary);
padding: var(--button-padding-y) var(--button-padding-x);
border-radius: var(--button-border-radius);
}
```
### Benefits
1. **Consistency**: All components use the same design tokens
2. **Maintainability**: Change once in `theme.css`, updates everywhere
3. **Theme Switching**: Easy to implement dark mode (already scaffolded)
4. **Scalability**: Add new tokens without touching component styles
5. **Documentation**: Variable names are self-documenting
### Utility Classes
The theme file includes utility classes for common patterns:
```html
<!-- Spacing -->
<div class="mt-3 mb-4 p-2">Content</div>
<!-- Text styling -->
<span class="text-primary font-weight-bold">Important</span>
<!-- Flexbox -->
<div class="d-flex justify-between align-center gap-2">Items</div>
```
See `styles/THEME_USAGE_EXAMPLES.css` for complete examples of refactoring existing CSS to use theme variables.

View File

@ -7,7 +7,7 @@ import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx";
import Register from "./pages/Register.jsx";
import AppLayout from "./components/AppLayout.jsx";
import AppLayout from "./components/layout/AppLayout.jsx";
import PrivateRoute from "./utils/PrivateRoute.jsx";
import RoleGuard from "./utils/RoleGuard.jsx";

View File

@ -2,6 +2,45 @@ import api from "./axios";
export const getList = () => api.get("/list");
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
export const addItem = (itemName, quantity) => api.post("/list/add", { itemName, quantity });
export const addItem = (itemName, quantity, imageFile = null) => {
const formData = new FormData();
formData.append("itemName", itemName);
formData.append("quantity", quantity);
if (imageFile) {
formData.append("image", imageFile);
}
return api.post("/list/add", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
return api.put(`/list/item/${id}`, {
itemName,
quantity,
classification
});
};
export const markBought = (id) => api.post("/list/mark-bought", { id });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getRecentlyBought = () => api.get("/list/recently-bought");
export const updateItemImage = (id, itemName, quantity, imageFile) => {
const formData = new FormData();
formData.append("id", id);
formData.append("itemName", itemName);
formData.append("quantity", quantity);
formData.append("image", imageFile);
return api.post("/list/update-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};

View File

@ -0,0 +1,7 @@
export default function ErrorMessage({ message, type = "error" }) {
if (!message) return null;
const className = type === "success" ? "success-message" : "error-message";
return <p className={className}>{message}</p>;
}

View File

@ -0,0 +1,7 @@
export default function FloatingActionButton({ isOpen, onClick }) {
return (
<button className="glist-fab" onClick={onClick}>
{isOpen ? "" : "+"}
</button>
);
}

View File

@ -0,0 +1,21 @@
export default function FormInput({
type = "text",
placeholder,
value,
onChange,
onKeyUp,
required = false,
className = "",
}) {
return (
<input
type={type}
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyUp={onKeyUp}
required={required}
className={className}
/>
);
}

View File

@ -0,0 +1,11 @@
export default function SortDropdown({ value, onChange }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className="glist-sort">
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
<option value="zone">By Zone</option>
</select>
);
}

View File

@ -0,0 +1,21 @@
import { ROLES } from "../../constants/roles";
export default function UserRoleCard({ user, onRoleChange }) {
return (
<div className="user-card">
<div className="user-info">
<strong>{user.name}</strong>
<span className="user-username">@{user.username}</span>
</div>
<select
onChange={(e) => onRoleChange(user.id, e.target.value)}
value={user.role}
className="role-select"
>
<option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option>
<option value={ROLES.ADMIN}>Admin</option>
</select>
</div>
);
}

View File

@ -0,0 +1,7 @@
// Barrel export for common components
export { default as ErrorMessage } from './ErrorMessage.jsx';
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
export { default as FormInput } from './FormInput.jsx';
export { default as SortDropdown } from './SortDropdown.jsx';
export { default as UserRoleCard } from './UserRoleCard.jsx';

View File

@ -0,0 +1,99 @@
import { useState } from "react";
import "../../styles/components/AddItemForm.css";
import SuggestionList from "../items/SuggestionList";
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) {
const [itemName, setItemName] = useState("");
const [quantity, setQuantity] = useState(1);
const [showSuggestions, setShowSuggestions] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
if (!itemName.trim()) return;
onAdd(itemName, quantity);
setItemName("");
setQuantity(1);
};
const handleInputChange = (text) => {
setItemName(text);
onSuggest(text);
};
const handleSuggestionSelect = (suggestion) => {
setItemName(suggestion);
setShowSuggestions(false);
onSuggest(suggestion); // Trigger button text update
};
const incrementQuantity = () => {
setQuantity(prev => prev + 1);
};
const decrementQuantity = () => {
setQuantity(prev => Math.max(1, prev - 1));
};
const isDisabled = !itemName.trim();
return (
<div className="add-item-form-container">
<form onSubmit={handleSubmit} className="add-item-form">
<div className="add-item-form-field">
<input
type="text"
className="add-item-form-input"
placeholder="Enter item name"
value={itemName}
onChange={(e) => handleInputChange(e.target.value)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onClick={() => setShowSuggestions(true)}
/>
{showSuggestions && suggestions.length > 0 && (
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
)}
</div>
<div className="add-item-form-actions">
<div className="add-item-form-quantity-control">
<button
type="button"
className="quantity-btn quantity-btn-minus"
onClick={decrementQuantity}
disabled={quantity <= 1}
>
</button>
<input
type="number"
min="1"
className="add-item-form-quantity-input"
value={quantity}
readOnly
/>
<button
type="button"
className="quantity-btn quantity-btn-plus"
onClick={incrementQuantity}
>
+
</button>
</div>
<button
type="submit"
className={`add-item-form-submit ${isDisabled ? 'disabled' : ''}`}
disabled={isDisabled}
>
{buttonText}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
import "../../styles/components/ClassificationSection.css";
/**
* Reusable classification component with cascading type/group/zone selects
* @param {Object} props
* @param {string} props.itemType - Selected item type
* @param {string} props.itemGroup - Selected item group
* @param {string} props.zone - Selected zone
* @param {Function} props.onItemTypeChange - Callback for type change (newType)
* @param {Function} props.onItemGroupChange - Callback for group change (newGroup)
* @param {Function} props.onZoneChange - Callback for zone change (newZone)
* @param {string} props.title - Section title (optional)
* @param {string} props.fieldClass - CSS class for field containers (optional)
* @param {string} props.selectClass - CSS class for select elements (optional)
*/
export default function ClassificationSection({
itemType,
itemGroup,
zone,
onItemTypeChange,
onItemGroupChange,
onZoneChange,
title = "Item Classification (Optional)",
fieldClass = "classification-field",
selectClass = "classification-select"
}) {
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
const handleTypeChange = (e) => {
const newType = e.target.value;
onItemTypeChange(newType);
// Parent should reset group when type changes
};
return (
<div className="classification-section">
<h3 className="classification-title">{title}</h3>
<div className={fieldClass}>
<label>Item Type</label>
<select
value={itemType}
onChange={handleTypeChange}
className={selectClass}
>
<option value="">-- Select Type --</option>
{Object.values(ITEM_TYPES).map((type) => (
<option key={type} value={type}>
{getItemTypeLabel(type)}
</option>
))}
</select>
</div>
{itemType && (
<div className={fieldClass}>
<label>Item Group</label>
<select
value={itemGroup}
onChange={(e) => onItemGroupChange(e.target.value)}
className={selectClass}
>
<option value="">-- Select Group --</option>
{availableGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</div>
)}
<div className={fieldClass}>
<label>Store Zone</label>
<select
value={zone}
onChange={(e) => onZoneChange(e.target.value)}
className={selectClass}
>
<option value="">-- Select Zone --</option>
{getZoneValues().map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { useRef } from "react";
import "../../styles/components/ImageUploadSection.css";
/**
* Reusable image upload component with camera and gallery options
* @param {Object} props
* @param {string} props.imagePreview - Base64 preview URL or null
* @param {Function} props.onImageChange - Callback when image is selected (file)
* @param {Function} props.onImageRemove - Callback to remove image
* @param {string} props.title - Section title (optional)
*/
export default function ImageUploadSection({
imagePreview,
onImageChange,
onImageRemove,
title = "Item Image (Optional)"
}) {
const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
onImageChange(file);
}
};
const handleCameraClick = () => {
cameraInputRef.current?.click();
};
const handleGalleryClick = () => {
galleryInputRef.current?.click();
};
return (
<div className="image-upload-section">
<h3 className="image-upload-title">{title}</h3>
<div className="image-upload-content">
{!imagePreview ? (
<div className="image-upload-options">
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button">
📷 Use Camera
</button>
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button">
🖼 Choose from Gallery
</button>
</div>
) : (
<div className="image-upload-preview">
<img src={imagePreview} alt="Preview" />
<button type="button" onClick={onImageRemove} className="image-upload-remove">
× Remove
</button>
</div>
)}
</div>
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleFileChange}
style={{ display: "none" }}
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
// Barrel export for form components
export { default as AddItemForm } from './AddItemForm.jsx';
export { default as ClassificationSection } from './ClassificationSection.jsx';
export { default as ImageUploadSection } from './ImageUploadSection.jsx';

View File

@ -1,23 +1,23 @@
import type { GroceryItemType } from "../types";
interface Props {
item: GroceryItemType;
onClick: (id: number) => void;
}
export default function GroceryItem({ item, onClick }: Props) {
return (
<li
onClick={() => onClick(item.id)}
style={{
padding: "0.5em",
background: "#e9ecef",
marginBottom: "0.5em",
borderRadius: "4px",
cursor: "pointer",
}}
>
{item.item_name} ({item.quantity})
</li>
);
}
import type { GroceryItemType } from "../types";
interface Props {
item: GroceryItemType;
onClick: (id: number) => void;
}
export default function GroceryItem({ item, onClick }: Props) {
return (
<li
onClick={() => onClick(item.id)}
style={{
padding: "0.5em",
background: "#e9ecef",
marginBottom: "0.5em",
borderRadius: "4px",
cursor: "pointer",
}}
>
{item.item_name} ({item.quantity})
</li>
);
}

View File

@ -0,0 +1,178 @@
import { useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
import ImageModal from "../modals/ImageModal";
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
const [showModal, setShowModal] = useState(false);
const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const longPressTimer = useRef(null);
const pressStartPos = useRef({ x: 0, y: 0 });
const handleTouchStart = (e) => {
const touch = e.touches[0];
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
longPressTimer.current = setTimeout(() => {
if (onLongPress) {
onLongPress(item);
}
}, 500); // 500ms for long press
};
const handleTouchMove = (e) => {
// Cancel long press if finger moves too much
const touch = e.touches[0];
const moveDistance = Math.sqrt(
Math.pow(touch.clientX - pressStartPos.current.x, 2) +
Math.pow(touch.clientY - pressStartPos.current.y, 2)
);
if (moveDistance > 10) {
clearTimeout(longPressTimer.current);
}
};
const handleTouchEnd = () => {
clearTimeout(longPressTimer.current);
};
const handleMouseDown = () => {
longPressTimer.current = setTimeout(() => {
if (onLongPress) {
onLongPress(item);
}
}, 500);
};
const handleMouseUp = () => {
clearTimeout(longPressTimer.current);
};
const handleMouseLeave = () => {
clearTimeout(longPressTimer.current);
};
const handleItemClick = () => {
if (onClick) {
setShowConfirmBuyModal(true);
}
};
const handleConfirmBuy = (quantity) => {
if (onClick) {
onClick(quantity);
}
setShowConfirmBuyModal(false);
};
const handleCancelBuy = () => {
setShowConfirmBuyModal(false);
};
const handleImageClick = (e) => {
e.stopPropagation(); // Prevent triggering the bought action
if (item.item_image) {
setShowModal(true);
} else {
setShowAddImageModal(true);
}
};
const handleAddImage = async (imageFile) => {
if (onImageAdded) {
await onImageAdded(item.id, item.item_name, item.quantity, imageFile);
}
setShowAddImageModal(false);
};
const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}`
: null;
const getTimeAgo = (dateString) => {
if (!dateString) return null;
const addedDate = new Date(dateString);
const now = new Date();
const diffMs = now - addedDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 7) {
return `${diffDays}d ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks}w ago`;
} else {
const months = Math.floor(diffDays / 30);
return `${months}m ago`;
}
};
return (
<>
<li
className="glist-li"
onClick={handleItemClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div className="glist-item-layout">
<div
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
onClick={handleImageClick}
style={{ cursor: "pointer" }}
>
{item.item_image ? (
<img src={imageUrl} alt={item.item_name} />
) : (
<span>📦</span>
)}
<span className="glist-item-quantity">x{item.quantity}</span>
</div>
<div className="glist-item-content">
<div className="glist-item-header">
<span className="glist-item-name">{item.item_name}</span>
</div>
{item.added_by_users && item.added_by_users.length > 0 && (
<div className="glist-item-users">
{item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `}
{item.added_by_users.join(" • ")}
</div>
)}
</div>
</div>
</li>
{showModal && (
<ImageModal
imageUrl={imageUrl}
itemName={item.item_name}
onClose={() => setShowModal(false)}
/>
)}
{showAddImageModal && (
<AddImageModal
itemName={item.item_name}
onClose={() => setShowAddImageModal(false)}
onAddImage={handleAddImage}
/>
)}
{showConfirmBuyModal && (
<ConfirmBuyModal
item={item}
onConfirm={handleConfirmBuy}
onCancel={handleCancelBuy}
/>
)}
</>
);
}

View File

@ -1,40 +1,39 @@
interface Props {
suggestions: string[];
onSelect: (value: string) => void;
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null;
return (
<ul
style={{
background: "#fff",
border: "1px solid #ccc",
maxHeight: "150px",
overflowY: "auto",
position: "absolute",
zIndex: 1000,
left: "1em",
right: "1em",
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{suggestions.map((s) => (
<li
key={s}
onClick={() => onSelect(s)}
style={{
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{s}
</li>
))}
</ul>
);
}
import React from "react";
interface Props {
suggestions: string[];
onSelect: (value: string) => void;
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null;
return (
<ul
className="suggestion-list"
style={{
background: "#fff",
border: "1px solid #ccc",
maxHeight: "150px",
overflowY: "auto",
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{suggestions.map((s) => (
<li
key={s}
onClick={() => onSelect(s)}
style={{
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{s}
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,5 @@
// Barrel export for item-related components
export { default as GroceryItem } from './GroceryItem.tsx';
export { default as GroceryListItem } from './GroceryListItem.jsx';
export { default as SuggestionList } from './SuggestionList.tsx';

View File

@ -1,11 +1,11 @@
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
export default function AppLayout() {
return (
<div>
<Navbar />
<Outlet />
</div>
);
}
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
export default function AppLayout() {
return (
<div>
<Navbar />
<Outlet />
</div>
);
}

View File

@ -1,30 +1,30 @@
import "../styles/Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../context/AuthContext";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
return (
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}
</div>
<div className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span>
</div>
</div>
<button className="navbar-logout" onClick={logout}>
Logout
</button>
</nav>
);
import "../../styles/components/Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../../context/AuthContext";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
return (
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}
</div>
<div className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span>
</div>
</div>
<button className="navbar-logout" onClick={logout}>
Logout
</button>
</nav>
);
}

View File

@ -0,0 +1,4 @@
// Barrel export for layout components
export { default as AppLayout } from './AppLayout.jsx';
export { default as Navbar } from './Navbar.jsx';

View File

@ -0,0 +1,99 @@
import { useRef, useState } from "react";
import "../../styles/AddImageModal.css";
export default function AddImageModal({ itemName, onClose, onAddImage }) {
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null);
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
setSelectedImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const handleCameraClick = () => {
cameraInputRef.current?.click();
};
const handleGalleryClick = () => {
galleryInputRef.current?.click();
};
const handleConfirm = () => {
if (selectedImage) {
onAddImage(selectedImage);
}
};
const removeImage = () => {
setSelectedImage(null);
setImagePreview(null);
};
return (
<div className="add-image-modal-overlay" onClick={onClose}>
<div className="add-image-modal" onClick={(e) => e.stopPropagation()}>
<h2>Add Image</h2>
<p className="add-image-subtitle">
There's no image for <strong>"{itemName}"</strong> yet. Add a new image?
</p>
{!imagePreview ? (
<div className="add-image-options">
<button onClick={handleCameraClick} className="add-image-option-btn camera">
📷 Use Camera
</button>
<button onClick={handleGalleryClick} className="add-image-option-btn gallery">
🖼 Choose from Gallery
</button>
</div>
) : (
<div className="add-image-preview-container">
<div className="add-image-preview">
<img src={imagePreview} alt="Preview" />
<button type="button" onClick={removeImage} className="add-image-remove">
×
</button>
</div>
</div>
)}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<div className="add-image-actions">
<button onClick={onClose} className="add-image-cancel">
Cancel
</button>
{imagePreview && (
<button onClick={handleConfirm} className="add-image-confirm">
Add Image
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
import { useState } from "react";
import "../../styles/components/AddItemWithDetailsModal.css";
import ClassificationSection from "../forms/ClassificationSection";
import ImageUploadSection from "../forms/ImageUploadSection";
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState("");
const handleImageChange = (file) => {
setSelectedImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
};
const handleImageRemove = () => {
setSelectedImage(null);
setImagePreview(null);
};
const handleItemTypeChange = (newType) => {
setItemType(newType);
setItemGroup(""); // Reset group when type changes
};
const handleConfirm = () => {
// Validate classification if provided
if (itemType && !itemGroup) {
alert("Please select an item group");
return;
}
const classification = itemType ? {
item_type: itemType,
item_group: itemGroup,
zone: zone || null
} : null;
onConfirm(selectedImage, classification);
};
const handleSkip = () => {
onSkip();
};
return (
<div className="add-item-details-overlay" onClick={onCancel}>
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
{/* Image Section */}
<div className="add-item-details-section">
<ImageUploadSection
imagePreview={imagePreview}
onImageChange={handleImageChange}
onImageRemove={handleImageRemove}
/>
</div>
{/* Classification Section */}
<div className="add-item-details-section">
<ClassificationSection
itemType={itemType}
itemGroup={itemGroup}
zone={zone}
onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup}
onZoneChange={setZone}
fieldClass="add-item-details-field"
selectClass="add-item-details-select"
/>
</div>
{/* Actions */}
<div className="add-item-details-actions">
<button onClick={onCancel} className="add-item-details-btn cancel">
Cancel
</button>
<button onClick={handleSkip} className="add-item-details-btn skip">
Skip All
</button>
<button onClick={handleConfirm} className="add-item-details-btn confirm">
Add Item
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useState } from "react";
import "../../styles/ConfirmBuyModal.css";
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
const [quantity, setQuantity] = useState(item.quantity);
const maxQuantity = item.quantity;
const handleIncrement = () => {
if (quantity < maxQuantity) {
setQuantity(prev => prev + 1);
}
};
const handleDecrement = () => {
if (quantity > 1) {
setQuantity(prev => prev - 1);
}
};
const handleConfirm = () => {
onConfirm(quantity);
};
return (
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
<h2>Mark as Bought</h2>
<p className="confirm-buy-item-name">"{item.item_name}"</p>
<div className="confirm-buy-quantity-section">
<p className="confirm-buy-label">Quantity to buy:</p>
<div className="confirm-buy-counter">
<button
onClick={handleDecrement}
className="confirm-buy-counter-btn"
disabled={quantity <= 1}
>
</button>
<input
type="number"
value={quantity}
readOnly
className="confirm-buy-counter-display"
/>
<button
onClick={handleIncrement}
className="confirm-buy-counter-btn"
disabled={quantity >= maxQuantity}
>
+
</button>
</div>
</div>
<div className="confirm-buy-actions">
<button onClick={onCancel} className="confirm-buy-cancel">
Cancel
</button>
<button onClick={handleConfirm} className="confirm-buy-confirm">
Mark as Bought
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import "../../styles/components/EditItemModal.css";
import ClassificationSection from "../forms/ClassificationSection";
export default function EditItemModal({ item, onSave, onCancel }) {
const [itemName, setItemName] = useState(item.item_name || "");
const [quantity, setQuantity] = useState(item.quantity || 1);
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState("");
const [loading, setLoading] = useState(false);
// Load existing classification
useEffect(() => {
if (item.classification) {
setItemType(item.classification.item_type || "");
setItemGroup(item.classification.item_group || "");
setZone(item.classification.zone || "");
}
}, [item]);
const handleItemTypeChange = (newType) => {
setItemType(newType);
setItemGroup(""); // Reset group when type changes
};
const handleSave = async () => {
if (!itemName.trim()) {
alert("Item name is required");
return;
}
if (quantity < 1) {
alert("Quantity must be at least 1");
return;
}
// If classification fields are filled, validate them
if (itemType && !itemGroup) {
alert("Please select an item group");
return;
}
setLoading(true);
try {
const classification = itemType ? {
item_type: itemType,
item_group: itemGroup,
zone: zone || null
} : null;
await onSave(item.id, itemName, quantity, classification);
} catch (error) {
console.error("Failed to save:", error);
alert("Failed to save changes");
} finally {
setLoading(false);
}
};
return (
<div className="edit-modal-overlay" onClick={onCancel}>
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
<h2 className="edit-modal-title">Edit Item</h2>
<div className="edit-modal-field">
<label>Item Name</label>
<input
type="text"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
className="edit-modal-input"
/>
</div>
<div className="edit-modal-field">
<label>Quantity</label>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
className="edit-modal-input"
/>
</div>
<div className="edit-modal-divider" />
<ClassificationSection
itemType={itemType}
itemGroup={itemGroup}
zone={zone}
onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup}
onZoneChange={setZone}
fieldClass="edit-modal-field"
selectClass="edit-modal-select"
/>
<div className="edit-modal-actions">
<button
className="edit-modal-btn edit-modal-btn-cancel"
onClick={onCancel}
disabled={loading}
>
Cancel
</button>
<button
className="edit-modal-btn edit-modal-btn-save"
onClick={handleSave}
disabled={loading}
>
{loading ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { useEffect } from "react";
import "../../styles/ImageModal.css";
export default function ImageModal({ imageUrl, itemName, onClose }) {
useEffect(() => {
// Close modal on Escape key
const handleEscape = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose]);
if (!imageUrl) return null;
return (
<div className="image-modal-overlay" onClick={onClose}>
<div className="image-modal-content" onClick={onClose}>
<img src={imageUrl} alt={itemName} className="image-modal-img" />
<p className="image-modal-caption">{itemName}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
import { useRef, useState } from "react";
import "../../styles/ImageUploadModal.css";
export default function ImageUploadModal({ itemName, onConfirm, onSkip, onCancel }) {
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
setSelectedImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const removeImage = () => {
setSelectedImage(null);
setImagePreview(null);
};
const handleConfirm = () => {
onConfirm(selectedImage);
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
};
const handleCameraClick = () => {
cameraInputRef.current?.click();
};
const handleGalleryClick = () => {
galleryInputRef.current?.click();
};
return (
<div className="image-upload-modal-overlay" onClick={handleCancel}>
<div className="image-upload-modal" onClick={(e) => e.stopPropagation()}>
<h2>Add Image for "{itemName}"</h2>
<p className="image-upload-subtitle">This is a new item. Would you like to add a verification image?</p>
<div className="image-upload-content">
{!imagePreview ? (
<div className="image-upload-options">
<button onClick={handleCameraClick} className="image-upload-option-btn camera">
📷 Use Camera
</button>
<button onClick={handleGalleryClick} className="image-upload-option-btn gallery">
🖼 Choose from Gallery
</button>
</div>
) : (
<div className="modal-image-preview">
<img src={imagePreview} alt="Preview" />
<button type="button" onClick={removeImage} className="modal-remove-image">
×
</button>
</div>
)}
</div>
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleImageChange}
style={{ display: "none" }}
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
onChange={handleImageChange}
style={{ display: "none" }}
/>
<div className="image-upload-actions">
<button onClick={handleCancel} className="image-upload-cancel">
Cancel
</button>
<button onClick={onSkip} className="image-upload-skip">
Skip
</button>
<button onClick={handleConfirm} className="image-upload-confirm" disabled={!selectedImage}>
{selectedImage ? "Add with Image" : "Select an Image"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { useState } from "react";
import "../../styles/ItemClassificationModal.css";
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState("");
const handleItemTypeChange = (e) => {
const newType = e.target.value;
setItemType(newType);
// Reset item group when type changes
setItemGroup("");
};
const handleConfirm = () => {
if (!itemType) {
alert("Please select an item type");
return;
}
if (!itemGroup) {
alert("Please select an item group");
return;
}
onConfirm({
item_type: itemType,
item_group: itemGroup,
zone: zone || null
});
};
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
return (
<div className="classification-modal-overlay">
<div className="classification-modal-content">
<h2 className="classification-modal-title">Classify Item</h2>
<p className="classification-modal-subtitle">Help organize "{itemName}" in your list</p>
<div className="classification-modal-field">
<label>Item Type <span className="required">*</span></label>
<select
value={itemType}
onChange={handleItemTypeChange}
className="classification-modal-select"
>
<option value="">-- Select Type --</option>
{Object.values(ITEM_TYPES).map((type) => (
<option key={type} value={type}>
{getItemTypeLabel(type)}
</option>
))}
</select>
</div>
{itemType && (
<div className="classification-modal-field">
<label>Item Group <span className="required">*</span></label>
<select
value={itemGroup}
onChange={(e) => setItemGroup(e.target.value)}
className="classification-modal-select"
>
<option value="">-- Select Group --</option>
{availableGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</div>
)}
<div className="classification-modal-field">
<label>Store Zone (Optional)</label>
<select
value={zone}
onChange={(e) => setZone(e.target.value)}
className="classification-modal-select"
>
<option value="">-- Select Zone --</option>
{ZONES.map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
</div>
<div className="classification-modal-actions">
<button
className="classification-modal-btn classification-modal-btn-skip"
onClick={onSkip}
>
Skip for Now
</button>
<button
className="classification-modal-btn classification-modal-btn-confirm"
onClick={handleConfirm}
>
Confirm
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import "../../styles/SimilarItemModal.css";
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
return (
<div className="similar-item-modal-overlay" onClick={onCancel}>
<div className="similar-item-modal" onClick={(e) => e.stopPropagation()}>
<h2>Similar Item Found</h2>
<p className="similar-item-question">
Do you mean <strong>"{suggestedName}"</strong>?
</p>
<p className="similar-item-clarification">
You entered: "{originalName}"
</p>
<div className="similar-item-actions">
<button onClick={onCancel} className="similar-item-cancel">
Cancel
</button>
<button onClick={onNo} className="similar-item-no">
No, Create New
</button>
<button onClick={onYes} className="similar-item-yes">
Yes, Use Suggestion
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
// Barrel export for modal components
export { default as AddImageModal } from './AddImageModal.jsx';
export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx';
export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx';
export { default as EditItemModal } from './EditItemModal.jsx';
export { default as ImageModal } from './ImageModal.jsx';
export { default as ImageUploadModal } from './ImageUploadModal.jsx';
export { default as ItemClassificationModal } from './ItemClassificationModal.jsx';
export { default as SimilarItemModal } from './SimilarItemModal.jsx';

View File

@ -0,0 +1,181 @@
// Item classification constants - app-level controlled values
export const ITEM_TYPES = {
PRODUCE: "produce",
MEAT: "meat",
DAIRY: "dairy",
BAKERY: "bakery",
FROZEN: "frozen",
PANTRY: "pantry",
BEVERAGE: "beverage",
SNACK: "snack",
HOUSEHOLD: "household",
PERSONAL_CARE: "personal_care",
OTHER: "other",
};
// Item groups filtered by item type
export const ITEM_GROUPS = {
[ITEM_TYPES.PRODUCE]: [
"Fruits",
"Vegetables",
"Salad Mix",
"Herbs",
"Organic Produce",
],
[ITEM_TYPES.MEAT]: [
"Beef",
"Pork",
"Chicken",
"Seafood",
"Deli Meat",
"Prepared Meat",
],
[ITEM_TYPES.DAIRY]: [
"Milk",
"Cheese",
"Yogurt",
"Butter",
"Eggs",
"Cream",
],
[ITEM_TYPES.BAKERY]: [
"Bread",
"Rolls",
"Pastries",
"Cakes",
"Bagels",
"Tortillas",
],
[ITEM_TYPES.FROZEN]: [
"Frozen Meals",
"Ice Cream",
"Frozen Vegetables",
"Frozen Meat",
"Pizza",
"Desserts",
],
[ITEM_TYPES.PANTRY]: [
"Canned Goods",
"Pasta",
"Rice",
"Cereal",
"Condiments",
"Spices",
"Baking",
"Oils",
],
[ITEM_TYPES.BEVERAGE]: [
"Water",
"Soda",
"Juice",
"Coffee",
"Tea",
"Alcohol",
"Sports Drinks",
],
[ITEM_TYPES.SNACK]: [
"Chips",
"Crackers",
"Nuts",
"Candy",
"Cookies",
"Protein Bars",
],
[ITEM_TYPES.HOUSEHOLD]: [
"Cleaning Supplies",
"Paper Products",
"Laundry",
"Kitchen Items",
"Storage",
],
[ITEM_TYPES.PERSONAL_CARE]: [
"Bath & Body",
"Hair Care",
"Oral Care",
"Skincare",
"Health",
],
[ITEM_TYPES.OTHER]: [
"Miscellaneous",
],
};
// Store zones - path-oriented physical shopping areas
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
export const ZONES = {
ENTRANCE: "Entrance & Seasonal",
PRODUCE_SECTION: "Produce & Fresh Vegetables",
MEAT_SEAFOOD: "Meat & Seafood Counter",
DELI_PREPARED: "Deli & Prepared Foods",
BAKERY_SECTION: "Bakery",
DAIRY_SECTION: "Dairy & Refrigerated",
FROZEN_FOODS: "Frozen Foods",
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
BEVERAGES: "Beverages & Water",
SNACKS_CANDY: "Snacks & Candy",
HOUSEHOLD_CLEANING: "Household & Cleaning",
HEALTH_BEAUTY: "Health & Beauty",
CHECKOUT_AREA: "Checkout Area",
};
// Default zone mapping for each item type
// This determines where items are typically found in the store
export const ITEM_TYPE_TO_ZONE = {
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
};
// Optimal walking flow through the store
// Represents a typical shopping path that minimizes backtracking
// Start with perimeter (fresh items), then move to center aisles, end at checkout
export const ZONE_FLOW = [
ZONES.ENTRANCE,
ZONES.PRODUCE_SECTION,
ZONES.MEAT_SEAFOOD,
ZONES.DELI_PREPARED,
ZONES.BAKERY_SECTION,
ZONES.DAIRY_SECTION,
ZONES.FROZEN_FOODS,
ZONES.DRY_GOODS_CENTER,
ZONES.BEVERAGES,
ZONES.SNACKS_CANDY,
ZONES.HOUSEHOLD_CLEANING,
ZONES.HEALTH_BEAUTY,
ZONES.CHECKOUT_AREA,
];
// Helper to get display label for item type
export const getItemTypeLabel = (type) => {
const labels = {
[ITEM_TYPES.PRODUCE]: "Produce",
[ITEM_TYPES.MEAT]: "Meat & Seafood",
[ITEM_TYPES.DAIRY]: "Dairy & Eggs",
[ITEM_TYPES.BAKERY]: "Bakery",
[ITEM_TYPES.FROZEN]: "Frozen",
[ITEM_TYPES.PANTRY]: "Pantry & Dry Goods",
[ITEM_TYPES.BEVERAGE]: "Beverages",
[ITEM_TYPES.SNACK]: "Snacks",
[ITEM_TYPES.HOUSEHOLD]: "Household",
[ITEM_TYPES.PERSONAL_CARE]: "Personal Care",
[ITEM_TYPES.OTHER]: "Other",
};
return labels[type] || type;
};
// Helper to get all zone values as array (for dropdowns)
export const getZoneValues = () => Object.values(ZONES);
// Helper to get suggested zone for an item type
export const getSuggestedZone = (itemType) => {
return ITEM_TYPE_TO_ZONE[itemType] || null;
};

View File

@ -68,16 +68,32 @@ button:focus-visible {
} */
/**
* Global Base Styles
* Uses theme variables defined in theme.css
*/
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
background: var(--color-bg-body);
margin: 0;
padding: 1em;
background: #f8f9fa;
padding: var(--spacing-md);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 480px;
max-width: var(--container-max-width);
margin: auto;
padding: var(--container-padding);
}
background: white;
padding: 1em;
border-radius: 8px;

View File

@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import './styles/theme.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Move to common/
mv components/FloatingActionButton.jsx components/common/
mv components/SortDropdown.jsx components/common/
mv components/ErrorMessage.jsx components/common/
mv components/FormInput.jsx components/common/
mv components/UserRoleCard.jsx components/common/
# Move to modals/
mv components/AddItemWithDetailsModal.jsx components/modals/
mv components/EditItemModal.jsx components/modals/
mv components/SimilarItemModal.jsx components/modals/
mv components/ConfirmBuyModal.jsx components/modals/
mv components/ImageModal.jsx components/modals/
mv components/AddImageModal.jsx components/modals/
mv components/ImageUploadModal.jsx components/modals/
mv components/ItemClassificationModal.jsx components/modals/
# Move to forms/
mv components/AddItemForm.jsx components/forms/
mv components/ImageUploadSection.jsx components/forms/
mv components/ClassificationSection.jsx components/forms/
# Move to items/
mv components/GroceryListItem.jsx components/items/
mv components/GroceryItem.tsx components/items/
mv components/SuggestionList.tsx components/items/
# Move to layout/
mv components/AppLayout.jsx components/layout/
mv components/Navbar.jsx components/layout/
echo "Components moved successfully!"

View File

@ -0,0 +1,15 @@
#!/bin/bash
# Move page styles
mv styles/GroceryList.css styles/pages/
mv styles/Login.css styles/pages/
mv styles/Register.css styles/pages/
# Move component styles
mv styles/Navbar.css styles/components/
mv styles/AddItemWithDetailsModal.css styles/components/
mv styles/EditItemModal.css styles/components/
mv styles/ImageUploadSection.css styles/components/
mv styles/ClassificationSection.css styles/components/
echo "Styles moved successfully!"

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users";
import { ROLES } from "../constants/roles";
import UserRoleCard from "../components/common/UserRoleCard";
import "../styles/UserRoleCard.css";
export default function AdminPanel() {
const [users, setUsers] = useState([]);
async function loadUsers() {
const allUsers = await getAllUsers();
console.log(allUsers);
setUsers(allUsers.data);
}
@ -22,19 +22,17 @@ export default function AdminPanel() {
}
return (
<div>
<div style={{ padding: "2rem" }}>
<h1>Admin Panel</h1>
{users.map((user) => (
<div key={user.id}>
<strong>{user.username}</strong> - {user.role}
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
<option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option>
<option value={ROLES.ADMIN}>Admin</option>
</select>
</div>
))
}
</div >
<div style={{ marginTop: "2rem" }}>
{users.map((user) => (
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
</div>
</div>
)
}

View File

@ -1,62 +1,113 @@
import { useContext, useEffect, useState } from "react";
import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list";
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
import FloatingActionButton from "../components/common/FloatingActionButton";
import SortDropdown from "../components/common/SortDropdown";
import AddItemForm from "../components/forms/AddItemForm";
import GroceryListItem from "../components/items/GroceryListItem";
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal";
import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext";
import "../styles/GroceryList.css";
import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() {
const { role, username } = useContext(AuthContext);
const { role } = useContext(AuthContext);
const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("az");
const [showSuggestions, setShowSuggestions] = useState(false);
const [itemName, setItemName] = useState("");
const [quantity, setQuantity] = useState(1);
const [sortMode, setSortMode] = useState("zone");
const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(true);
const [loading, setLoading] = useState(true);
const [buttonText, setButtonText] = useState("Add Item");
const [pendingItem, setPendingItem] = useState(null);
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
const [showSimilarModal, setShowSimilarModal] = useState(false);
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const loadItems = async () => {
setLoading(true);
const res = await getList();
console.log(res.data);
setItems(res.data);
setLoading(false);
};
const loadRecentlyBought = async () => {
try {
const res = await getRecentlyBought();
setRecentlyBoughtItems(res.data);
} catch (error) {
console.error("Failed to load recently bought items:", error);
setRecentlyBoughtItems([]);
}
};
useEffect(() => {
loadItems();
loadRecentlyBought();
}, []);
useEffect(() => {
let sorted = [...items];
if (sortMode === "az")
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
if (sortMode === "zone") {
sorted.sort((a, b) => {
// Items without classification go to the end
if (!a.item_type && b.item_type) return 1;
if (a.item_type && !b.item_type) return -1;
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
if (sortMode === "za")
sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
// Sort by item_type
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare;
if (sortMode === "qty-high")
sorted.sort((a, b) => b.quantity - a.quantity);
// Then by item_group
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare;
if (sortMode === "qty-low")
sorted.sort((a, b) => a.quantity - b.quantity);
// Then by zone
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
if (zoneCompare !== 0) return zoneCompare;
// Finally by name
return a.item_name.localeCompare(b.item_name);
});
}
setSortedItems(sorted);
}, [items, sortMode]);
const handleSuggest = async (text) => {
setItemName(text);
if (!text.trim()) {
setSuggestions([]);
setButtonText("Add Item");
return;
}
// Combine both unbought and recently bought items for similarity checking
const allItems = [...items, ...recentlyBoughtItems];
// Check if exact match exists (case-insensitive)
const lowerText = text.toLowerCase().trim();
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
if (exactMatch) {
setButtonText("Add");
} else {
setButtonText("Create + Add");
}
try {
let suggestions = await getSuggestions(text);
suggestions = suggestions.data.map(s => s.item_name);
@ -66,38 +117,213 @@ export default function GroceryList() {
}
};
const handleAdd = async (e) => {
e.preventDefault();
const handleAdd = async (itemName, quantity) => {
if (!itemName.trim()) return;
let newQuantity = quantity;
const item = await getItemByName(itemName);
if (item.data && item.data.bought === false) {
console.log("Item exists:", item.data);
let currentQuantity = item.data.quantity;
const lowerItemName = itemName.toLowerCase().trim();
// First check if exact item exists in database (case-insensitive)
let existingItem = null;
try {
const response = await getItemByName(itemName);
existingItem = response.data;
} catch {
existingItem = null;
}
// If exact item exists, skip similarity check and process directly
if (existingItem) {
await processItemAddition(itemName, quantity);
return;
}
// Only check for similar items if exact item doesn't exist
const allItems = [...items, ...recentlyBoughtItems];
const similar = findSimilarItems(itemName, allItems, 80);
if (similar.length > 0) {
// Show modal and wait for user decision
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
setShowSimilarModal(true);
return;
}
// Continue with normal flow for new items
await processItemAddition(itemName, quantity);
};
const processItemAddition = async (itemName, quantity) => {
// Check if item exists in database (case-insensitive)
let existingItem = null;
try {
const response = await getItemByName(itemName);
existingItem = response.data;
} catch {
existingItem = null;
}
if (existingItem && existingItem.bought === false) {
// Item exists and is unbought - update quantity
const currentQuantity = existingItem.quantity;
const newQuantity = currentQuantity + quantity;
const yes = window.confirm(
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${currentQuantity + newQuantity}?`
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
);
if (!yes) return;
newQuantity += currentQuantity;
await addItem(itemName, newQuantity, null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
} else if (existingItem) {
// Item exists in database (was previously bought) - just add quantity
await addItem(itemName, quantity, null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
} else {
// NEW ITEM - show combined add details modal
setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true);
}
await addItem(itemName, newQuantity);
setItemName("");
setQuantity(1);
setSuggestions([]);
loadItems();
};
const handleBought = async (id) => {
const yes = window.confirm("Mark this item as bought?");
if (!yes) return;
const handleSimilarCancel = () => {
setShowSimilarModal(false);
setSimilarItemSuggestion(null);
};
const handleSimilarNo = async () => {
if (!similarItemSuggestion) return;
setShowSimilarModal(false);
// Create new item with original name
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null);
};
const handleSimilarYes = async () => {
if (!similarItemSuggestion) return;
setShowSimilarModal(false);
// Use suggested item name
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null);
};
const handleAddDetailsConfirm = async (imageFile, classification) => {
if (!pendingItem) return;
try {
// Add item to grocery_list with image
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
// If classification provided, add it
if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id;
await updateItemWithClassification(itemId, undefined, undefined, classification);
}
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
};
const handleAddDetailsSkip = async () => {
if (!pendingItem) return;
try {
// Add item without image or classification
await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
};
const handleAddDetailsCancel = () => {
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
};
const handleBought = async (id, quantity) => {
await markBought(id);
loadItems();
loadRecentlyBought();
};
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
try {
await updateItemImage(id, itemName, quantity, imageFile);
loadItems(); // Reload to show new image
} catch (error) {
console.error("Failed to add image:", error);
alert("Failed to add image. Please try again.");
}
};
const handleLongPress = async (item) => {
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
try {
// Fetch existing classification
const classificationResponse = await getClassification(item.id);
setEditingItem({
...item,
classification: classificationResponse.data
});
setShowEditModal(true);
} catch (error) {
console.error("Failed to load classification:", error);
setEditingItem({ ...item, classification: null });
setShowEditModal(true);
}
};
const handleEditSave = async (id, itemName, quantity, classification) => {
try {
await updateItemWithClassification(id, itemName, quantity, classification);
setShowEditModal(false);
setEditingItem(null);
loadItems();
loadRecentlyBought();
} catch (error) {
console.error("Failed to update item:", error);
throw error; // Re-throw to let modal handle it
}
};
const handleEditCancel = () => {
setShowEditModal(false);
setEditingItem(null);
};
// Group items by zone for classification view
const groupItemsByZone = (items) => {
const groups = {};
items.forEach(item => {
const zone = item.zone || 'unclassified';
if (!groups[zone]) {
groups[zone] = [];
}
groups[zone].push(item);
});
return groups;
};
if (loading) return <p>Loading...</p>;
@ -107,86 +333,132 @@ export default function GroceryList() {
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
{/* Sorting dropdown */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value)}
className="glist-sort"
>
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
</select>
{/* Add Item form (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
<>
<input
type="text"
className="glist-input"
placeholder="Item name"
value={itemName}
onChange={(e) => handleSuggest(e.target.value)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onClick={() => setShowSuggestions(true)}
/>
{showSuggestions && suggestions.length > 0 && (
<ul className="glist-suggest-box">
{suggestions.map((s, i) => (
<li
key={i}
className="glist-suggest-item"
onClick={() => {
setItemName(s);
setSuggestions([]);
}}
>
{s}
</li>
))}
</ul>
)}
<input
type="number"
min="1"
className="glist-input"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button className="glist-btn" onClick={handleAdd}>
Add Item
</button>
</>
<AddItemForm
onAdd={handleAdd}
onSuggest={handleSuggest}
suggestions={suggestions}
buttonText={buttonText}
/>
)}
{/* Grocery list */}
<ul className="glist-ul">
{sortedItems.map((item) => (
<li
key={item.id}
className="glist-li"
onClick={() =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id)
}
>
{item.item_name} ({item.quantity})
</li>
))}
</ul>
<SortDropdown value={sortMode} onChange={setSortMode} />
{sortMode === "zone" ? (
// Grouped view by zone
(() => {
const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => (
<div key={zone} className="glist-classification-group">
<h3 className="glist-classification-header">
{zone === 'unclassified' ? 'Unclassified' : zone}
</h3>
<ul className="glist-ul">
{grouped[zone].map((item) => (
<GroceryListItem
key={item.id}
item={item}
onClick={(quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
</div>
));
})()
) : (
// Regular flat list view
<ul className="glist-ul">
{sortedItems.map((item) => (
<GroceryListItem
key={item.id}
item={item}
onClick={(quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
)}
{recentlyBoughtItems.length > 0 && (
<>
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
<ul className="glist-ul">
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
<GroceryListItem
key={item.id}
item={item}
onClick={null}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<button
className="glist-show-more-btn"
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
>
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
</button>
</div>
)}
</>
)}
</div>
{/* Floating Button (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
<button
className="glist-fab"
<FloatingActionButton
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? "" : "+"}
</button>
/>
)}
{showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal
itemName={pendingItem.itemName}
onConfirm={handleAddDetailsConfirm}
onSkip={handleAddDetailsSkip}
onCancel={handleAddDetailsCancel}
/>
)}
{showSimilarModal && similarItemSuggestion && (
<SimilarItemModal
originalName={similarItemSuggestion.originalName}
suggestedName={similarItemSuggestion.suggestedItem.item_name}
onCancel={handleSimilarCancel}
onNo={handleSimilarNo}
onYes={handleSimilarYes}
/>
)}
{showEditModal && editingItem && (
<EditItemModal
item={editingItem}
onSave={handleEditSave}
onCancel={handleEditCancel}
/>
)}
</div>
);

View File

@ -1,13 +1,16 @@
import { useContext, useState } from "react";
import { Link } from "react-router-dom";
import { loginRequest } from "../api/auth";
import ErrorMessage from "../components/common/ErrorMessage";
import FormInput from "../components/common/FormInput";
import { AuthContext } from "../context/AuthContext";
import "../styles/Login.css";
import "../styles/pages/Login.css";
export default function Login() {
const { login } = useContext(AuthContext);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const submit = async (e) => {
@ -28,22 +31,32 @@ export default function Login() {
<div className="login-box">
<h1 className="login-title">Login</h1>
{error && <p className="login-error">{error}</p>}
<ErrorMessage message={error} />
<form onSubmit={submit}>
<input
<FormInput
type="text"
className="login-input"
placeholder="Username"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
className="login-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="login-password-wrapper">
<FormInput
type={showPassword ? "text" : "password"}
className="login-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="login-password-toggle"
onClick={() => setShowPassword(!showPassword)}
aria-label="Toggle password visibility"
>
{showPassword ? "👀" : "🙈"}
</button>
</div>
<button type="submit" className="login-button">Login</button>
</form>

View File

@ -2,9 +2,10 @@ import { useContext, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { loginRequest, registerRequest } from "../api/auth";
import { checkIfUserExists } from "../api/users";
import ErrorMessage from "../components/common/ErrorMessage";
import FormInput from "../components/common/FormInput";
import { AuthContext } from "../context/AuthContext";
import "../styles/Register.css";
import "../styles/pages/Register.css";
export default function Register() {
const navigate = useNavigate();
@ -19,25 +20,25 @@ export default function Register() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
checkIfUserExistsHandler();
}, [username]);
useEffect(() => { checkIfUserExistsHandler(); }, [username]);
async function checkIfUserExistsHandler() {
setUserExists((await checkIfUserExists(username)).data);
}
useEffect(() => { setError(userExists ? `Username '${username}' already taken` : ""); }, [userExists]);
useEffect(() => {
setError(userExists ? `Username '${username}' already taken` : "");
}, [userExists]);
useEffect(() => {
setPasswordMatches(
!password ||
!confirm ||
password === confirm
);
setPasswordMatches(!password || !confirm || password === confirm);
}, [password, confirm]);
useEffect(() => { setError(passwordMatches ? "" : "Passwords are not matching"); }, [passwordMatches]);
useEffect(() => {
setError(passwordMatches ? "" : "Passwords are not matching");
}, [passwordMatches]);
const submit = async (e) => {
e.preventDefault();
@ -46,51 +47,46 @@ export default function Register() {
try {
await registerRequest(username, password, name);
console.log("Registered user:", username);
const data = await loginRequest(username, password);
console.log(data);
login(data);
setSuccess("Account created! Redirecting the grocery list...");
setSuccess("Account created! Redirecting to the grocery list...");
setTimeout(() => navigate("/"), 2000);
} catch (err) {
setError(err.response?.data?.message || "Registration failed");
setTimeout(() => {
setError("");
}, 1000);
setTimeout(() => setError(""), 1000);
}
};
return (
<div className="register-container">
<h1>Register</h1>
{<p className="error-message">{error}</p>}
{success && <p className="success-message">{success}</p>}
<ErrorMessage message={error} />
<ErrorMessage message={success} type="success" />
<form className="register-form" onSubmit={submit}>
<input
<FormInput
type="text"
placeholder="Name"
onChange={(e) => setName(e.target.value)}
required
/>
<input
<FormInput
type="text"
placeholder="Username"
onKeyUp={(e) => setUsername(e.target.value)}
required
/>
<input
<FormInput
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
required
/>
<input
<FormInput
type="password"
placeholder="Confirm Password"
onChange={(e) => setConfirm(e.target.value)}

View File

@ -0,0 +1,176 @@
.add-image-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.add-image-modal {
background: white;
padding: 2em;
border-radius: 12px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out;
}
.add-image-modal h2 {
margin: 0 0 0.5em 0;
font-size: 1.5em;
color: #333;
text-align: center;
}
.add-image-subtitle {
margin: 0 0 1.5em 0;
color: #666;
font-size: 0.95em;
text-align: center;
}
.add-image-subtitle strong {
color: #007bff;
}
.add-image-options {
display: flex;
flex-direction: column;
gap: 1em;
margin: 2em 0;
}
.add-image-option-btn {
padding: 1.2em;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
font-size: 1.1em;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.add-image-option-btn:hover {
border-color: #007bff;
background: #f8f9fa;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
}
.add-image-option-btn.camera {
color: #007bff;
}
.add-image-option-btn.gallery {
color: #28a745;
}
.add-image-preview-container {
margin: 1.5em 0;
display: flex;
justify-content: center;
}
.add-image-preview {
position: relative;
width: 250px;
height: 250px;
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.add-image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.add-image-remove {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
font-size: 1.5em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.add-image-remove:hover {
background: rgba(255, 0, 0, 1);
}
.add-image-actions {
display: flex;
gap: 1em;
margin-top: 1.5em;
}
.add-image-cancel,
.add-image-confirm {
flex: 1;
padding: 0.8em;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
}
.add-image-cancel {
background: #f0f0f0;
color: #333;
}
.add-image-cancel:hover {
background: #e0e0e0;
}
.add-image-confirm {
background: #28a745;
color: white;
}
.add-image-confirm:hover {
background: #218838;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -0,0 +1,158 @@
.confirm-buy-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.confirm-buy-modal {
background: white;
padding: 2em;
border-radius: 12px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out;
}
.confirm-buy-modal h2 {
margin: 0 0 0.5em 0;
font-size: 1.5em;
color: #333;
text-align: center;
}
.confirm-buy-item-name {
margin: 0 0 1.5em 0;
font-size: 1.1em;
color: #007bff;
font-weight: 600;
text-align: center;
}
.confirm-buy-quantity-section {
margin: 2em 0;
}
.confirm-buy-label {
margin: 0 0 1em 0;
font-size: 1em;
color: #555;
text-align: center;
}
.confirm-buy-counter {
display: flex;
align-items: center;
justify-content: center;
gap: 1em;
}
.confirm-buy-counter-btn {
width: 50px;
height: 50px;
border: 2px solid #007bff;
border-radius: 8px;
background: white;
color: #007bff;
font-size: 1.8em;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.confirm-buy-counter-btn:hover:not(:disabled) {
background: #007bff;
color: white;
}
.confirm-buy-counter-btn:disabled {
border-color: #ccc;
color: #ccc;
cursor: not-allowed;
}
.confirm-buy-counter-display {
width: 80px;
height: 50px;
border: 2px solid #ddd;
border-radius: 8px;
text-align: center;
font-size: 1.5em;
font-weight: bold;
color: #333;
background: #f8f9fa;
}
.confirm-buy-counter-display:focus {
outline: none;
border-color: #007bff;
}
.confirm-buy-actions {
display: flex;
gap: 1em;
margin-top: 2em;
}
.confirm-buy-cancel,
.confirm-buy-confirm {
flex: 1;
padding: 0.9em;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.confirm-buy-cancel {
background: #f0f0f0;
color: #333;
}
.confirm-buy-cancel:hover {
background: #e0e0e0;
}
.confirm-buy-confirm {
background: #28a745;
color: white;
}
.confirm-buy-confirm:hover {
background: #218838;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -1,138 +0,0 @@
/* Container */
.glist-body {
font-family: Arial, sans-serif;
padding: 1em;
background: #f8f9fa;
}
.glist-container {
max-width: 480px;
margin: auto;
background: white;
padding: 1em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
}
/* Title */
.glist-title {
text-align: center;
font-size: 1.5em;
margin-bottom: 0.4em;
}
/* Inputs */
.glist-input {
font-size: 1em;
padding: 0.5em;
margin: 0.3em 0;
width: 100%;
box-sizing: border-box;
}
/* Buttons */
.glist-btn {
font-size: 1em;
padding: 0.55em;
width: 100%;
margin-top: 0.4em;
cursor: pointer;
border: none;
background: #007bff;
color: white;
border-radius: 4px;
}
.glist-btn:hover {
background: #0067d8;
}
/* Suggestion dropdown */
.glist-suggest-box {
background: #fff;
border: 1px solid #ccc;
max-height: 150px;
overflow-y: auto;
position: absolute;
z-index: 999;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: 1em;
width: calc(100% - 8em);
max-width: 440px;
margin: 0 auto;
}
.glist-suggest-item {
padding: 0.5em;
padding-inline: 2em;
cursor: pointer;
}
.glist-suggest-item:hover {
background: #eee;
}
/* Grocery list items */
.glist-ul {
list-style: none;
padding: 0;
margin-top: 1em;
}
.glist-li {
padding: 0.7em;
background: #e9ecef;
border-radius: 5px;
margin-bottom: 0.6em;
cursor: pointer;
}
.glist-li:hover {
background: #dee2e6;
}
/* Sorting dropdown */
.glist-sort {
width: 100%;
margin: 0.3em 0;
padding: 0.5em;
font-size: 1em;
border-radius: 4px;
}
/* Floating Action Button (FAB) */
.glist-fab {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 50%;
width: 62px;
height: 62px;
font-size: 2em;
line-height: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
cursor: pointer;
}
.glist-fab:hover {
background: #218838;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.glist-container {
padding: 1em 0.8em;
}
.glist-fab {
bottom: 16px;
right: 16px;
}
}

View File

@ -0,0 +1,99 @@
.image-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.image-modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.image-modal-close {
position: absolute;
top: -15px;
right: -15px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #ff4444;
color: white;
border: 3px solid white;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
transition: background 0.2s;
}
.image-modal-close:hover {
background: #cc0000;
}
.image-modal-img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
display: block;
border-radius: 8px;
}
.image-modal-caption {
text-align: center;
margin-top: 1rem;
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
@media (max-width: 768px) {
.image-modal-overlay {
padding: 1rem;
}
.image-modal-img {
max-height: 60vh;
}
.image-modal-caption {
font-size: 1rem;
}
}

View File

@ -0,0 +1,203 @@
.image-upload-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.image-upload-modal {
background: white;
padding: 2em;
border-radius: 12px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out;
}
.image-upload-modal h2 {
margin: 0 0 0.5em 0;
font-size: 1.5em;
color: #333;
}
.image-upload-subtitle {
margin: 0 0 1.5em 0;
color: #666;
font-size: 0.95em;
}
.image-upload-content {
margin: 1.5em 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
}
.image-upload-options {
display: flex;
flex-direction: column;
gap: 1em;
width: 100%;
margin: 1em 0;
}
.image-upload-option-btn {
padding: 1.2em;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
font-size: 1.1em;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.image-upload-option-btn:hover {
border-color: #007bff;
background: #f8f9fa;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
}
.image-upload-option-btn.camera {
color: #007bff;
}
.image-upload-option-btn.gallery {
color: #28a745;
}
.image-upload-button {
display: inline-block;
padding: 0.8em 1.5em;
background: #007bff;
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
transition: background 0.2s;
}
.image-upload-button:hover {
background: #0056b3;
}
.modal-image-preview {
position: relative;
width: 200px;
height: 200px;
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.modal-image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.modal-remove-image {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
font-size: 1.5em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.modal-remove-image:hover {
background: rgba(255, 0, 0, 1);
}
.image-upload-actions {
display: flex;
gap: 1em;
margin-top: 1.5em;
}
.image-upload-cancel,
.image-upload-skip,
.image-upload-confirm {
flex: 1;
padding: 0.8em;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
}
.image-upload-cancel {
background: #dc3545;
color: white;
}
.image-upload-cancel:hover {
background: #c82333;
}
.image-upload-skip {
background: #f0f0f0;
color: #333;
}
.image-upload-skip:hover {
background: #e0e0e0;
}
.image-upload-confirm {
background: #28a745;
color: white;
}
.image-upload-confirm:hover:not(:disabled) {
background: #218838;
}
.image-upload-confirm:disabled {
background: #ccc;
cursor: not-allowed;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -0,0 +1,102 @@
.classification-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1em;
}
.classification-modal-content {
background: white;
border-radius: 12px;
padding: 1.5em;
max-width: 480px;
width: 100%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.classification-modal-title {
font-size: 1.5em;
margin: 0 0 0.3em 0;
text-align: center;
color: #333;
}
.classification-modal-subtitle {
text-align: center;
color: #666;
margin: 0 0 1.5em 0;
font-size: 0.95em;
}
.classification-modal-field {
margin-bottom: 1em;
}
.classification-modal-field label {
display: block;
margin-bottom: 0.3em;
font-weight: 600;
color: #333;
font-size: 0.95em;
}
.classification-modal-field label .required {
color: #dc3545;
}
.classification-modal-select {
width: 100%;
padding: 0.6em;
font-size: 1em;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.classification-modal-select:focus {
outline: none;
border-color: #007bff;
}
.classification-modal-actions {
display: flex;
gap: 0.8em;
margin-top: 1.5em;
}
.classification-modal-btn {
flex: 1;
padding: 0.7em;
font-size: 1em;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.classification-modal-btn-skip {
background: #6c757d;
color: white;
}
.classification-modal-btn-skip:hover {
background: #5a6268;
}
.classification-modal-btn-confirm {
background: #007bff;
color: white;
}
.classification-modal-btn-confirm:hover {
background: #0056b3;
}

View File

@ -0,0 +1,115 @@
.similar-item-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.similar-item-modal {
background: white;
padding: 2em;
border-radius: 12px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out;
}
.similar-item-modal h2 {
margin: 0 0 1em 0;
font-size: 1.5em;
color: #333;
text-align: center;
}
.similar-item-question {
margin: 0 0 0.5em 0;
font-size: 1.1em;
color: #333;
text-align: center;
}
.similar-item-question strong {
color: #007bff;
}
.similar-item-clarification {
margin: 0 0 2em 0;
font-size: 0.9em;
color: #666;
text-align: center;
font-style: italic;
}
.similar-item-actions {
display: flex;
gap: 0.8em;
margin-top: 1.5em;
}
.similar-item-cancel,
.similar-item-no,
.similar-item-yes {
flex: 1;
padding: 0.8em;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.similar-item-cancel {
background: #f0f0f0;
color: #333;
}
.similar-item-cancel:hover {
background: #e0e0e0;
}
.similar-item-no {
background: #6c757d;
color: white;
}
.similar-item-no:hover {
background: #5a6268;
}
.similar-item-yes {
background: #28a745;
color: white;
}
.similar-item-yes:hover {
background: #218838;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -0,0 +1,130 @@
/**
* Theme Variable Usage Examples
*
* This file demonstrates how to refactor existing CSS to use theme variables.
* Copy these patterns when updating component styles.
*/
/* ============================================
BEFORE: Hardcoded values
============================================ */
.button-old {
background: #007bff;
color: white;
padding: 0.6em 1.2em;
border-radius: 4px;
border: none;
font-size: 1em;
transition: 0.2s;
}
.button-old:hover {
background: #0056b3;
}
/* ============================================
AFTER: Using theme variables
============================================ */
.button-new {
background: var(--color-primary);
color: var(--color-text-inverse);
padding: var(--button-padding-y) var(--button-padding-x);
border-radius: var(--button-border-radius);
border: none;
font-size: var(--font-size-base);
font-weight: var(--button-font-weight);
transition: var(--transition-base);
cursor: pointer;
}
.button-new:hover {
background: var(--color-primary-hover);
}
/* ============================================
MORE EXAMPLES
============================================ */
/* Input Field */
.input-field {
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
font-size: var(--font-size-base);
font-family: var(--font-family-base);
transition: var(--transition-base);
}
.input-field:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
/* Card Component */
.card {
background: var(--card-bg);
padding: var(--card-padding);
border-radius: var(--card-border-radius);
box-shadow: var(--card-shadow);
margin-bottom: var(--spacing-md);
}
/* Modal */
.modal-overlay {
background: var(--modal-backdrop-bg);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.modal-content {
background: var(--modal-bg);
padding: var(--modal-padding);
border-radius: var(--modal-border-radius);
max-width: var(--modal-max-width);
box-shadow: var(--shadow-xl);
}
/* Text Styles */
.heading-primary {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin-bottom: var(--spacing-md);
}
.text-muted {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
/* Spacing Examples */
.section {
margin-bottom: var(--spacing-xl);
}
.field-group {
margin-bottom: var(--spacing-md);
}
/* Border Examples */
.divider {
border-bottom: var(--border-width-thin) solid var(--color-border-light);
margin: var(--spacing-lg) 0;
}
/* ============================================
BENEFITS OF USING THEME VARIABLES
============================================
1. Consistency: All components use the same colors/spacing
2. Maintainability: Change once, update everywhere
3. Theme switching: Easy to implement dark mode
4. Scalability: Add new colors/sizes without touching components
5. Documentation: Variable names are self-documenting
*/

View File

@ -0,0 +1,40 @@
.user-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
margin: 0.5rem 0;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #ddd;
}
.user-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.user-username {
color: #666;
font-size: 0.9rem;
}
.role-select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
font-size: 0.9rem;
}
.role-select:hover {
border-color: #007bff;
}
.role-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}

View File

@ -0,0 +1,172 @@
/* Add Item Form Container */
.add-item-form-container {
background: var(--color-bg-surface);
padding: var(--spacing-lg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--spacing-xs);
border: var(--border-width-thin) solid var(--color-border-light);
}
.add-item-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
/* Form Fields */
.add-item-form-field {
display: flex;
flex-direction: column;
position: relative;
}
.add-item-form-input {
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
font-size: var(--font-size-base);
font-family: var(--font-family-base);
transition: var(--transition-base);
width: 100%;
}
.add-item-form-input:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
/* Suggestion List Positioning */
.add-item-form-field .suggestion-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: var(--spacing-xs);
z-index: var(--z-dropdown);
}
/* Actions Row */
.add-item-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
}
/* Quantity Control */
.add-item-form-quantity-control {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.quantity-btn {
width: 40px;
height: 40px;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface);
color: var(--color-text-primary);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
cursor: pointer;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.quantity-btn:hover:not(:disabled) {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
.quantity-btn:active:not(:disabled) {
transform: scale(0.95);
}
.quantity-btn:disabled {
background: var(--color-bg-disabled);
color: var(--color-text-disabled);
border-color: var(--color-border-disabled);
cursor: not-allowed;
opacity: 0.5;
}
.add-item-form-quantity-input {
width: 40px;
max-width: 40px;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
font-size: var(--font-size-base);
font-family: var(--font-family-base);
text-align: center;
transition: var(--transition-base);
-moz-appearance: textfield; /* Remove spinner in Firefox */
}
/* Remove spinner arrows in Chrome/Safari */
.add-item-form-quantity-input::-webkit-outer-spin-button,
.add-item-form-quantity-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.add-item-form-quantity-input:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
/* Submit Button */
.add-item-form-submit {
height: 40px;
padding: 0 var(--spacing-lg);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--button-border-radius);
font-size: var(--font-size-base);
font-weight: var(--button-font-weight);
cursor: pointer;
transition: var(--transition-base);
margin-top: var(--spacing-sm);
}
.add-item-form-submit:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.add-item-form-submit:active:not(:disabled) {
transform: translateY(0);
}
.add-item-form-submit.disabled,
.add-item-form-submit:disabled {
background: var(--color-bg-disabled);
color: var(--color-text-disabled);
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
transform: none;
}
/* Responsive */
@media (max-width: 480px) {
.add-item-form-container {
padding: var(--spacing-md);
}
.quantity-btn {
width: 36px;
height: 36px;
font-size: var(--font-size-lg);
}
}

View File

@ -0,0 +1,218 @@
.add-item-details-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1em;
}
.add-item-details-modal {
background: white;
border-radius: 12px;
padding: 1.5em;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.add-item-details-title {
font-size: 1.4em;
margin: 0 0 0.3em 0;
text-align: center;
color: #333;
}
.add-item-details-subtitle {
text-align: center;
color: #666;
margin: 0 0 1.5em 0;
font-size: 0.9em;
}
.add-item-details-section {
margin-bottom: 1.5em;
padding-bottom: 1.5em;
border-bottom: 1px solid #e0e0e0;
}
.add-item-details-section:last-of-type {
border-bottom: none;
}
.add-item-details-section-title {
font-size: 1.1em;
margin: 0 0 1em 0;
color: #555;
font-weight: 600;
}
/* Image Upload Section */
.add-item-details-image-content {
min-height: 120px;
}
.add-item-details-image-options {
display: flex;
gap: 0.8em;
flex-wrap: wrap;
}
.add-item-details-image-btn {
flex: 1;
min-width: 140px;
padding: 0.8em;
font-size: 0.95em;
border: 2px solid #007bff;
background: white;
color: #007bff;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.add-item-details-image-btn:hover {
background: #007bff;
color: white;
}
.add-item-details-image-preview {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 2px solid #e0e0e0;
}
.add-item-details-image-preview img {
width: 100%;
height: auto;
display: block;
max-height: 300px;
object-fit: contain;
}
.add-item-details-remove-image {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
border-radius: 6px;
padding: 0.4em 0.8em;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: background 0.2s;
}
.add-item-details-remove-image:hover {
background: rgba(220, 53, 69, 1);
}
/* Classification Section */
.add-item-details-field {
margin-bottom: 1em;
}
.add-item-details-field label {
display: block;
margin-bottom: 0.4em;
font-weight: 600;
color: #333;
font-size: 0.9em;
}
.add-item-details-select {
width: 100%;
padding: 0.6em;
font-size: 1em;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.2s;
background: white;
}
.add-item-details-select:focus {
outline: none;
border-color: #007bff;
}
/* Actions */
.add-item-details-actions {
display: flex;
gap: 0.6em;
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid #e0e0e0;
}
.add-item-details-btn {
flex: 1;
padding: 0.7em;
font-size: 1em;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.add-item-details-btn.cancel {
background: #6c757d;
color: white;
}
.add-item-details-btn.cancel:hover {
background: #5a6268;
}
.add-item-details-btn.skip {
background: #ffc107;
color: #333;
}
.add-item-details-btn.skip:hover {
background: #e0a800;
}
.add-item-details-btn.confirm {
background: #007bff;
color: white;
}
.add-item-details-btn.confirm:hover {
background: #0056b3;
}
/* Mobile responsiveness */
@media (max-width: 480px) {
.add-item-details-modal {
padding: 1.2em;
}
.add-item-details-title {
font-size: 1.2em;
}
.add-item-details-image-options {
flex-direction: column;
}
.add-item-details-image-btn {
min-width: 100%;
}
.add-item-details-actions {
flex-direction: column;
}
}

View File

@ -0,0 +1,44 @@
/* Classification Section */
.classification-section {
margin-bottom: 1.5rem;
}
.classification-title {
font-size: 1em;
font-weight: 600;
margin-bottom: 0.8rem;
color: #333;
}
.classification-field {
margin-bottom: 1rem;
}
.classification-field label {
display: block;
font-size: 0.9em;
font-weight: 500;
margin-bottom: 0.4rem;
color: #555;
}
.classification-select {
width: 100%;
padding: 0.6rem;
font-size: 1em;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
transition: border-color 0.2s;
}
.classification-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
.classification-select:hover {
border-color: #999;
}

View File

@ -0,0 +1,112 @@
.edit-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1em;
}
.edit-modal-content {
background: white;
border-radius: 12px;
padding: 1.5em;
max-width: 480px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.edit-modal-title {
font-size: 1.5em;
margin: 0 0 1em 0;
text-align: center;
color: #333;
}
.edit-modal-subtitle {
font-size: 1.1em;
margin: 0.5em 0 0.8em 0;
color: #555;
}
.edit-modal-field {
margin-bottom: 1em;
}
.edit-modal-field label {
display: block;
margin-bottom: 0.3em;
font-weight: 600;
color: #333;
font-size: 0.95em;
}
.edit-modal-input,
.edit-modal-select {
width: 100%;
padding: 0.6em;
font-size: 1em;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.edit-modal-input:focus,
.edit-modal-select:focus {
outline: none;
border-color: #007bff;
}
.edit-modal-divider {
height: 1px;
background: #e0e0e0;
margin: 1.5em 0;
}
.edit-modal-actions {
display: flex;
gap: 0.8em;
margin-top: 1.5em;
}
.edit-modal-btn {
flex: 1;
padding: 0.7em;
font-size: 1em;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.edit-modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.edit-modal-btn-cancel {
background: #6c757d;
color: white;
}
.edit-modal-btn-cancel:hover:not(:disabled) {
background: #5a6268;
}
.edit-modal-btn-save {
background: #007bff;
color: white;
}
.edit-modal-btn-save:hover:not(:disabled) {
background: #0056b3;
}

View File

@ -0,0 +1,86 @@
/* Image Upload Section */
.image-upload-section {
margin-bottom: 1.5rem;
}
.image-upload-title {
font-size: 1em;
font-weight: 600;
margin-bottom: 0.8rem;
color: #333;
}
.image-upload-content {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 1rem;
background: #f9f9f9;
}
.image-upload-options {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.image-upload-btn {
padding: 0.8rem 1rem;
font-size: 1em;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.image-upload-btn.camera {
background: #007bff;
color: white;
}
.image-upload-btn.camera:hover {
background: #0056b3;
}
.image-upload-btn.gallery {
background: #6c757d;
color: white;
}
.image-upload-btn.gallery:hover {
background: #545b62;
}
.image-upload-preview {
position: relative;
max-width: 300px;
margin: 0 auto;
}
.image-upload-preview img {
width: 100%;
border-radius: 8px;
display: block;
}
.image-upload-remove {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
font-size: 1.2em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.image-upload-remove:hover {
background: rgba(255, 0, 0, 1);
}

View File

@ -1,58 +1,58 @@
.navbar {
background: #343a40;
color: white;
padding: 0.6em 1em;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
margin-bottom: 1em;
}
.navbar-links a {
color: white;
margin-right: 1em;
text-decoration: none;
font-size: 1.1em;
}
.navbar-links a:hover {
text-decoration: underline;
}
.navbar-logout {
background: #dc3545;
color: white;
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
width: 100px;
}
.navbar-idcard {
display: flex;
align-items: center;
align-content: center;
margin-right: 1em;
padding: 0.3em 0.6em;
background: #495057;
border-radius: 4px;
color: white;
}
.navbar-idinfo {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.navbar-username {
font-size: 0.95em;
font-weight: bold;
}
.navbar-role {
font-size: 0.75em;
opacity: 0.8;
}
.navbar {
background: #343a40;
color: white;
padding: 0.6em 1em;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
margin-bottom: 1em;
}
.navbar-links a {
color: white;
margin-right: 1em;
text-decoration: none;
font-size: 1.1em;
}
.navbar-links a:hover {
text-decoration: underline;
}
.navbar-logout {
background: #dc3545;
color: white;
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
width: 100px;
}
.navbar-idcard {
display: flex;
align-items: center;
align-content: center;
margin-right: 1em;
padding: 0.3em 0.6em;
background: #495057;
border-radius: 4px;
color: white;
}
.navbar-idinfo {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.navbar-username {
font-size: 0.95em;
font-weight: bold;
}
.navbar-role {
font-size: 0.75em;
opacity: 0.8;
}

View File

@ -0,0 +1,323 @@
/* Container */
.glist-body {
font-family: var(--font-family-base);
padding: var(--spacing-sm);
background: var(--color-bg-body);
}
.glist-container {
max-width: var(--container-max-width);
margin: auto;
background: var(--color-bg-surface);
padding: var(--spacing-sm);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-card);
}
/* Title */
.glist-title {
text-align: center;
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-sm);
}
.glist-section-title {
text-align: center;
font-size: var(--font-size-xl);
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-sm);
color: var(--color-gray-700);
border-top: var(--border-width-medium) solid var(--color-border-light);
padding-top: var(--spacing-md);
}
/* Classification Groups */
.glist-classification-group {
margin-bottom: var(--spacing-xl);
}
.glist-classification-header {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-primary-light);
border-left: var(--border-width-thick) solid var(--color-primary);
border-radius: var(--border-radius-sm);
}
/* Inputs */
.glist-input {
font-size: 1em;
padding: 0.5em;
margin: 0.3em 0;
width: 100%;
box-sizing: border-box;
}
/* Buttons */
.glist-btn {
font-size: var(--font-size-base);
padding: var(--button-padding-y);
width: 100%;
margin-top: var(--spacing-sm);
cursor: pointer;
border: none;
background: var(--color-primary);
color: var(--color-text-inverse);
border-radius: var(--button-border-radius);
font-weight: var(--button-font-weight);
transition: var(--transition-base);
}
.glist-btn:hover {
background: var(--color-primary-dark);
}
.glist-show-more-btn {
font-size: var(--font-size-sm);
padding: var(--spacing-sm) var(--spacing-lg);
cursor: pointer;
border: var(--border-width-thin) solid var(--color-primary);
background: var(--color-bg-surface);
color: var(--color-primary);
border-radius: var(--button-border-radius);
transition: var(--transition-base);
font-weight: var(--button-font-weight);
}
.glist-show-more-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
}
/* Suggestion dropdown */
.glist-suggest-box {
background: #fff;
border: 1px solid #ccc;
max-height: 150px;
overflow-y: auto;
position: absolute;
z-index: 999;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: 1em;
width: calc(100% - 8em);
max-width: 440px;
margin: 0 auto;
}
.glist-suggest-item {
padding: 0.5em;
padding-inline: 2em;
cursor: pointer;
}
.glist-suggest-item:hover {
background: #eee;
}
/* Grocery list items */
.glist-ul {
list-style: none;
padding: 0;
margin-top: 1em;
}
.glist-li {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 0.8em;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
overflow: hidden;
}
.glist-li:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.glist-item-layout {
display: flex;
gap: 1em;
padding: 0em;
align-items: center;
}
.glist-item-image {
width: 50px;
height: 50px;
min-width: 50px;
background: #f5f5f5;
border: 2px solid #e0e0e0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
color: #ccc;
overflow: hidden;
position: relative;
}
.glist-item-image.has-image {
border-color: #007bff;
background: #fff;
}
.glist-item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.glist-item-image.has-image:hover {
opacity: 0.8;
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
}
.glist-item-content {
display: flex;
flex-direction: column;
gap: 0.4em;
flex: 1;
min-width: 0;
}
.glist-item-header {
display: flex;
align-items: baseline;
gap: 0.5em;
flex-wrap: wrap;
}
.glist-item-name {
font-weight: 800;
font-size: 0.8em;
color: #333;
}
.glist-item-quantity {
position: absolute;
top: 0;
right: 0;
background: rgba(0, 123, 255, 0.9);
color: white;
font-weight: 700;
font-size: 0.3em;
padding: 0.2em 0.4em;
border-radius: 0 6px 0 4px;
min-width: 20%;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.glist-item-users {
font-size: 0.7em;
color: #888;
font-style: italic;
}
/* Sorting dropdown */
.glist-sort {
width: 100%;
margin: 0.3em 0;
padding: 0.5em;
font-size: 1em;
border-radius: 4px;
}
/* Image upload */
.glist-image-upload {
margin: 0.5em 0;
}
.glist-image-label {
display: block;
padding: 0.6em;
background: #f0f0f0;
border: 2px dashed #ccc;
border-radius: 4px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.glist-image-label:hover {
background: #e8e8e8;
border-color: #007bff;
}
.glist-image-preview {
position: relative;
margin-top: 0.5em;
display: inline-block;
}
.glist-image-preview img {
max-width: 150px;
max-height: 150px;
border-radius: 8px;
border: 2px solid #ddd;
}
.glist-remove-image {
position: absolute;
top: -8px;
right: -8px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #ff4444;
color: white;
border: 2px solid white;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.glist-remove-image:hover {
background: #cc0000;
}
/* Floating Action Button (FAB) */
.glist-fab {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 50%;
width: 62px;
height: 62px;
font-size: 2em;
line-height: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
cursor: pointer;
}
.glist-fab:hover {
background: #218838;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.glist-container {
padding: 1em 0.8em;
}
.glist-fab {
bottom: 16px;
right: 16px;
}
}

View File

@ -33,6 +33,40 @@
border: 1px solid #ccc;
}
.login-password-wrapper {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0.4em 0;
}
.login-password-wrapper .login-input {
flex: 1;
width: auto;
margin: 0;
}
.login-password-toggle {
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 1.2em;
padding: 0.4em;
line-height: 1;
opacity: 0.8;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
}
.login-password-toggle:hover {
opacity: 1;
background: #e8e8e8;
}
.login-button {
width: 100%;
padding: 0.7em;

View File

@ -0,0 +1,270 @@
/**
* Global Theme Variables
*
* This file defines the design system for the entire application.
* All colors, spacing, typography, and other design tokens are centralized here.
*
* Usage: var(--variable-name)
* Example: color: var(--color-primary);
*/
:root {
/* ============================================
COLOR PALETTE
============================================ */
/* Primary Colors */
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-primary-light: #e7f3ff;
--color-primary-dark: #0067d8;
/* Secondary Colors */
--color-secondary: #6c757d;
--color-secondary-hover: #545b62;
--color-secondary-light: #f8f9fa;
/* Semantic Colors */
--color-success: #28a745;
--color-success-hover: #218838;
--color-success-light: #d4edda;
--color-danger: #dc3545;
--color-danger-hover: #c82333;
--color-danger-light: #f8d7da;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-warning-light: #fff3cd;
--color-info: #17a2b8;
--color-info-hover: #138496;
--color-info-light: #d1ecf1;
/* Neutral Colors */
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9f9f9;
--color-gray-100: #f8f9fa;
--color-gray-200: #e9ecef;
--color-gray-300: #dee2e6;
--color-gray-400: #ced4da;
--color-gray-500: #adb5bd;
--color-gray-600: #6c757d;
--color-gray-700: #495057;
--color-gray-800: #343a40;
--color-gray-900: #212529;
/* Text Colors */
--color-text-primary: #212529;
--color-text-secondary: #6c757d;
--color-text-muted: #adb5bd;
--color-text-inverse: #ffffff;
--color-text-disabled: #6c757d;
/* Background Colors */
--color-bg-body: #f8f9fa;
--color-bg-surface: #ffffff;
--color-bg-hover: #f5f5f5;
--color-bg-disabled: #e9ecef;
/* Border Colors */
--color-border-light: #e0e0e0;
--color-border-medium: #ccc;
--color-border-dark: #999;
--color-border-disabled: #dee2e6;
/* ============================================
SPACING
============================================ */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--spacing-2xl: 3rem; /* 48px */
--spacing-3xl: 4rem; /* 64px */
/* ============================================
TYPOGRAPHY
============================================ */
--font-family-base: Arial, sans-serif;
--font-family-heading: Arial, sans-serif;
--font-family-mono: 'Courier New', monospace;
/* Font Sizes */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 2rem; /* 32px */
/* Font Weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--border-width-thin: 1px;
--border-width-medium: 2px;
--border-width-thick: 4px;
--border-radius-sm: 4px;
--border-radius-md: 6px;
--border-radius-lg: 8px;
--border-radius-xl: 12px;
--border-radius-full: 50%;
/* ============================================
SHADOWS
============================================ */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.08);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
/* ============================================
Z-INDEX LAYERS
============================================ */
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 900;
--z-modal: 1000;
--z-tooltip: 1100;
/* ============================================
LAYOUT
============================================ */
--container-max-width: 480px;
--container-padding: var(--spacing-md);
/* ============================================
COMPONENT-SPECIFIC
============================================ */
/* Buttons */
--button-padding-y: 0.6rem;
--button-padding-x: 1.5rem;
--button-border-radius: var(--border-radius-sm);
--button-font-weight: var(--font-weight-medium);
/* Inputs */
--input-padding-y: 0.6rem;
--input-padding-x: 0.75rem;
--input-border-color: var(--color-border-medium);
--input-border-radius: var(--border-radius-sm);
--input-focus-border-color: var(--color-primary);
--input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
/* Cards */
--card-bg: var(--color-bg-surface);
--card-padding: var(--spacing-md);
--card-border-radius: var(--border-radius-lg);
--card-shadow: var(--shadow-card);
/* Modals */
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
--modal-bg: var(--color-white);
--modal-border-radius: var(--border-radius-lg);
--modal-padding: var(--spacing-lg);
--modal-max-width: 500px;
}
/* ============================================
DARK MODE SUPPORT (Future Implementation)
============================================ */
@media (prefers-color-scheme: dark) {
/* Uncomment to enable dark mode
:root {
--color-text-primary: #f8f9fa;
--color-text-secondary: #adb5bd;
--color-bg-body: #212529;
--color-bg-surface: #343a40;
--color-border-light: #495057;
--color-border-medium: #6c757d;
}
*/
}
/* Manual dark mode class override */
.dark-mode {
--color-text-primary: #f8f9fa;
--color-text-secondary: #adb5bd;
--color-bg-body: #212529;
--color-bg-surface: #343a40;
--color-border-light: #495057;
--color-border-medium: #6c757d;
}
/* ============================================
UTILITY CLASSES
============================================ */
/* Spacing Utilities */
.m-0 { margin: 0 !important; }
.mt-1 { margin-top: var(--spacing-xs) !important; }
.mt-2 { margin-top: var(--spacing-sm) !important; }
.mt-3 { margin-top: var(--spacing-md) !important; }
.mt-4 { margin-top: var(--spacing-lg) !important; }
.mb-1 { margin-bottom: var(--spacing-xs) !important; }
.mb-2 { margin-bottom: var(--spacing-sm) !important; }
.mb-3 { margin-bottom: var(--spacing-md) !important; }
.mb-4 { margin-bottom: var(--spacing-lg) !important; }
.p-0 { padding: 0 !important; }
.p-1 { padding: var(--spacing-xs) !important; }
.p-2 { padding: var(--spacing-sm) !important; }
.p-3 { padding: var(--spacing-md) !important; }
.p-4 { padding: var(--spacing-lg) !important; }
/* Text Utilities */
.text-center { text-align: center !important; }
.text-left { text-align: left !important; }
.text-right { text-align: right !important; }
.text-primary { color: var(--color-primary) !important; }
.text-secondary { color: var(--color-text-secondary) !important; }
.text-muted { color: var(--color-text-muted) !important; }
.text-danger { color: var(--color-danger) !important; }
.text-success { color: var(--color-success) !important; }
.font-weight-normal { font-weight: var(--font-weight-normal) !important; }
.font-weight-medium { font-weight: var(--font-weight-medium) !important; }
.font-weight-semibold { font-weight: var(--font-weight-semibold) !important; }
.font-weight-bold { font-weight: var(--font-weight-bold) !important; }
/* Display Utilities */
.d-none { display: none !important; }
.d-block { display: block !important; }
.d-flex { display: flex !important; }
.d-inline-block { display: inline-block !important; }
/* Flex Utilities */
.flex-column { flex-direction: column !important; }
.flex-row { flex-direction: row !important; }
.justify-center { justify-content: center !important; }
.justify-between { justify-content: space-between !important; }
.align-center { align-items: center !important; }
.gap-1 { gap: var(--spacing-xs) !important; }
.gap-2 { gap: var(--spacing-sm) !important; }
.gap-3 { gap: var(--spacing-md) !important; }

View File

@ -0,0 +1,68 @@
/**
* Calculate Levenshtein distance between two strings
* @param {string} str1 - First string
* @param {string} str2 - Second string
* @returns {number} - Edit distance
*/
function levenshteinDistance(str1, str2) {
const len1 = str1.length;
const len2 = str2.length;
const matrix = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[len1][len2];
}
/**
* Calculate similarity percentage between two strings (0-100%)
* @param {string} str1 - First string
* @param {string} str2 - Second string
* @returns {number} - Similarity percentage
*/
export function calculateSimilarity(str1, str2) {
const lower1 = str1.toLowerCase().trim();
const lower2 = str2.toLowerCase().trim();
if (lower1 === lower2) return 100;
if (lower1.length === 0 || lower2.length === 0) return 0;
const distance = levenshteinDistance(lower1, lower2);
const maxLength = Math.max(lower1.length, lower2.length);
const similarity = ((maxLength - distance) / maxLength) * 100;
return Math.round(similarity);
}
/**
* Find items with similarity >= threshold
* @param {string} inputName - Item name to check
* @param {Array} existingItems - Array of existing items with item_name property
* @param {number} threshold - Minimum similarity percentage (default 80)
* @returns {Array} - Array of similar items sorted by similarity
*/
export function findSimilarItems(inputName, existingItems, threshold = 80) {
const similar = [];
for (const item of existingItems) {
const similarity = calculateSimilarity(inputName, item.item_name);
if (similarity >= threshold && similarity < 100) {
similar.push({ ...item, similarity });
}
}
// Sort by similarity descending
return similar.sort((a, b) => b.similarity - a.similarity);
}

9
package-lock.json generated
View File

@ -1,9 +1,10 @@
{
"name": "costco-grocery-list",
"name": "Costco-Grocery-List",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Costco-Grocery-List",
"devDependencies": {
"cross-env": "^10.1.0",
"jest": "^30.2.0",
@ -3745,9 +3746,9 @@
]
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"dependencies": {
"side-channel": "^1.1.0"