From 3073403f5899492a5352f4af7634a57bbe9a17bd Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 1 Jan 2026 22:55:39 -0800 Subject: [PATCH 1/7] wip --- .copilotignore | 91 +++ .github/copilot-instructions.md | 197 ++++++ IMAGE_STORAGE_IMPLEMENTATION.md | 110 +++ backend/controllers/lists.controller.js | 32 +- backend/middleware/image.js | 48 ++ backend/migrations/add_image_columns.sql | 20 + backend/models/list.model.js | 62 +- backend/package-lock.json | 636 +++++++++++++++++- backend/package.json | 4 +- backend/routes/list.routes.js | 4 +- dev-rebuild.sh | 8 + docker-compose.dev.yml | 8 +- frontend/src/api/list.js | 34 +- frontend/src/components/AddImageModal.jsx | 99 +++ frontend/src/components/AddItemForm.jsx | 60 ++ frontend/src/components/ConfirmBuyModal.jsx | 67 ++ frontend/src/components/ErrorMessage.jsx | 7 + .../src/components/FloatingActionButton.jsx | 7 + frontend/src/components/FormInput.jsx | 21 + frontend/src/components/GroceryListItem.jsx | 100 +++ frontend/src/components/ImageModal.jsx | 27 + frontend/src/components/ImageUploadModal.jsx | 102 +++ frontend/src/components/SimilarItemModal.jsx | 29 + frontend/src/components/SortDropdown.jsx | 10 + frontend/src/components/UserRoleCard.jsx | 21 + frontend/src/pages/AdminPanel.jsx | 28 +- frontend/src/pages/GroceryList.jsx | 281 +++++--- frontend/src/pages/Login.jsx | 29 +- frontend/src/pages/Register.jsx | 44 +- frontend/src/styles/AddImageModal.css | 176 +++++ frontend/src/styles/ConfirmBuyModal.css | 158 +++++ frontend/src/styles/GroceryList.css | 150 ++++- frontend/src/styles/ImageModal.css | 99 +++ frontend/src/styles/ImageUploadModal.css | 203 ++++++ frontend/src/styles/Login.css | 34 + frontend/src/styles/SimilarItemModal.css | 115 ++++ frontend/src/styles/UserRoleCard.css | 40 ++ frontend/src/utils/stringSimilarity.js | 68 ++ package-lock.json | 9 +- 39 files changed, 3043 insertions(+), 195 deletions(-) create mode 100644 .copilotignore create mode 100644 .github/copilot-instructions.md create mode 100644 IMAGE_STORAGE_IMPLEMENTATION.md create mode 100644 backend/middleware/image.js create mode 100644 backend/migrations/add_image_columns.sql create mode 100644 dev-rebuild.sh create mode 100644 frontend/src/components/AddImageModal.jsx create mode 100644 frontend/src/components/AddItemForm.jsx create mode 100644 frontend/src/components/ConfirmBuyModal.jsx create mode 100644 frontend/src/components/ErrorMessage.jsx create mode 100644 frontend/src/components/FloatingActionButton.jsx create mode 100644 frontend/src/components/FormInput.jsx create mode 100644 frontend/src/components/GroceryListItem.jsx create mode 100644 frontend/src/components/ImageModal.jsx create mode 100644 frontend/src/components/ImageUploadModal.jsx create mode 100644 frontend/src/components/SimilarItemModal.jsx create mode 100644 frontend/src/components/SortDropdown.jsx create mode 100644 frontend/src/components/UserRoleCard.jsx create mode 100644 frontend/src/styles/AddImageModal.css create mode 100644 frontend/src/styles/ConfirmBuyModal.css create mode 100644 frontend/src/styles/ImageModal.css create mode 100644 frontend/src/styles/ImageUploadModal.css create mode 100644 frontend/src/styles/SimilarItemModal.css create mode 100644 frontend/src/styles/UserRoleCard.css create mode 100644 frontend/src/utils/stringSimilarity.js diff --git a/.copilotignore b/.copilotignore new file mode 100644 index 0000000..ec6897f --- /dev/null +++ b/.copilotignore @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..996b319 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 ` 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**: +- ``: Requires authentication, redirects to `/login` if no token +- ``: 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 `:` +3. Build frontend image with tags: `:latest` and `:` +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 ` 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 `` 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 `` and/or `` + +**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 diff --git a/IMAGE_STORAGE_IMPLEMENTATION.md b/IMAGE_STORAGE_IMPLEMENTATION.md new file mode 100644 index 0000000..7c99fc7 --- /dev/null +++ b/IMAGE_STORAGE_IMPLEMENTATION.md @@ -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) diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index 3667c2d..ad109f2 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -15,17 +15,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 +40,22 @@ exports.getSuggestions = async (req, res) => { const { query } = req.query || ""; const suggestions = await List.getSuggestions(query); res.json(suggestions); +}; + +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" }); }; \ No newline at end of file diff --git a/backend/middleware/image.js b/backend/middleware/image.js new file mode 100644 index 0000000..522ceee --- /dev/null +++ b/backend/middleware/image.js @@ -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 }; diff --git a/backend/migrations/add_image_columns.sql b/backend/migrations/add_image_columns.sql new file mode 100644 index 0000000..6f5a061 --- /dev/null +++ b/backend/migrations/add_image_columns.sql @@ -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`. diff --git a/backend/models/list.model.js b/backend/models/list.model.js index 328b383..ebd4d71 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -3,7 +3,21 @@ 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, + gl.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 + 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 = FALSE + GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type + ORDER BY gl.id ASC` ); return result.rows; }; @@ -18,43 +32,57 @@ 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 + WHERE id = $2`, + [quantity, result.rows[0].id, imageBuffer, mimeType] + ); + } else { + await pool.query( + `UPDATE grocery_list + SET quantity = $1, + bought = FALSE + 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) => { +exports.setBought = async (id, userId) => { await pool.query("UPDATE grocery_list SET bought = TRUE 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] ); }; diff --git a/backend/package-lock.json b/backend/package-lock.json index f25dc1b..bd00905 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index bb5ca49..5ba58c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index 63e6026..a9f65e1 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -4,6 +4,7 @@ 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"); @@ -12,7 +13,8 @@ router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controll router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions); -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); diff --git a/dev-rebuild.sh b/dev-rebuild.sh new file mode 100644 index 0000000..8bab805 --- /dev/null +++ b/dev-rebuild.sh @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de6750f..5982a4b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index 675a82e..e48e575 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -2,6 +2,36 @@ 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 markBought = (id) => api.post("/list/mark-bought", { id }); -export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); \ No newline at end of file +export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); + +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", + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/components/AddImageModal.jsx b/frontend/src/components/AddImageModal.jsx new file mode 100644 index 0000000..39ab266 --- /dev/null +++ b/frontend/src/components/AddImageModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +

Add Image

+

+ There's no image for "{itemName}" yet. Add a new image? +

+ + {!imagePreview ? ( +
+ + +
+ ) : ( +
+
+ Preview + +
+
+ )} + + + + + +
+ + {imagePreview && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/AddItemForm.jsx b/frontend/src/components/AddItemForm.jsx new file mode 100644 index 0000000..10e76a7 --- /dev/null +++ b/frontend/src/components/AddItemForm.jsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import SuggestionList from "./SuggestionList"; + +export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) { + 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); + }; + + return ( +
+ handleInputChange(e.target.value)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + onClick={() => setShowSuggestions(true)} + /> + + {showSuggestions && suggestions.length > 0 && ( + + )} + + setQuantity(Number(e.target.value))} + /> + + + + ); +} diff --git a/frontend/src/components/ConfirmBuyModal.jsx b/frontend/src/components/ConfirmBuyModal.jsx new file mode 100644 index 0000000..3e9c7f6 --- /dev/null +++ b/frontend/src/components/ConfirmBuyModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +

Mark as Bought

+

"{item.item_name}"

+ +
+

Quantity to buy:

+
+ + + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ErrorMessage.jsx b/frontend/src/components/ErrorMessage.jsx new file mode 100644 index 0000000..00e981b --- /dev/null +++ b/frontend/src/components/ErrorMessage.jsx @@ -0,0 +1,7 @@ +export default function ErrorMessage({ message, type = "error" }) { + if (!message) return null; + + const className = type === "success" ? "success-message" : "error-message"; + + return

{message}

; +} diff --git a/frontend/src/components/FloatingActionButton.jsx b/frontend/src/components/FloatingActionButton.jsx new file mode 100644 index 0000000..7f5d359 --- /dev/null +++ b/frontend/src/components/FloatingActionButton.jsx @@ -0,0 +1,7 @@ +export default function FloatingActionButton({ isOpen, onClick }) { + return ( + + ); +} diff --git a/frontend/src/components/FormInput.jsx b/frontend/src/components/FormInput.jsx new file mode 100644 index 0000000..8081902 --- /dev/null +++ b/frontend/src/components/FormInput.jsx @@ -0,0 +1,21 @@ +export default function FormInput({ + type = "text", + placeholder, + value, + onChange, + onKeyUp, + required = false, + className = "", +}) { + return ( + + ); +} diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/GroceryListItem.jsx new file mode 100644 index 0000000..537217f --- /dev/null +++ b/frontend/src/components/GroceryListItem.jsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import AddImageModal from "./AddImageModal"; +import ConfirmBuyModal from "./ConfirmBuyModal"; +import ImageModal from "./ImageModal"; + +export default function GroceryListItem({ item, onClick, onImageAdded }) { + const [showModal, setShowModal] = useState(false); + const [showAddImageModal, setShowAddImageModal] = useState(false); + const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false); + + const handleItemClick = () => { + 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; + + return ( + <> +
  • +
    +
    + {item.item_image ? ( + {item.item_name} + ) : ( + 📦 + )} + x{item.quantity} +
    +
    +
    + {item.item_name} +
    + {item.added_by_users && item.added_by_users.length > 0 && ( +
    + {item.added_by_users.join(", ")} +
    + )} +
    +
    +
  • + + {showModal && ( + setShowModal(false)} + /> + )} + + {showAddImageModal && ( + setShowAddImageModal(false)} + onAddImage={handleAddImage} + /> + )} + + {showConfirmBuyModal && ( + + )} + + ); +} diff --git a/frontend/src/components/ImageModal.jsx b/frontend/src/components/ImageModal.jsx new file mode 100644 index 0000000..913dd42 --- /dev/null +++ b/frontend/src/components/ImageModal.jsx @@ -0,0 +1,27 @@ +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 ( +
    +
    e.stopPropagation()}> + + {itemName} +

    {itemName}

    +
    +
    + ); +} diff --git a/frontend/src/components/ImageUploadModal.jsx b/frontend/src/components/ImageUploadModal.jsx new file mode 100644 index 0000000..99c7ca9 --- /dev/null +++ b/frontend/src/components/ImageUploadModal.jsx @@ -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 ( +
    +
    e.stopPropagation()}> +

    Add Image for "{itemName}"

    +

    This is a new item. Would you like to add a verification image?

    + +
    + {!imagePreview ? ( +
    + + +
    + ) : ( +
    + Preview + +
    + )} +
    + + + + + +
    + + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/SimilarItemModal.jsx b/frontend/src/components/SimilarItemModal.jsx new file mode 100644 index 0000000..2c4ed71 --- /dev/null +++ b/frontend/src/components/SimilarItemModal.jsx @@ -0,0 +1,29 @@ +import "../styles/SimilarItemModal.css"; + +export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) { + return ( +
    +
    e.stopPropagation()}> +

    Similar Item Found

    +

    + Do you mean "{suggestedName}"? +

    +

    + You entered: "{originalName}" +

    + +
    + + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/SortDropdown.jsx b/frontend/src/components/SortDropdown.jsx new file mode 100644 index 0000000..65f171e --- /dev/null +++ b/frontend/src/components/SortDropdown.jsx @@ -0,0 +1,10 @@ +export default function SortDropdown({ value, onChange }) { + return ( + + ); +} diff --git a/frontend/src/components/UserRoleCard.jsx b/frontend/src/components/UserRoleCard.jsx new file mode 100644 index 0000000..0bb24dd --- /dev/null +++ b/frontend/src/components/UserRoleCard.jsx @@ -0,0 +1,21 @@ +import { ROLES } from "../constants/roles"; + +export default function UserRoleCard({ user, onRoleChange }) { + return ( +
    +
    + {user.name} + @{user.username} +
    + +
    + ); +} diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index 0bbd540..ecbd806 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -1,13 +1,13 @@ import { useEffect, useState } from "react"; import { getAllUsers, updateRole } from "../api/users"; -import { ROLES } from "../constants/roles"; +import UserRoleCard from "../components/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 ( -
    +

    Admin Panel

    - {users.map((user) => ( -
    - {user.username} - {user.role} - -
    - )) - } -
    +
    + {users.map((user) => ( + + ))} +
    +
    ) } \ No newline at end of file diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index e4ae9c0..420c6b5 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,28 +1,35 @@ import { useContext, useEffect, useState } from "react"; -import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list"; +import { addItem, getItemByName, getList, getSuggestions, markBought, updateItemImage } from "../api/list"; +import AddItemForm from "../components/AddItemForm"; +import FloatingActionButton from "../components/FloatingActionButton"; +import GroceryListItem from "../components/GroceryListItem"; +import ImageUploadModal from "../components/ImageUploadModal"; +import SimilarItemModal from "../components/SimilarItemModal"; +import SortDropdown from "../components/SortDropdown"; import { ROLES } from "../constants/roles"; import { AuthContext } from "../context/AuthContext"; import "../styles/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 [sortedItems, setSortedItems] = useState([]); - const [sortMode, setSortMode] = useState("az"); - - const [showSuggestions, setShowSuggestions] = useState(false); - const [itemName, setItemName] = useState(""); - const [quantity, setQuantity] = useState(1); const [suggestions, setSuggestions] = useState([]); - - const [showAddForm, setShowAddForm] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); const [loading, setLoading] = useState(true); + const [buttonText, setButtonText] = useState("Add Item"); + const [pendingItem, setPendingItem] = useState(null); + const [showImageModal, setShowImageModal] = useState(false); + const [showSimilarModal, setShowSimilarModal] = useState(false); + const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); const loadItems = async () => { setLoading(true); const res = await getList(); + console.log(res.data); setItems(res.data); setLoading(false); }; @@ -34,29 +41,38 @@ export default function GroceryList() { useEffect(() => { let sorted = [...items]; - 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 === "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); setSortedItems(sorted); }, [items, sortMode]); const handleSuggest = async (text) => { - setItemName(text); - if (!text.trim()) { setSuggestions([]); + setButtonText("Add Item"); return; } + // Check if exact match exists (case-insensitive) + const lowerText = text.toLowerCase().trim(); + const exactMatch = items.find(item => item.item_name.toLowerCase() === lowerText); + + if (exactMatch) { + setButtonText("Add Item"); + } else { + // Check for similar items (80% match) + const similar = findSimilarItems(text, items, 80); + if (similar.length > 0) { + // Show suggestion in button but allow creation + setButtonText("Create and Add Item"); + } else { + setButtonText("Create and Add Item"); + } + } + try { let suggestions = await getSuggestions(text); suggestions = suggestions.data.map(s => s.item_name); @@ -66,40 +82,126 @@ 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(); + + // Check for 80% similar items + const similar = findSimilarItems(itemName, items, 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 + 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 image upload modal + setPendingItem({ itemName, quantity }); + setShowImageModal(true); } + }; - await addItem(itemName, newQuantity); + const handleSimilarCancel = () => { + setShowSimilarModal(false); + setSimilarItemSuggestion(null); + }; - setItemName(""); - setQuantity(1); + 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 handleImageConfirm = async (imageFile) => { + if (!pendingItem) return; + + await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); + setShowImageModal(false); + setPendingItem(null); setSuggestions([]); - + setButtonText("Add Item"); loadItems(); }; - const handleBought = async (id) => { - const yes = window.confirm("Mark this item as bought?"); - if (!yes) return; + const handleImageSkip = async () => { + if (!pendingItem) return; + await addItem(pendingItem.itemName, pendingItem.quantity, null); + setShowImageModal(false); + setPendingItem(null); + setSuggestions([]); + setButtonText("Add Item"); + loadItems(); + }; + + const handleImageCancel = () => { + setShowImageModal(false); + setPendingItem(null); + setSuggestions([]); + setButtonText("Add Item"); + }; + + const handleBought = async (id, quantity) => { await markBought(id); loadItems(); }; + 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."); + } + }; + if (loading) return

    Loading...

    ; return ( @@ -107,86 +209,57 @@ export default function GroceryList() {

    Costco Grocery List

    - {/* Sorting dropdown */} - + - {/* Add Item form (editor/admin only) */} {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( - <> - handleSuggest(e.target.value)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} - onClick={() => setShowSuggestions(true)} - /> - - {showSuggestions && suggestions.length > 0 && ( -
      - {suggestions.map((s, i) => ( -
    • { - setItemName(s); - setSuggestions([]); - }} - > - {s} -
    • - ))} -
    - )} - - setQuantity(Number(e.target.value))} - /> - - - + )} - {/* Grocery list */}
      {sortedItems.map((item) => ( -
    • - [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id) + item={item} + onClick={(quantity) => + [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity) } - > - {item.item_name} ({item.quantity}) -
    • + onImageAdded={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null + } + /> ))}
    - {/* Floating Button (editor/admin only) */} {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( - + /> + )} + + {showImageModal && pendingItem && ( + + )} + + {showSimilarModal && similarItemSuggestion && ( + )} ); diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 64feb16..7768c49 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,6 +1,8 @@ import { useContext, useState } from "react"; import { Link } from "react-router-dom"; import { loginRequest } from "../api/auth"; +import ErrorMessage from "../components/ErrorMessage"; +import FormInput from "../components/FormInput"; import { AuthContext } from "../context/AuthContext"; import "../styles/Login.css"; @@ -8,6 +10,7 @@ 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() {

    Login

    - {error &&

    {error}

    } +
    - setUsername(e.target.value)} /> - setPassword(e.target.value)} - /> +
    + setPassword(e.target.value)} + /> + +
    diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 3d46424..6f42f9e 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -2,8 +2,9 @@ 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/ErrorMessage"; +import FormInput from "../components/FormInput"; import { AuthContext } from "../context/AuthContext"; - import "../styles/Register.css"; export default function Register() { @@ -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 (

    Register

    - {

    {error}

    } - {success &&

    {success}

    } + +
    - setName(e.target.value)} required /> - setUsername(e.target.value)} required /> - setPassword(e.target.value)} required /> - setConfirm(e.target.value)} diff --git a/frontend/src/styles/AddImageModal.css b/frontend/src/styles/AddImageModal.css new file mode 100644 index 0000000..4a34dc5 --- /dev/null +++ b/frontend/src/styles/AddImageModal.css @@ -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; + } +} diff --git a/frontend/src/styles/ConfirmBuyModal.css b/frontend/src/styles/ConfirmBuyModal.css new file mode 100644 index 0000000..2a4adc9 --- /dev/null +++ b/frontend/src/styles/ConfirmBuyModal.css @@ -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; + } +} diff --git a/frontend/src/styles/GroceryList.css b/frontend/src/styles/GroceryList.css index f168931..59b9abc 100644 --- a/frontend/src/styles/GroceryList.css +++ b/frontend/src/styles/GroceryList.css @@ -81,15 +81,99 @@ } .glist-li { - padding: 0.7em; - background: #e9ecef; - border-radius: 5px; - margin-bottom: 0.6em; + 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 { - background: #dee2e6; + 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: 80px; + height: 80px; + min-width: 80px; + 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: 600; + font-size: 1.1em; + 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.9em; + color: #888; + font-style: italic; } /* Sorting dropdown */ @@ -101,6 +185,62 @@ 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; diff --git a/frontend/src/styles/ImageModal.css b/frontend/src/styles/ImageModal.css new file mode 100644 index 0000000..ae7b414 --- /dev/null +++ b/frontend/src/styles/ImageModal.css @@ -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; + } +} diff --git a/frontend/src/styles/ImageUploadModal.css b/frontend/src/styles/ImageUploadModal.css new file mode 100644 index 0000000..c528556 --- /dev/null +++ b/frontend/src/styles/ImageUploadModal.css @@ -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; + } +} diff --git a/frontend/src/styles/Login.css b/frontend/src/styles/Login.css index 034b067..df01ebc 100644 --- a/frontend/src/styles/Login.css +++ b/frontend/src/styles/Login.css @@ -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; diff --git a/frontend/src/styles/SimilarItemModal.css b/frontend/src/styles/SimilarItemModal.css new file mode 100644 index 0000000..e3930fd --- /dev/null +++ b/frontend/src/styles/SimilarItemModal.css @@ -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; + } +} diff --git a/frontend/src/styles/UserRoleCard.css b/frontend/src/styles/UserRoleCard.css new file mode 100644 index 0000000..eaee92a --- /dev/null +++ b/frontend/src/styles/UserRoleCard.css @@ -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); +} diff --git a/frontend/src/utils/stringSimilarity.js b/frontend/src/utils/stringSimilarity.js new file mode 100644 index 0000000..7ec1216 --- /dev/null +++ b/frontend/src/utils/stringSimilarity.js @@ -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); +} diff --git a/package-lock.json b/package-lock.json index 92ba427..5845a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" -- 2.39.5 From 29f64a13d598ffad7ceb6d97d8cfdfcf88fbb0e5 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 13:55:54 -0800 Subject: [PATCH 2/7] have backend automatically reload with the addition of nodemon.json and within it are watch settings add bought items within grocery list page --- backend/controllers/lists.controller.js | 5 +++ backend/migrations/add_modified_on_column.sql | 8 ++++ backend/models/list.model.js | 41 ++++++++++++++++--- backend/nodemon.json | 16 ++++++++ backend/routes/list.routes.js | 1 + frontend/src/api/list.js | 1 + frontend/src/components/GroceryListItem.jsx | 22 +++++++++- frontend/src/components/ImageModal.jsx | 5 +-- frontend/src/pages/GroceryList.jsx | 31 +++++++++++++- frontend/src/styles/GroceryList.css | 22 +++++++--- 10 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 backend/migrations/add_modified_on_column.sql create mode 100644 backend/nodemon.json diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index ad109f2..da6316b 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -42,6 +42,11 @@ exports.getSuggestions = async (req, res) => { 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; diff --git a/backend/migrations/add_modified_on_column.sql b/backend/migrations/add_modified_on_column.sql new file mode 100644 index 0000000..2034edc --- /dev/null +++ b/backend/migrations/add_modified_on_column.sql @@ -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; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index ebd4d71..effaf11 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -5,18 +5,19 @@ exports.getUnboughtItems = async () => { const result = await pool.query( `SELECT gl.id, - gl.item_name, + 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 + 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 = FALSE - GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type + GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on ORDER BY gl.id ASC` ); return result.rows; @@ -46,7 +47,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, SET quantity = $1, bought = FALSE, item_image = $3, - image_mime_type = $4 + image_mime_type = $4, + modified_on = NOW() WHERE id = $2`, [quantity, result.rows[0].id, imageBuffer, mimeType] ); @@ -54,7 +56,8 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, await pool.query( `UPDATE grocery_list SET quantity = $1, - bought = FALSE + bought = FALSE, + modified_on = NOW() WHERE id = $2`, [quantity, result.rows[0].id] ); @@ -74,7 +77,10 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, exports.setBought = async (id, userId) => { - await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]); + await pool.query( + "UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [id] + ); }; @@ -99,3 +105,26 @@ 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; +}; + diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..dfcea7e --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,16 @@ +{ + "watch": [ + "**/*.js", + ".env" + ], + "ext": "js,json", + "ignore": [ + "node_modules/**", + "dist/**" + ], + "legacyWatch": true, + "verbose": true, + "execMap": { + "js": "node" + } +} \ No newline at end of file diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index a9f65e1..37d336a 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -11,6 +11,7 @@ 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.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index e48e575..b247c75 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -21,6 +21,7 @@ export const addItem = (itemName, quantity, imageFile = null) => { export const markBought = (id) => api.post("/list/mark-bought", { id }); 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(); diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/GroceryListItem.jsx index 537217f..ed8caa3 100644 --- a/frontend/src/components/GroceryListItem.jsx +++ b/frontend/src/components/GroceryListItem.jsx @@ -43,6 +43,25 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) { ? `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 ( <>
  • @@ -65,7 +84,8 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
  • {item.added_by_users && item.added_by_users.length > 0 && (
    - {item.added_by_users.join(", ")} + {item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `} + {item.added_by_users.join(" • ")}
    )}
    diff --git a/frontend/src/components/ImageModal.jsx b/frontend/src/components/ImageModal.jsx index 913dd42..2bb4464 100644 --- a/frontend/src/components/ImageModal.jsx +++ b/frontend/src/components/ImageModal.jsx @@ -15,10 +15,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) { return (
    -
    e.stopPropagation()}> - +
    {itemName}

    {itemName}

    diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 420c6b5..4ec597f 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,5 +1,5 @@ import { useContext, useEffect, useState } from "react"; -import { addItem, getItemByName, getList, getSuggestions, markBought, updateItemImage } from "../api/list"; +import { addItem, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage } from "../api/list"; import AddItemForm from "../components/AddItemForm"; import FloatingActionButton from "../components/FloatingActionButton"; import GroceryListItem from "../components/GroceryListItem"; @@ -15,6 +15,7 @@ export default function GroceryList() { const { role } = useContext(AuthContext); const [items, setItems] = useState([]); + const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [sortedItems, setSortedItems] = useState([]); const [sortMode, setSortMode] = useState("az"); const [suggestions, setSuggestions] = useState([]); @@ -34,8 +35,19 @@ export default function GroceryList() { 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(() => { @@ -190,6 +202,7 @@ export default function GroceryList() { const handleBought = async (id, quantity) => { await markBought(id); loadItems(); + loadRecentlyBought(); }; const handleImageAdded = async (id, itemName, quantity, imageFile) => { @@ -234,6 +247,22 @@ export default function GroceryList() { /> ))} + + {recentlyBoughtItems.length > 0 && ( + <> +

    Recently Bought (Last 24 Hours)

    +
      + {recentlyBoughtItems.map((item) => ( + + ))} +
    + + )}
    {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( diff --git a/frontend/src/styles/GroceryList.css b/frontend/src/styles/GroceryList.css index 59b9abc..803a784 100644 --- a/frontend/src/styles/GroceryList.css +++ b/frontend/src/styles/GroceryList.css @@ -21,6 +21,16 @@ margin-bottom: 0.4em; } +.glist-section-title { + text-align: center; + font-size: 1.2em; + margin-top: 2em; + margin-bottom: 0.5em; + color: #495057; + border-top: 2px solid #e0e0e0; + padding-top: 1em; +} + /* Inputs */ .glist-input { font-size: 1em; @@ -103,9 +113,9 @@ } .glist-item-image { - width: 80px; - height: 80px; - min-width: 80px; + width: 50px; + height: 50px; + min-width: 50px; background: #f5f5f5; border: 2px solid #e0e0e0; border-radius: 8px; @@ -150,8 +160,8 @@ } .glist-item-name { - font-weight: 600; - font-size: 1.1em; + font-weight: 800; + font-size: 0.8em; color: #333; } @@ -171,7 +181,7 @@ } .glist-item-users { - font-size: 0.9em; + font-size: 0.7em; color: #888; font-style: italic; } -- 2.39.5 From d2824f8aebf82eb15b6e75c649a60f92a6bd19fb Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 14:27:39 -0800 Subject: [PATCH 3/7] Apply item classification feature --- CLASSIFICATION_IMPLEMENTATION.md | 336 ++++++++++++++++++ SETUP_CHECKLIST.md | 143 ++++++++ backend/constants/classifications.js | 133 +++++++ backend/controllers/lists.controller.js | 52 +++ .../create_item_classification_table.sql | 29 ++ backend/models/list.model.js | 41 +++ backend/routes/list.routes.js | 3 + frontend/src/api/list.js | 8 + .../components/AddItemWithDetailsModal.jsx | 186 ++++++++++ frontend/src/components/EditItemModal.jsx | 164 +++++++++ frontend/src/components/GroceryListItem.jsx | 62 +++- .../components/ItemClassificationModal.jsx | 110 ++++++ frontend/src/constants/classifications.js | 134 +++++++ frontend/src/pages/GroceryList.jsx | 132 +++++-- .../src/styles/AddItemWithDetailsModal.css | 218 ++++++++++++ frontend/src/styles/EditItemModal.css | 112 ++++++ .../src/styles/ItemClassificationModal.css | 102 ++++++ 17 files changed, 1935 insertions(+), 30 deletions(-) create mode 100644 CLASSIFICATION_IMPLEMENTATION.md create mode 100644 SETUP_CHECKLIST.md create mode 100644 backend/constants/classifications.js create mode 100644 backend/migrations/create_item_classification_table.sql create mode 100644 frontend/src/components/AddItemWithDetailsModal.jsx create mode 100644 frontend/src/components/EditItemModal.jsx create mode 100644 frontend/src/components/ItemClassificationModal.jsx create mode 100644 frontend/src/constants/classifications.js create mode 100644 frontend/src/styles/AddItemWithDetailsModal.css create mode 100644 frontend/src/styles/EditItemModal.css create mode 100644 frontend/src/styles/ItemClassificationModal.css diff --git a/CLASSIFICATION_IMPLEMENTATION.md b/CLASSIFICATION_IMPLEMENTATION.md new file mode 100644 index 0000000..3554d60 --- /dev/null +++ b/CLASSIFICATION_IMPLEMENTATION.md @@ -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. diff --git a/SETUP_CHECKLIST.md b/SETUP_CHECKLIST.md new file mode 100644 index 0000000..e97cb54 --- /dev/null +++ b/SETUP_CHECKLIST.md @@ -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! 🎉 diff --git a/backend/constants/classifications.js b/backend/constants/classifications.js new file mode 100644 index 0000000..eacac75 --- /dev/null +++ b/backend/constants/classifications.js @@ -0,0 +1,133 @@ +// 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", + ], +}; + +const ZONES = [ + "Front Entry", + "Fresh Foods Right", + "Fresh Foods Left", + "Center Aisles", + "Bakery", + "Meat Department", + "Dairy Cooler", + "Freezer Section", + "Back Wall", + "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) => ZONES.includes(zone); + +module.exports = { + ITEM_TYPES, + ITEM_GROUPS, + ZONES, + isValidItemType, + isValidItemGroup, + isValidZone, +}; diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index da6316b..8f7a4a4 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -1,4 +1,5 @@ const List = require("../models/list.model"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); exports.getList = async (req, res) => { @@ -63,4 +64,55 @@ exports.updateItemImage = async (req, res) => { 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" }); + } }; \ No newline at end of file diff --git a/backend/migrations/create_item_classification_table.sql b/backend/migrations/create_item_classification_table.sql new file mode 100644 index 0000000..effc63a --- /dev/null +++ b/backend/migrations/create_item_classification_table.sql @@ -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'; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index effaf11..64a06f6 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -128,3 +128,44 @@ exports.getRecentlyBoughtItems = async () => { 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]; +}; + diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index 37d336a..bcd6063 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -12,11 +12,14 @@ 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), 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; diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index b247c75..22b8ce6 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -18,7 +18,15 @@ export const addItem = (itemName, quantity, imageFile = null) => { }, }); }; +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 getRecentlyBought = () => api.get("/list/recently-bought"); diff --git a/frontend/src/components/AddItemWithDetailsModal.jsx b/frontend/src/components/AddItemWithDetailsModal.jsx new file mode 100644 index 0000000..d078651 --- /dev/null +++ b/frontend/src/components/AddItemWithDetailsModal.jsx @@ -0,0 +1,186 @@ +import { useRef, useState } from "react"; +import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import "../styles/AddItemWithDetailsModal.css"; + +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 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 handleItemTypeChange = (e) => { + const newType = e.target.value; + setItemType(newType); + // Reset item group when type changes + setItemGroup(""); + }; + + 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(); + }; + + const handleCameraClick = () => { + cameraInputRef.current?.click(); + }; + + const handleGalleryClick = () => { + galleryInputRef.current?.click(); + }; + + const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; + + return ( +
    +
    e.stopPropagation()}> +

    Add Details for "{itemName}"

    +

    Add an image and classification to help organize your list

    + + {/* Image Section */} +
    +

    Item Image (Optional)

    +
    + {!imagePreview ? ( +
    + + +
    + ) : ( +
    + Preview + +
    + )} +
    + + + + +
    + + {/* Classification Section */} +
    +

    Item Classification (Optional)

    + +
    + + +
    + + {itemType && ( +
    + + +
    + )} + +
    + + +
    +
    + + {/* Actions */} +
    + + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/EditItemModal.jsx b/frontend/src/components/EditItemModal.jsx new file mode 100644 index 0000000..62a8dcd --- /dev/null +++ b/frontend/src/components/EditItemModal.jsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import "../styles/EditItemModal.css"; + +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 = (e) => { + const newType = e.target.value; + setItemType(newType); + // Reset item group when type changes + setItemGroup(""); + }; + + 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); + } + }; + + const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; + + return ( +
    +
    e.stopPropagation()}> +

    Edit Item

    + +
    + + setItemName(e.target.value)} + className="edit-modal-input" + /> +
    + +
    + + setQuantity(parseInt(e.target.value))} + className="edit-modal-input" + /> +
    + +
    + +

    Item Classification

    + +
    + + +
    + + {itemType && ( +
    + + +
    + )} + +
    + + +
    + +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/GroceryListItem.jsx index ed8caa3..7500436 100644 --- a/frontend/src/components/GroceryListItem.jsx +++ b/frontend/src/components/GroceryListItem.jsx @@ -1,13 +1,60 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import AddImageModal from "./AddImageModal"; import ConfirmBuyModal from "./ConfirmBuyModal"; import ImageModal from "./ImageModal"; -export default function GroceryListItem({ item, onClick, onImageAdded }) { +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 = () => { setShowConfirmBuyModal(true); }; @@ -64,7 +111,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) { return ( <> -
  • +
  • { + 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 ( +
    +
    +

    Classify Item

    +

    Help organize "{itemName}" in your list

    + +
    + + +
    + + {itemType && ( +
    + + +
    + )} + +
    + + +
    + +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/constants/classifications.js b/frontend/src/constants/classifications.js new file mode 100644 index 0000000..60e50a8 --- /dev/null +++ b/frontend/src/constants/classifications.js @@ -0,0 +1,134 @@ +// 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 for Costco layout +export const ZONES = [ + "Front Entry", + "Fresh Foods Right", + "Fresh Foods Left", + "Center Aisles", + "Bakery", + "Meat Department", + "Dairy Cooler", + "Freezer Section", + "Back Wall", + "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; +}; diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 4ec597f..cfe72ba 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,9 +1,10 @@ import { useContext, useEffect, useState } from "react"; -import { addItem, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage } from "../api/list"; +import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list"; import AddItemForm from "../components/AddItemForm"; +import AddItemWithDetailsModal from "../components/AddItemWithDetailsModal"; +import EditItemModal from "../components/EditItemModal"; import FloatingActionButton from "../components/FloatingActionButton"; import GroceryListItem from "../components/GroceryListItem"; -import ImageUploadModal from "../components/ImageUploadModal"; import SimilarItemModal from "../components/SimilarItemModal"; import SortDropdown from "../components/SortDropdown"; import { ROLES } from "../constants/roles"; @@ -23,9 +24,11 @@ export default function GroceryList() { const [loading, setLoading] = useState(true); const [buttonText, setButtonText] = useState("Add Item"); const [pendingItem, setPendingItem] = useState(null); - const [showImageModal, setShowImageModal] = useState(false); + 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); @@ -143,9 +146,9 @@ export default function GroceryList() { setButtonText("Add Item"); loadItems(); } else { - // NEW ITEM - show image upload modal + // NEW ITEM - show combined add details modal setPendingItem({ itemName, quantity }); - setShowImageModal(true); + setShowAddDetailsModal(true); } }; @@ -170,35 +173,58 @@ export default function GroceryList() { setSimilarItemSuggestion(null); }; - const handleImageConfirm = async (imageFile) => { + const handleAddDetailsConfirm = async (imageFile, classification) => { if (!pendingItem) return; - await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); - setShowImageModal(false); - setPendingItem(null); - setSuggestions([]); - setButtonText("Add Item"); - loadItems(); + 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 handleImageSkip = async () => { + const handleAddDetailsSkip = async () => { if (!pendingItem) return; - await addItem(pendingItem.itemName, pendingItem.quantity, null); - setShowImageModal(false); - setPendingItem(null); - setSuggestions([]); - setButtonText("Add Item"); - loadItems(); + 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 handleImageCancel = () => { - setShowImageModal(false); + const handleAddDetailsCancel = () => { + setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); }; + + const handleBought = async (id, quantity) => { await markBought(id); loadItems(); @@ -215,6 +241,42 @@ export default function GroceryList() { } }; + 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); + }; + if (loading) return

    Loading...

    ; return ( @@ -244,6 +306,9 @@ export default function GroceryList() { onImageAdded={ [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null } + onLongPress={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + } /> ))} @@ -257,7 +322,12 @@ export default function GroceryList() { key={item.id} item={item} onClick={null} - onImageAdded={null} + onImageAdded={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null + } + onLongPress={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + } /> ))} @@ -272,12 +342,12 @@ export default function GroceryList() { /> )} - {showImageModal && pendingItem && ( - )} @@ -290,6 +360,14 @@ export default function GroceryList() { onYes={handleSimilarYes} /> )} + + {showEditModal && editingItem && ( + + )}
    ); } diff --git a/frontend/src/styles/AddItemWithDetailsModal.css b/frontend/src/styles/AddItemWithDetailsModal.css new file mode 100644 index 0000000..f503dec --- /dev/null +++ b/frontend/src/styles/AddItemWithDetailsModal.css @@ -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; + } +} diff --git a/frontend/src/styles/EditItemModal.css b/frontend/src/styles/EditItemModal.css new file mode 100644 index 0000000..1f1e2d4 --- /dev/null +++ b/frontend/src/styles/EditItemModal.css @@ -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; +} diff --git a/frontend/src/styles/ItemClassificationModal.css b/frontend/src/styles/ItemClassificationModal.css new file mode 100644 index 0000000..0232ada --- /dev/null +++ b/frontend/src/styles/ItemClassificationModal.css @@ -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; +} -- 2.39.5 From 8894cf21ea2dde52d685f7af77236f6dfa0c3197 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 14:38:11 -0800 Subject: [PATCH 4/7] remove re-marking bought items as bought --- frontend/src/components/GroceryListItem.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/GroceryListItem.jsx index 7500436..6e8c02f 100644 --- a/frontend/src/components/GroceryListItem.jsx +++ b/frontend/src/components/GroceryListItem.jsx @@ -56,7 +56,9 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre }; const handleItemClick = () => { - setShowConfirmBuyModal(true); + if (onClick) { + setShowConfirmBuyModal(true); + } }; const handleConfirmBuy = (quantity) => { -- 2.39.5 From aee9cd3244426158be41b952168c9b4d2cc19744 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 15:33:53 -0800 Subject: [PATCH 5/7] further apply zoning and add ordering by zone feature --- backend/constants/classifications.js | 71 ++++++++++-- backend/models/list.model.js | 8 +- .../components/AddItemWithDetailsModal.jsx | 4 +- frontend/src/components/EditItemModal.jsx | 4 +- frontend/src/components/SortDropdown.jsx | 1 + frontend/src/constants/classifications.js | 71 ++++++++++-- frontend/src/pages/GroceryList.jsx | 106 ++++++++++++++---- frontend/src/styles/GroceryList.css | 16 +++ 8 files changed, 232 insertions(+), 49 deletions(-) diff --git a/backend/constants/classifications.js b/backend/constants/classifications.js index eacac75..f659d76 100644 --- a/backend/constants/classifications.js +++ b/backend/constants/classifications.js @@ -100,17 +100,57 @@ const ITEM_GROUPS = { ], }; -const ZONES = [ - "Front Entry", - "Fresh Foods Right", - "Fresh Foods Left", - "Center Aisles", - "Bakery", - "Meat Department", - "Dairy Cooler", - "Freezer Section", - "Back Wall", - "Checkout Area", +// 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 @@ -121,13 +161,20 @@ const isValidItemGroup = (type, group) => { return ITEM_GROUPS[type]?.includes(group) || false; }; -const isValidZone = (zone) => ZONES.includes(zone); +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, }; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index 64a06f6..d3b1f27 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -11,13 +11,17 @@ exports.getUnboughtItems = async () => { 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 + 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 + 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; diff --git a/frontend/src/components/AddItemWithDetailsModal.jsx b/frontend/src/components/AddItemWithDetailsModal.jsx index d078651..dc42b30 100644 --- a/frontend/src/components/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/AddItemWithDetailsModal.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; import "../styles/AddItemWithDetailsModal.css"; export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { @@ -159,7 +159,7 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o className="add-item-details-select" > - {ZONES.map((z) => ( + {getZoneValues().map((z) => ( diff --git a/frontend/src/components/EditItemModal.jsx b/frontend/src/components/EditItemModal.jsx index 62a8dcd..1f6c113 100644 --- a/frontend/src/components/EditItemModal.jsx +++ b/frontend/src/components/EditItemModal.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; import "../styles/EditItemModal.css"; export default function EditItemModal({ item, onSave, onCancel }) { @@ -134,7 +134,7 @@ export default function EditItemModal({ item, onSave, onCancel }) { className="edit-modal-select" > - {ZONES.map((z) => ( + {getZoneValues().map((z) => ( diff --git a/frontend/src/components/SortDropdown.jsx b/frontend/src/components/SortDropdown.jsx index 65f171e..21e6ad4 100644 --- a/frontend/src/components/SortDropdown.jsx +++ b/frontend/src/components/SortDropdown.jsx @@ -5,6 +5,7 @@ export default function SortDropdown({ value, onChange }) { + ); } diff --git a/frontend/src/constants/classifications.js b/frontend/src/constants/classifications.js index 60e50a8..de63521 100644 --- a/frontend/src/constants/classifications.js +++ b/frontend/src/constants/classifications.js @@ -101,18 +101,57 @@ export const ITEM_GROUPS = { ], }; -// Store zones for Costco layout -export const ZONES = [ - "Front Entry", - "Fresh Foods Right", - "Fresh Foods Left", - "Center Aisles", - "Bakery", - "Meat Department", - "Dairy Cooler", - "Freezer Section", - "Back Wall", - "Checkout Area", +// 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 @@ -132,3 +171,11 @@ export const getItemTypeLabel = (type) => { }; 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; +}; diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index cfe72ba..cd7a947 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -18,7 +18,7 @@ export default function GroceryList() { const [items, setItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [sortedItems, setSortedItems] = useState([]); - const [sortMode, setSortMode] = useState("az"); + const [sortMode, setSortMode] = useState("zone"); const [suggestions, setSuggestions] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [loading, setLoading] = useState(true); @@ -60,6 +60,29 @@ export default function GroceryList() { 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); + + // Sort by item_type + const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); + if (typeCompare !== 0) return typeCompare; + + // Then by item_group + const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); + if (groupCompare !== 0) return groupCompare; + + // 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]); @@ -277,6 +300,19 @@ export default function GroceryList() { 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

    Loading...

    ; return ( @@ -295,27 +331,59 @@ export default function GroceryList() { /> )} -
      - {sortedItems.map((item) => ( - - [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 - } - /> - ))} -
    + {sortMode === "zone" ? ( + // Grouped view by zone + (() => { + const grouped = groupItemsByZone(sortedItems); + return Object.keys(grouped).map(zone => ( +
    +

    + {zone === 'unclassified' ? 'Unclassified' : zone} +

    +
      + {grouped[zone].map((item) => ( + + [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 + } + /> + ))} +
    +
    + )); + })() + ) : ( + // Regular flat list view +
      + {sortedItems.map((item) => ( + + [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 + } + /> + ))} +
    + )} {recentlyBoughtItems.length > 0 && ( <> -

    Recently Bought (Last 24 Hours)

    +

    Recently Bought (24HR)

      {recentlyBoughtItems.map((item) => ( Date: Fri, 2 Jan 2026 15:59:01 -0800 Subject: [PATCH 6/7] major refactor and restructure --- frontend/COMPONENT_STRUCTURE.md | 238 +++++++ frontend/src/App.jsx | 2 +- .../components/AddItemWithDetailsModal.jsx | 186 ------ .../components/{ => common}/ErrorMessage.jsx | 0 .../{ => common}/FloatingActionButton.jsx | 0 .../src/components/{ => common}/FormInput.jsx | 0 .../components/{ => common}/SortDropdown.jsx | 0 .../components/{ => common}/UserRoleCard.jsx | 2 +- frontend/src/components/common/index.js | 7 + .../components/{ => forms}/AddItemForm.jsx | 2 +- .../forms/ClassificationSection.jsx | 91 +++ .../components/forms/ImageUploadSection.jsx | 77 +++ frontend/src/components/forms/index.js | 5 + .../components/{ => items}/GroceryItem.tsx | 46 +- .../{ => items}/GroceryListItem.jsx | 6 +- .../components/{ => items}/SuggestionList.tsx | 80 +-- frontend/src/components/items/index.js | 5 + .../src/components/{ => layout}/AppLayout.jsx | 22 +- .../src/components/{ => layout}/Navbar.jsx | 58 +- frontend/src/components/layout/index.js | 4 + .../components/{ => modals}/AddImageModal.jsx | 2 +- .../modals/AddItemWithDetailsModal.jsx | 96 +++ .../{ => modals}/ConfirmBuyModal.jsx | 2 +- .../components/{ => modals}/EditItemModal.jsx | 73 +- .../components/{ => modals}/ImageModal.jsx | 2 +- .../{ => modals}/ImageUploadModal.jsx | 2 +- .../{ => modals}/ItemClassificationModal.jsx | 2 +- .../{ => modals}/SimilarItemModal.jsx | 2 +- frontend/src/components/modals/index.js | 10 + frontend/src/index.css | 24 +- frontend/src/main.tsx | 1 + frontend/src/move-components.sh | 34 + frontend/src/move-styles.sh | 15 + frontend/src/pages/AdminPanel.jsx | 2 +- frontend/src/pages/GroceryList.jsx | 41 +- frontend/src/pages/Login.jsx | 6 +- frontend/src/pages/Register.jsx | 6 +- frontend/src/styles/THEME_USAGE_EXAMPLES.css | 130 ++++ .../AddItemWithDetailsModal.css | 0 .../components/ClassificationSection.css | 44 ++ .../styles/{ => components}/EditItemModal.css | 0 .../styles/components/ImageUploadSection.css | 86 +++ .../src/styles/{ => components}/Navbar.css | 116 ++-- .../src/styles/{ => pages}/GroceryList.css | 627 +++++++++--------- frontend/src/styles/{ => pages}/Login.css | 0 frontend/src/styles/{ => pages}/Register.css | 0 frontend/src/styles/theme.css | 268 ++++++++ 47 files changed, 1677 insertions(+), 745 deletions(-) create mode 100644 frontend/COMPONENT_STRUCTURE.md delete mode 100644 frontend/src/components/AddItemWithDetailsModal.jsx rename frontend/src/components/{ => common}/ErrorMessage.jsx (100%) rename frontend/src/components/{ => common}/FloatingActionButton.jsx (100%) rename frontend/src/components/{ => common}/FormInput.jsx (100%) rename frontend/src/components/{ => common}/SortDropdown.jsx (100%) rename frontend/src/components/{ => common}/UserRoleCard.jsx (92%) create mode 100644 frontend/src/components/common/index.js rename frontend/src/components/{ => forms}/AddItemForm.jsx (96%) create mode 100644 frontend/src/components/forms/ClassificationSection.jsx create mode 100644 frontend/src/components/forms/ImageUploadSection.jsx create mode 100644 frontend/src/components/forms/index.js rename frontend/src/components/{ => items}/GroceryItem.tsx (95%) rename frontend/src/components/{ => items}/GroceryListItem.jsx (96%) rename frontend/src/components/{ => items}/SuggestionList.tsx (95%) create mode 100644 frontend/src/components/items/index.js rename frontend/src/components/{ => layout}/AppLayout.jsx (94%) rename frontend/src/components/{ => layout}/Navbar.jsx (85%) create mode 100644 frontend/src/components/layout/index.js rename frontend/src/components/{ => modals}/AddImageModal.jsx (98%) create mode 100644 frontend/src/components/modals/AddItemWithDetailsModal.jsx rename frontend/src/components/{ => modals}/ConfirmBuyModal.jsx (97%) rename frontend/src/components/{ => modals}/EditItemModal.jsx (59%) rename frontend/src/components/{ => modals}/ImageModal.jsx (94%) rename frontend/src/components/{ => modals}/ImageUploadModal.jsx (98%) rename frontend/src/components/{ => modals}/ItemClassificationModal.jsx (98%) rename frontend/src/components/{ => modals}/SimilarItemModal.jsx (95%) create mode 100644 frontend/src/components/modals/index.js create mode 100644 frontend/src/move-components.sh create mode 100644 frontend/src/move-styles.sh create mode 100644 frontend/src/styles/THEME_USAGE_EXAMPLES.css rename frontend/src/styles/{ => components}/AddItemWithDetailsModal.css (100%) create mode 100644 frontend/src/styles/components/ClassificationSection.css rename frontend/src/styles/{ => components}/EditItemModal.css (100%) create mode 100644 frontend/src/styles/components/ImageUploadSection.css rename frontend/src/styles/{ => components}/Navbar.css (93%) rename frontend/src/styles/{ => pages}/GroceryList.css (69%) rename frontend/src/styles/{ => pages}/Login.css (100%) rename frontend/src/styles/{ => pages}/Register.css (100%) create mode 100644 frontend/src/styles/theme.css diff --git a/frontend/COMPONENT_STRUCTURE.md b/frontend/COMPONENT_STRUCTURE.md new file mode 100644 index 0000000..4fca22d --- /dev/null +++ b/frontend/COMPONENT_STRUCTURE.md @@ -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 + +
      Content
      + + +Important + + +
      Items
      +``` + +See `styles/THEME_USAGE_EXAMPLES.css` for complete examples of refactoring existing CSS to use theme variables. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 225c2aa..c248c29 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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"; diff --git a/frontend/src/components/AddItemWithDetailsModal.jsx b/frontend/src/components/AddItemWithDetailsModal.jsx deleted file mode 100644 index dc42b30..0000000 --- a/frontend/src/components/AddItemWithDetailsModal.jsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useRef, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; -import "../styles/AddItemWithDetailsModal.css"; - -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 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 handleItemTypeChange = (e) => { - const newType = e.target.value; - setItemType(newType); - // Reset item group when type changes - setItemGroup(""); - }; - - 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(); - }; - - const handleCameraClick = () => { - cameraInputRef.current?.click(); - }; - - const handleGalleryClick = () => { - galleryInputRef.current?.click(); - }; - - const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; - - return ( -
      -
      e.stopPropagation()}> -

      Add Details for "{itemName}"

      -

      Add an image and classification to help organize your list

      - - {/* Image Section */} -
      -

      Item Image (Optional)

      -
      - {!imagePreview ? ( -
      - - -
      - ) : ( -
      - Preview - -
      - )} -
      - - - - -
      - - {/* Classification Section */} -
      -

      Item Classification (Optional)

      - -
      - - -
      - - {itemType && ( -
      - - -
      - )} - -
      - - -
      -
      - - {/* Actions */} -
      - - - -
      -
      -
      - ); -} diff --git a/frontend/src/components/ErrorMessage.jsx b/frontend/src/components/common/ErrorMessage.jsx similarity index 100% rename from frontend/src/components/ErrorMessage.jsx rename to frontend/src/components/common/ErrorMessage.jsx diff --git a/frontend/src/components/FloatingActionButton.jsx b/frontend/src/components/common/FloatingActionButton.jsx similarity index 100% rename from frontend/src/components/FloatingActionButton.jsx rename to frontend/src/components/common/FloatingActionButton.jsx diff --git a/frontend/src/components/FormInput.jsx b/frontend/src/components/common/FormInput.jsx similarity index 100% rename from frontend/src/components/FormInput.jsx rename to frontend/src/components/common/FormInput.jsx diff --git a/frontend/src/components/SortDropdown.jsx b/frontend/src/components/common/SortDropdown.jsx similarity index 100% rename from frontend/src/components/SortDropdown.jsx rename to frontend/src/components/common/SortDropdown.jsx diff --git a/frontend/src/components/UserRoleCard.jsx b/frontend/src/components/common/UserRoleCard.jsx similarity index 92% rename from frontend/src/components/UserRoleCard.jsx rename to frontend/src/components/common/UserRoleCard.jsx index 0bb24dd..9a1405b 100644 --- a/frontend/src/components/UserRoleCard.jsx +++ b/frontend/src/components/common/UserRoleCard.jsx @@ -1,4 +1,4 @@ -import { ROLES } from "../constants/roles"; +import { ROLES } from "../../constants/roles"; export default function UserRoleCard({ user, onRoleChange }) { return ( diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js new file mode 100644 index 0000000..0dc675e --- /dev/null +++ b/frontend/src/components/common/index.js @@ -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'; + diff --git a/frontend/src/components/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx similarity index 96% rename from frontend/src/components/AddItemForm.jsx rename to frontend/src/components/forms/AddItemForm.jsx index 10e76a7..cd26d02 100644 --- a/frontend/src/components/AddItemForm.jsx +++ b/frontend/src/components/forms/AddItemForm.jsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import SuggestionList from "./SuggestionList"; +import SuggestionList from "../items/SuggestionList"; export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) { const [itemName, setItemName] = useState(""); diff --git a/frontend/src/components/forms/ClassificationSection.jsx b/frontend/src/components/forms/ClassificationSection.jsx new file mode 100644 index 0000000..f701dd2 --- /dev/null +++ b/frontend/src/components/forms/ClassificationSection.jsx @@ -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 ( +
      +

      {title}

      + +
      + + +
      + + {itemType && ( +
      + + +
      + )} + +
      + + +
      +
      + ); +} diff --git a/frontend/src/components/forms/ImageUploadSection.jsx b/frontend/src/components/forms/ImageUploadSection.jsx new file mode 100644 index 0000000..1f57a09 --- /dev/null +++ b/frontend/src/components/forms/ImageUploadSection.jsx @@ -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 ( +
      +

      {title}

      +
      + {!imagePreview ? ( +
      + + +
      + ) : ( +
      + Preview + +
      + )} +
      + + + + +
      + ); +} diff --git a/frontend/src/components/forms/index.js b/frontend/src/components/forms/index.js new file mode 100644 index 0000000..129ca8f --- /dev/null +++ b/frontend/src/components/forms/index.js @@ -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'; + diff --git a/frontend/src/components/GroceryItem.tsx b/frontend/src/components/items/GroceryItem.tsx similarity index 95% rename from frontend/src/components/GroceryItem.tsx rename to frontend/src/components/items/GroceryItem.tsx index a352287..7daeba2 100644 --- a/frontend/src/components/GroceryItem.tsx +++ b/frontend/src/components/items/GroceryItem.tsx @@ -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 ( -
    • onClick(item.id)} - style={{ - padding: "0.5em", - background: "#e9ecef", - marginBottom: "0.5em", - borderRadius: "4px", - cursor: "pointer", - }} - > - {item.item_name} ({item.quantity}) -
    • - ); -} +import type { GroceryItemType } from "../types"; + +interface Props { + item: GroceryItemType; + onClick: (id: number) => void; +} + +export default function GroceryItem({ item, onClick }: Props) { + return ( +
    • onClick(item.id)} + style={{ + padding: "0.5em", + background: "#e9ecef", + marginBottom: "0.5em", + borderRadius: "4px", + cursor: "pointer", + }} + > + {item.item_name} ({item.quantity}) +
    • + ); +} diff --git a/frontend/src/components/GroceryListItem.jsx b/frontend/src/components/items/GroceryListItem.jsx similarity index 96% rename from frontend/src/components/GroceryListItem.jsx rename to frontend/src/components/items/GroceryListItem.jsx index 6e8c02f..d2417cb 100644 --- a/frontend/src/components/GroceryListItem.jsx +++ b/frontend/src/components/items/GroceryListItem.jsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; -import AddImageModal from "./AddImageModal"; -import ConfirmBuyModal from "./ConfirmBuyModal"; -import ImageModal from "./ImageModal"; +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); diff --git a/frontend/src/components/SuggestionList.tsx b/frontend/src/components/items/SuggestionList.tsx similarity index 95% rename from frontend/src/components/SuggestionList.tsx rename to frontend/src/components/items/SuggestionList.tsx index 51fd676..fc52eaa 100644 --- a/frontend/src/components/SuggestionList.tsx +++ b/frontend/src/components/items/SuggestionList.tsx @@ -1,40 +1,40 @@ -interface Props { - suggestions: string[]; - onSelect: (value: string) => void; -} - -export default function SuggestionList({ suggestions, onSelect }: Props) { - if (!suggestions.length) return null; - - return ( -
        - {suggestions.map((s) => ( -
      • onSelect(s)} - style={{ - padding: "0.5em", - cursor: "pointer", - borderBottom: "1px solid #eee", - }} - > - {s} -
      • - ))} -
      - ); -} +interface Props { + suggestions: string[]; + onSelect: (value: string) => void; +} + +export default function SuggestionList({ suggestions, onSelect }: Props) { + if (!suggestions.length) return null; + + return ( +
        + {suggestions.map((s) => ( +
      • onSelect(s)} + style={{ + padding: "0.5em", + cursor: "pointer", + borderBottom: "1px solid #eee", + }} + > + {s} +
      • + ))} +
      + ); +} diff --git a/frontend/src/components/items/index.js b/frontend/src/components/items/index.js new file mode 100644 index 0000000..f008559 --- /dev/null +++ b/frontend/src/components/items/index.js @@ -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'; + diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/layout/AppLayout.jsx similarity index 94% rename from frontend/src/components/AppLayout.jsx rename to frontend/src/components/layout/AppLayout.jsx index b2d8b07..028174a 100644 --- a/frontend/src/components/AppLayout.jsx +++ b/frontend/src/components/layout/AppLayout.jsx @@ -1,11 +1,11 @@ -import { Outlet } from "react-router-dom"; -import Navbar from "./Navbar"; - -export default function AppLayout() { - return ( -
      - - -
      - ); -} +import { Outlet } from "react-router-dom"; +import Navbar from "./Navbar"; + +export default function AppLayout() { + return ( +
      + + +
      + ); +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx similarity index 85% rename from frontend/src/components/Navbar.jsx rename to frontend/src/components/layout/Navbar.jsx index c9d6331..b797d77 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -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 ( - - ); +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 ( + + ); } \ No newline at end of file diff --git a/frontend/src/components/layout/index.js b/frontend/src/components/layout/index.js new file mode 100644 index 0000000..1e2af60 --- /dev/null +++ b/frontend/src/components/layout/index.js @@ -0,0 +1,4 @@ +// Barrel export for layout components +export { default as AppLayout } from './AppLayout.jsx'; +export { default as Navbar } from './Navbar.jsx'; + diff --git a/frontend/src/components/AddImageModal.jsx b/frontend/src/components/modals/AddImageModal.jsx similarity index 98% rename from frontend/src/components/AddImageModal.jsx rename to frontend/src/components/modals/AddImageModal.jsx index 39ab266..a1ac428 100644 --- a/frontend/src/components/AddImageModal.jsx +++ b/frontend/src/components/modals/AddImageModal.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import "../styles/AddImageModal.css"; +import "../../styles/AddImageModal.css"; export default function AddImageModal({ itemName, onClose, onAddImage }) { const [selectedImage, setSelectedImage] = useState(null); diff --git a/frontend/src/components/modals/AddItemWithDetailsModal.jsx b/frontend/src/components/modals/AddItemWithDetailsModal.jsx new file mode 100644 index 0000000..b9e31fd --- /dev/null +++ b/frontend/src/components/modals/AddItemWithDetailsModal.jsx @@ -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 ( +
      +
      e.stopPropagation()}> +

      Add Details for "{itemName}"

      +

      Add an image and classification to help organize your list

      + + {/* Image Section */} +
      + +
      + + {/* Classification Section */} +
      + +
      + + {/* Actions */} +
      + + + +
      +
      +
      + ); +} diff --git a/frontend/src/components/ConfirmBuyModal.jsx b/frontend/src/components/modals/ConfirmBuyModal.jsx similarity index 97% rename from frontend/src/components/ConfirmBuyModal.jsx rename to frontend/src/components/modals/ConfirmBuyModal.jsx index 3e9c7f6..e3a0dea 100644 --- a/frontend/src/components/ConfirmBuyModal.jsx +++ b/frontend/src/components/modals/ConfirmBuyModal.jsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import "../styles/ConfirmBuyModal.css"; +import "../../styles/ConfirmBuyModal.css"; export default function ConfirmBuyModal({ item, onConfirm, onCancel }) { const [quantity, setQuantity] = useState(item.quantity); diff --git a/frontend/src/components/EditItemModal.jsx b/frontend/src/components/modals/EditItemModal.jsx similarity index 59% rename from frontend/src/components/EditItemModal.jsx rename to frontend/src/components/modals/EditItemModal.jsx index 1f6c113..b7b5438 100644 --- a/frontend/src/components/EditItemModal.jsx +++ b/frontend/src/components/modals/EditItemModal.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications"; -import "../styles/EditItemModal.css"; +import "../../styles/components/EditItemModal.css"; +import ClassificationSection from "../forms/ClassificationSection"; export default function EditItemModal({ item, onSave, onCancel }) { const [itemName, setItemName] = useState(item.item_name || ""); @@ -19,11 +19,9 @@ export default function EditItemModal({ item, onSave, onCancel }) { } }, [item]); - const handleItemTypeChange = (e) => { - const newType = e.target.value; + const handleItemTypeChange = (newType) => { setItemType(newType); - // Reset item group when type changes - setItemGroup(""); + setItemGroup(""); // Reset group when type changes }; const handleSave = async () => { @@ -60,8 +58,6 @@ export default function EditItemModal({ item, onSave, onCancel }) { } }; - const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; - return (
      e.stopPropagation()}> @@ -90,57 +86,16 @@ export default function EditItemModal({ item, onSave, onCancel }) {
      -

      Item Classification

      - -
      - - -
      - - {itemType && ( -
      - - -
      - )} - -
      - - -
      +
      +
      + )} )}
      diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 7768c49..7a100bc 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,10 +1,10 @@ import { useContext, useState } from "react"; import { Link } from "react-router-dom"; import { loginRequest } from "../api/auth"; -import ErrorMessage from "../components/ErrorMessage"; -import FormInput from "../components/FormInput"; +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); diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 6f42f9e..f39d20c 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -2,10 +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/ErrorMessage"; -import FormInput from "../components/FormInput"; +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(); diff --git a/frontend/src/styles/THEME_USAGE_EXAMPLES.css b/frontend/src/styles/THEME_USAGE_EXAMPLES.css new file mode 100644 index 0000000..16d24ea --- /dev/null +++ b/frontend/src/styles/THEME_USAGE_EXAMPLES.css @@ -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 + +*/ diff --git a/frontend/src/styles/AddItemWithDetailsModal.css b/frontend/src/styles/components/AddItemWithDetailsModal.css similarity index 100% rename from frontend/src/styles/AddItemWithDetailsModal.css rename to frontend/src/styles/components/AddItemWithDetailsModal.css diff --git a/frontend/src/styles/components/ClassificationSection.css b/frontend/src/styles/components/ClassificationSection.css new file mode 100644 index 0000000..1b06669 --- /dev/null +++ b/frontend/src/styles/components/ClassificationSection.css @@ -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; +} diff --git a/frontend/src/styles/EditItemModal.css b/frontend/src/styles/components/EditItemModal.css similarity index 100% rename from frontend/src/styles/EditItemModal.css rename to frontend/src/styles/components/EditItemModal.css diff --git a/frontend/src/styles/components/ImageUploadSection.css b/frontend/src/styles/components/ImageUploadSection.css new file mode 100644 index 0000000..79c1378 --- /dev/null +++ b/frontend/src/styles/components/ImageUploadSection.css @@ -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); +} diff --git a/frontend/src/styles/Navbar.css b/frontend/src/styles/components/Navbar.css similarity index 93% rename from frontend/src/styles/Navbar.css rename to frontend/src/styles/components/Navbar.css index 5bca7aa..30d23a2 100644 --- a/frontend/src/styles/Navbar.css +++ b/frontend/src/styles/components/Navbar.css @@ -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; +} diff --git a/frontend/src/styles/GroceryList.css b/frontend/src/styles/pages/GroceryList.css similarity index 69% rename from frontend/src/styles/GroceryList.css rename to frontend/src/styles/pages/GroceryList.css index b07e1f6..e50c865 100644 --- a/frontend/src/styles/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -1,304 +1,323 @@ -/* 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; -} - -.glist-section-title { - text-align: center; - font-size: 1.2em; - margin-top: 2em; - margin-bottom: 0.5em; - color: #495057; - border-top: 2px solid #e0e0e0; - padding-top: 1em; -} - -/* Classification Groups */ -.glist-classification-group { - margin-bottom: 2em; -} - -.glist-classification-header { - font-size: 1.1em; - font-weight: 600; - color: #007bff; - margin: 1em 0 0.5em 0; - padding: 0.5em 0.8em; - background: #e7f3ff; - border-left: 4px solid #007bff; - border-radius: 4px; -} - -/* 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 { - 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; - } -} +/* Container */ +.glist-body { + font-family: var(--font-family-base); + padding: var(--spacing-md); + background: var(--color-bg-body); +} + +.glist-container { + max-width: var(--container-max-width); + margin: auto; + background: var(--color-bg-surface); + padding: var(--spacing-md); + 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; + } +} diff --git a/frontend/src/styles/Login.css b/frontend/src/styles/pages/Login.css similarity index 100% rename from frontend/src/styles/Login.css rename to frontend/src/styles/pages/Login.css diff --git a/frontend/src/styles/Register.css b/frontend/src/styles/pages/Register.css similarity index 100% rename from frontend/src/styles/Register.css rename to frontend/src/styles/pages/Register.css diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..a8d4f5c --- /dev/null +++ b/frontend/src/styles/theme.css @@ -0,0 +1,268 @@ +/** + * 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; + + /* 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; + + /* ============================================ + 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; } -- 2.39.5 From 0d5316bc27f11df30990ce49d4f94e7a74018841 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 2 Jan 2026 17:00:48 -0800 Subject: [PATCH 7/7] fix add item formatting and suggestion loigic --- frontend/src/components/forms/AddItemForm.jsx | 95 +++++++--- .../src/components/items/SuggestionList.tsx | 7 +- frontend/src/pages/GroceryList.jsx | 37 ++-- .../src/styles/components/AddItemForm.css | 172 ++++++++++++++++++ frontend/src/styles/pages/GroceryList.css | 4 +- frontend/src/styles/theme.css | 2 + 6 files changed, 268 insertions(+), 49 deletions(-) create mode 100644 frontend/src/styles/components/AddItemForm.css diff --git a/frontend/src/components/forms/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx index cd26d02..6f889eb 100644 --- a/frontend/src/components/forms/AddItemForm.jsx +++ b/frontend/src/components/forms/AddItemForm.jsx @@ -1,7 +1,8 @@ import { useState } from "react"; +import "../../styles/components/AddItemForm.css"; import SuggestionList from "../items/SuggestionList"; -export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) { +export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) { const [itemName, setItemName] = useState(""); const [quantity, setQuantity] = useState(1); const [showSuggestions, setShowSuggestions] = useState(false); @@ -23,38 +24,76 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText 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 ( - - handleInputChange(e.target.value)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} - onClick={() => setShowSuggestions(true)} - /> +
      + +
      + handleInputChange(e.target.value)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + onClick={() => setShowSuggestions(true)} + /> - {showSuggestions && suggestions.length > 0 && ( - - )} + {showSuggestions && suggestions.length > 0 && ( + + )} +
      - setQuantity(Number(e.target.value))} - /> +
      +
      + + + +
      - - + +
      + +
      ); } diff --git a/frontend/src/components/items/SuggestionList.tsx b/frontend/src/components/items/SuggestionList.tsx index fc52eaa..d79791a 100644 --- a/frontend/src/components/items/SuggestionList.tsx +++ b/frontend/src/components/items/SuggestionList.tsx @@ -1,3 +1,5 @@ +import React from "react"; + interface Props { suggestions: string[]; onSelect: (value: string) => void; @@ -8,15 +10,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) { return (
        item.item_name.toLowerCase() === lowerText); if (exactMatch) { - setButtonText("Add Item"); + setButtonText("Add"); } else { - // Check for similar items (80% match) - const similar = findSimilarItems(text, allItems, 80); - if (similar.length > 0) { - // Show suggestion in button but allow creation - setButtonText("Create and Add Item"); - } else { - setButtonText("Create and Add Item"); - } + setButtonText("Create + Add"); } try { @@ -129,10 +122,23 @@ export default function GroceryList() { const lowerItemName = itemName.toLowerCase().trim(); - // Combine both unbought and recently bought items for similarity checking - const allItems = [...items, ...recentlyBoughtItems]; + // 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; + } - // Check for 80% similar items + // 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 @@ -141,7 +147,7 @@ export default function GroceryList() { return; } - // Continue with normal flow + // Continue with normal flow for new items await processItemAddition(itemName, quantity); }; @@ -327,7 +333,6 @@ export default function GroceryList() {

        Costco Grocery List

        - {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( )} + + {sortMode === "zone" ? ( // Grouped view by zone (() => { diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css new file mode 100644 index 0000000..0511311 --- /dev/null +++ b/frontend/src/styles/components/AddItemForm.css @@ -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); + } +} diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css index e50c865..9748444 100644 --- a/frontend/src/styles/pages/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -1,7 +1,7 @@ /* Container */ .glist-body { font-family: var(--font-family-base); - padding: var(--spacing-md); + padding: var(--spacing-sm); background: var(--color-bg-body); } @@ -9,7 +9,7 @@ max-width: var(--container-max-width); margin: auto; background: var(--color-bg-surface); - padding: var(--spacing-md); + padding: var(--spacing-sm); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-card); } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index a8d4f5c..97d9cf6 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -60,6 +60,7 @@ --color-text-secondary: #6c757d; --color-text-muted: #adb5bd; --color-text-inverse: #ffffff; + --color-text-disabled: #6c757d; /* Background Colors */ --color-bg-body: #f8f9fa; @@ -71,6 +72,7 @@ --color-border-light: #e0e0e0; --color-border-medium: #ccc; --color-border-dark: #999; + --color-border-disabled: #dee2e6; /* ============================================ SPACING -- 2.39.5