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"