This commit is contained in:
Nico 2026-01-01 22:55:39 -08:00
parent 54fd64b9e3
commit 3073403f58
39 changed files with 3043 additions and 195 deletions

91
.copilotignore Normal file
View File

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

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

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

View File

@ -0,0 +1,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)

View File

@ -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" });
};
@ -35,3 +41,21 @@ exports.getSuggestions = async (req, res) => {
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" });
};

View File

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

View File

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

View File

@ -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,13 +32,25 @@ 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) {
// 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,
@ -32,29 +58,31 @@ exports.addOrUpdateItem = async (itemName, quantity) => {
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]
);
};

View File

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

View File

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

View File

@ -4,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);

8
dev-rebuild.sh Normal file
View File

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

View File

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

View File

@ -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 } });
export const updateItemImage = (id, itemName, quantity, imageFile) => {
const formData = new FormData();
formData.append("id", id);
formData.append("itemName", itemName);
formData.append("quantity", quantity);
formData.append("image", imageFile);
return api.post("/list/update-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};

View File

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

View File

@ -0,0 +1,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 (
<form onSubmit={handleSubmit}>
<input
type="text"
className="glist-input"
placeholder="Item name"
value={itemName}
onChange={(e) => handleInputChange(e.target.value)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onClick={() => setShowSuggestions(true)}
/>
{showSuggestions && suggestions.length > 0 && (
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
)}
<input
type="number"
min="1"
className="glist-input"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button type="submit" className="glist-btn">
{buttonText}
</button>
</form>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 (
<>
<li className="glist-li" onClick={handleItemClick}>
<div className="glist-item-layout">
<div
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
onClick={handleImageClick}
style={{ cursor: "pointer" }}
>
{item.item_image ? (
<img src={imageUrl} alt={item.item_name} />
) : (
<span>📦</span>
)}
<span className="glist-item-quantity">x{item.quantity}</span>
</div>
<div className="glist-item-content">
<div className="glist-item-header">
<span className="glist-item-name">{item.item_name}</span>
</div>
{item.added_by_users && item.added_by_users.length > 0 && (
<div className="glist-item-users">
{item.added_by_users.join(", ")}
</div>
)}
</div>
</div>
</li>
{showModal && (
<ImageModal
imageUrl={imageUrl}
itemName={item.item_name}
onClose={() => setShowModal(false)}
/>
)}
{showAddImageModal && (
<AddImageModal
itemName={item.item_name}
onClose={() => setShowAddImageModal(false)}
onAddImage={handleAddImage}
/>
)}
{showConfirmBuyModal && (
<ConfirmBuyModal
item={item}
onConfirm={handleConfirmBuy}
onCancel={handleCancelBuy}
/>
)}
</>
);
}

View File

@ -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 (
<div className="image-modal-overlay" onClick={onClose}>
<div className="image-modal-content" onClick={(e) => e.stopPropagation()}>
<button className="image-modal-close" onClick={onClose}>
×
</button>
<img src={imageUrl} alt={itemName} className="image-modal-img" />
<p className="image-modal-caption">{itemName}</p>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div>
<div style={{ padding: "2rem" }}>
<h1>Admin Panel</h1>
<div style={{ marginTop: "2rem" }}>
{users.map((user) => (
<div key={user.id}>
<strong>{user.username}</strong> - {user.role}
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
<option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option>
<option value={ROLES.ADMIN}>Admin</option>
</select>
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
</div>
</div>
))
}
</div >
)
}

View File

@ -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);
setItemName("");
setQuantity(1);
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);
}
};
const handleSimilarCancel = () => {
setShowSimilarModal(false);
setSimilarItemSuggestion(null);
};
const handleSimilarNo = async () => {
if (!similarItemSuggestion) return;
setShowSimilarModal(false);
// Create new item with original name
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null);
};
const handleSimilarYes = async () => {
if (!similarItemSuggestion) return;
setShowSimilarModal(false);
// Use suggested item name
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null);
};
const 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 <p>Loading...</p>;
return (
@ -107,86 +209,57 @@ export default function GroceryList() {
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
{/* Sorting dropdown */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value)}
className="glist-sort"
>
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
</select>
<SortDropdown value={sortMode} onChange={setSortMode} />
{/* Add Item form (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
<>
<input
type="text"
className="glist-input"
placeholder="Item name"
value={itemName}
onChange={(e) => handleSuggest(e.target.value)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onClick={() => setShowSuggestions(true)}
<AddItemForm
onAdd={handleAdd}
onSuggest={handleSuggest}
suggestions={suggestions}
buttonText={buttonText}
/>
{showSuggestions && suggestions.length > 0 && (
<ul className="glist-suggest-box">
{suggestions.map((s, i) => (
<li
key={i}
className="glist-suggest-item"
onClick={() => {
setItemName(s);
setSuggestions([]);
}}
>
{s}
</li>
))}
</ul>
)}
<input
type="number"
min="1"
className="glist-input"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button className="glist-btn" onClick={handleAdd}>
Add Item
</button>
</>
)}
{/* Grocery list */}
<ul className="glist-ul">
{sortedItems.map((item) => (
<li
<GroceryListItem
key={item.id}
className="glist-li"
onClick={() =>
[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})
</li>
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
/>
))}
</ul>
</div>
{/* Floating Button (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
<button
className="glist-fab"
<FloatingActionButton
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? "" : "+"}
</button>
/>
)}
{showImageModal && pendingItem && (
<ImageUploadModal
itemName={pendingItem.itemName}
onConfirm={handleImageConfirm}
onSkip={handleImageSkip}
onCancel={handleImageCancel}
/>
)}
{showSimilarModal && similarItemSuggestion && (
<SimilarItemModal
originalName={similarItemSuggestion.originalName}
suggestedName={similarItemSuggestion.suggestedItem.item_name}
onCancel={handleSimilarCancel}
onNo={handleSimilarNo}
onYes={handleSimilarYes}
/>
)}
</div>
);

View File

@ -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() {
<div className="login-box">
<h1 className="login-title">Login</h1>
{error && <p className="login-error">{error}</p>}
<ErrorMessage message={error} />
<form onSubmit={submit}>
<input
<FormInput
type="text"
className="login-input"
placeholder="Username"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
<div className="login-password-wrapper">
<FormInput
type={showPassword ? "text" : "password"}
className="login-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="login-password-toggle"
onClick={() => setShowPassword(!showPassword)}
aria-label="Toggle password visibility"
>
{showPassword ? "👀" : "🙈"}
</button>
</div>
<button type="submit" className="login-button">Login</button>
</form>

View File

@ -2,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 (
<div className="register-container">
<h1>Register</h1>
{<p className="error-message">{error}</p>}
{success && <p className="success-message">{success}</p>}
<ErrorMessage message={error} />
<ErrorMessage message={success} type="success" />
<form className="register-form" onSubmit={submit}>
<input
<FormInput
type="text"
placeholder="Name"
onChange={(e) => setName(e.target.value)}
required
/>
<input
<FormInput
type="text"
placeholder="Username"
onKeyUp={(e) => setUsername(e.target.value)}
required
/>
<input
<FormInput
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
required
/>
<input
<FormInput
type="password"
placeholder="Confirm Password"
onChange={(e) => setConfirm(e.target.value)}

View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
package-lock.json generated
View File

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