wip
This commit is contained in:
parent
54fd64b9e3
commit
3073403f58
91
.copilotignore
Normal file
91
.copilotignore
Normal 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
197
.github/copilot-instructions.md
vendored
Normal 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
|
||||||
110
IMAGE_STORAGE_IMPLEMENTATION.md
Normal file
110
IMAGE_STORAGE_IMPLEMENTATION.md
Normal 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)
|
||||||
@ -15,17 +15,23 @@ exports.getItemByName = async (req, res) => {
|
|||||||
|
|
||||||
exports.addItem = async (req, res) => {
|
exports.addItem = async (req, res) => {
|
||||||
const { itemName, quantity } = req.body;
|
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) => {
|
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" });
|
res.json({ message: "Item marked bought" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -35,3 +41,21 @@ exports.getSuggestions = async (req, res) => {
|
|||||||
const suggestions = await List.getSuggestions(query);
|
const suggestions = await List.getSuggestions(query);
|
||||||
res.json(suggestions);
|
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" });
|
||||||
|
};
|
||||||
48
backend/middleware/image.js
Normal file
48
backend/middleware/image.js
Normal 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 };
|
||||||
20
backend/migrations/add_image_columns.sql
Normal file
20
backend/migrations/add_image_columns.sql
Normal 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`.
|
||||||
@ -3,7 +3,21 @@ const pool = require("../db/pool");
|
|||||||
|
|
||||||
exports.getUnboughtItems = async () => {
|
exports.getUnboughtItems = async () => {
|
||||||
const result = await pool.query(
|
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;
|
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(
|
const result = await pool.query(
|
||||||
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
||||||
[itemName]
|
[itemName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rowCount > 0) {
|
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(
|
await pool.query(
|
||||||
`UPDATE grocery_list
|
`UPDATE grocery_list
|
||||||
SET quantity = $1,
|
SET quantity = $1,
|
||||||
@ -32,29 +58,31 @@ exports.addOrUpdateItem = async (itemName, quantity) => {
|
|||||||
WHERE id = $2`,
|
WHERE id = $2`,
|
||||||
[quantity, result.rows[0].id]
|
[quantity, result.rows[0].id]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return result.rows[0].id;
|
return result.rows[0].id;
|
||||||
} else {
|
} else {
|
||||||
|
// Insert new item
|
||||||
const insert = await pool.query(
|
const insert = await pool.query(
|
||||||
`INSERT INTO grocery_list
|
`INSERT INTO grocery_list
|
||||||
(item_name, quantity)
|
(item_name, quantity, added_by, item_image, image_mime_type)
|
||||||
VALUES ($1, $2) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
[itemName, quantity]
|
[itemName, quantity, userId, imageBuffer, mimeType]
|
||||||
);
|
);
|
||||||
return insert.rows[0].id;
|
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]);
|
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(
|
await pool.query(
|
||||||
`INSERT INTO grocery_history (list_item_id, quantity, added_on)
|
`INSERT INTO grocery_history (list_item_id, quantity, added_by, added_on)
|
||||||
VALUES ($1, $2, NOW())`,
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
[itemId, quantity]
|
[itemId, quantity, userId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
636
backend/package-lock.json
generated
636
backend/package-lock.json
generated
@ -10,7 +10,9 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.16.0"
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.16.0",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cpx": "^1.5.0",
|
"cpx": "^1.5.0",
|
||||||
@ -19,6 +21,15 @@
|
|||||||
"rimraf": "^6.0.1"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.5",
|
"version": "0.25.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
||||||
@ -419,6 +430,446 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@ -482,6 +933,11 @@
|
|||||||
"normalize-path": "^2.0.0"
|
"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": {
|
"node_modules/arr-diff": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@ -862,6 +1334,33 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||||
@ -1023,6 +1522,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@ -2138,7 +2645,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
@ -2181,7 +2687,6 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@ -2194,6 +2699,62 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"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": {
|
"node_modules/nan": {
|
||||||
"version": "2.22.2",
|
"version": "2.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@ -3842,11 +4446,18 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@ -3854,8 +4465,7 @@
|
|||||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
@ -4055,6 +4665,12 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
@ -4068,6 +4684,11 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@ -4173,8 +4794,7 @@
|
|||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.16.0"
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.16.0",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cpx": "^1.5.0",
|
"cpx": "^1.5.0",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const auth = require("../middleware/auth");
|
|||||||
const requireRole = require("../middleware/rbac");
|
const requireRole = require("../middleware/rbac");
|
||||||
const { ROLES } = require("../models/user.model");
|
const { ROLES } = require("../models/user.model");
|
||||||
const User = 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.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);
|
router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
dev-rebuild.sh
Normal file
8
dev-rebuild.sh
Normal 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
|
||||||
@ -7,7 +7,7 @@ services:
|
|||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- frontend_node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:5173"
|
- "3000:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -21,9 +21,13 @@ services:
|
|||||||
command: npm run dev
|
command: npm run dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- backend_node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_node_modules:
|
||||||
|
backend_node_modules:
|
||||||
|
|||||||
@ -2,6 +2,36 @@ import api from "./axios";
|
|||||||
|
|
||||||
export const getList = () => api.get("/list");
|
export const getList = () => api.get("/list");
|
||||||
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
|
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 markBought = (id) => api.post("/list/mark-bought", { id });
|
||||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||||
|
|
||||||
|
export const 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
99
frontend/src/components/AddImageModal.jsx
Normal file
99
frontend/src/components/AddImageModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/AddItemForm.jsx
Normal file
60
frontend/src/components/AddItemForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/ConfirmBuyModal.jsx
Normal file
67
frontend/src/components/ConfirmBuyModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/ErrorMessage.jsx
Normal file
7
frontend/src/components/ErrorMessage.jsx
Normal 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>;
|
||||||
|
}
|
||||||
7
frontend/src/components/FloatingActionButton.jsx
Normal file
7
frontend/src/components/FloatingActionButton.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function FloatingActionButton({ isOpen, onClick }) {
|
||||||
|
return (
|
||||||
|
<button className="glist-fab" onClick={onClick}>
|
||||||
|
{isOpen ? "−" : "+"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/FormInput.jsx
Normal file
21
frontend/src/components/FormInput.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/components/GroceryListItem.jsx
Normal file
100
frontend/src/components/GroceryListItem.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/ImageModal.jsx
Normal file
27
frontend/src/components/ImageModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/src/components/ImageUploadModal.jsx
Normal file
102
frontend/src/components/ImageUploadModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/SimilarItemModal.jsx
Normal file
29
frontend/src/components/SimilarItemModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/components/SortDropdown.jsx
Normal file
10
frontend/src/components/SortDropdown.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/UserRoleCard.jsx
Normal file
21
frontend/src/components/UserRoleCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getAllUsers, updateRole } from "../api/users";
|
import { getAllUsers, updateRole } from "../api/users";
|
||||||
import { ROLES } from "../constants/roles";
|
import UserRoleCard from "../components/UserRoleCard";
|
||||||
|
import "../styles/UserRoleCard.css";
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
const allUsers = await getAllUsers();
|
const allUsers = await getAllUsers();
|
||||||
console.log(allUsers);
|
|
||||||
setUsers(allUsers.data);
|
setUsers(allUsers.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,19 +22,17 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ padding: "2rem" }}>
|
||||||
<h1>Admin Panel</h1>
|
<h1>Admin Panel</h1>
|
||||||
|
<div style={{ marginTop: "2rem" }}>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div key={user.id}>
|
<UserRoleCard
|
||||||
<strong>{user.username}</strong> - {user.role}
|
key={user.id}
|
||||||
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
|
user={user}
|
||||||
<option value={ROLES.VIEWER}>Viewer</option>
|
onRoleChange={changeRole}
|
||||||
<option value={ROLES.EDITOR}>Editor</option>
|
/>
|
||||||
<option value={ROLES.ADMIN}>Admin</option>
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
}
|
|
||||||
</div >
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,28 +1,35 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
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 { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import "../styles/GroceryList.css";
|
import "../styles/GroceryList.css";
|
||||||
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { role, username } = useContext(AuthContext);
|
const { role } = useContext(AuthContext);
|
||||||
|
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [sortedItems, setSortedItems] = useState([]);
|
const [sortedItems, setSortedItems] = useState([]);
|
||||||
|
|
||||||
const [sortMode, setSortMode] = useState("az");
|
const [sortMode, setSortMode] = useState("az");
|
||||||
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [itemName, setItemName] = useState("");
|
|
||||||
const [quantity, setQuantity] = useState(1);
|
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [showAddForm, setShowAddForm] = useState(true);
|
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const loadItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getList();
|
const res = await getList();
|
||||||
|
console.log(res.data);
|
||||||
setItems(res.data);
|
setItems(res.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@ -34,29 +41,38 @@ export default function GroceryList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let sorted = [...items];
|
let sorted = [...items];
|
||||||
|
|
||||||
if (sortMode === "az")
|
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||||
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 === "za")
|
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
||||||
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);
|
setSortedItems(sorted);
|
||||||
}, [items, sortMode]);
|
}, [items, sortMode]);
|
||||||
|
|
||||||
const handleSuggest = async (text) => {
|
const handleSuggest = async (text) => {
|
||||||
setItemName(text);
|
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setButtonText("Add Item");
|
||||||
return;
|
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 {
|
try {
|
||||||
let suggestions = await getSuggestions(text);
|
let suggestions = await getSuggestions(text);
|
||||||
suggestions = suggestions.data.map(s => s.item_name);
|
suggestions = suggestions.data.map(s => s.item_name);
|
||||||
@ -66,40 +82,126 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async (e) => {
|
const handleAdd = async (itemName, quantity) => {
|
||||||
e.preventDefault();
|
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
let newQuantity = quantity;
|
|
||||||
|
|
||||||
const item = await getItemByName(itemName);
|
const lowerItemName = itemName.toLowerCase().trim();
|
||||||
if (item.data && item.data.bought === false) {
|
|
||||||
console.log("Item exists:", item.data);
|
// Check for 80% similar items
|
||||||
let currentQuantity = item.data.quantity;
|
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(
|
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;
|
if (!yes) return;
|
||||||
|
|
||||||
newQuantity += currentQuantity;
|
await addItem(itemName, newQuantity, null);
|
||||||
}
|
|
||||||
|
|
||||||
await addItem(itemName, newQuantity);
|
|
||||||
|
|
||||||
setItemName("");
|
|
||||||
setQuantity(1);
|
|
||||||
setSuggestions([]);
|
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();
|
loadItems();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBought = async (id) => {
|
const handleImageSkip = async () => {
|
||||||
const yes = window.confirm("Mark this item as bought?");
|
if (!pendingItem) return;
|
||||||
if (!yes) 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);
|
await markBought(id);
|
||||||
loadItems();
|
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>;
|
if (loading) return <p>Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -107,86 +209,57 @@ export default function GroceryList() {
|
|||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">Costco Grocery List</h1>
|
<h1 className="glist-title">Costco Grocery List</h1>
|
||||||
|
|
||||||
{/* Sorting dropdown */}
|
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||||
<select
|
|
||||||
value={sortMode}
|
|
||||||
onChange={(e) => setSortMode(e.target.value)}
|
|
||||||
className="glist-sort"
|
|
||||||
>
|
|
||||||
<option value="az">A → Z</option>
|
|
||||||
<option value="za">Z → A</option>
|
|
||||||
<option value="qty-high">Quantity: High → Low</option>
|
|
||||||
<option value="qty-low">Quantity: Low → High</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Add Item form (editor/admin only) */}
|
|
||||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||||
<>
|
<AddItemForm
|
||||||
<input
|
onAdd={handleAdd}
|
||||||
type="text"
|
onSuggest={handleSuggest}
|
||||||
className="glist-input"
|
suggestions={suggestions}
|
||||||
placeholder="Item name"
|
buttonText={buttonText}
|
||||||
value={itemName}
|
|
||||||
onChange={(e) => handleSuggest(e.target.value)}
|
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
|
||||||
onClick={() => setShowSuggestions(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
|
||||||
<ul className="glist-suggest-box">
|
|
||||||
{suggestions.map((s, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="glist-suggest-item"
|
|
||||||
onClick={() => {
|
|
||||||
setItemName(s);
|
|
||||||
setSuggestions([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
className="glist-input"
|
|
||||||
value={quantity}
|
|
||||||
onChange={(e) => setQuantity(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button className="glist-btn" onClick={handleAdd}>
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Grocery list */}
|
|
||||||
<ul className="glist-ul">
|
<ul className="glist-ul">
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<li
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="glist-li"
|
item={item}
|
||||||
onClick={() =>
|
onClick={(quantity) =>
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id)
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||||
}
|
}
|
||||||
>
|
onImageAdded={
|
||||||
{item.item_name} ({item.quantity})
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
</li>
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Button (editor/admin only) */}
|
|
||||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||||
<button
|
<FloatingActionButton
|
||||||
className="glist-fab"
|
isOpen={showAddForm}
|
||||||
onClick={() => setShowAddForm(!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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { loginRequest } from "../api/auth";
|
import { loginRequest } from "../api/auth";
|
||||||
|
import ErrorMessage from "../components/ErrorMessage";
|
||||||
|
import FormInput from "../components/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import "../styles/Login.css";
|
import "../styles/Login.css";
|
||||||
|
|
||||||
@ -8,6 +10,7 @@ export default function Login() {
|
|||||||
const { login } = useContext(AuthContext);
|
const { login } = useContext(AuthContext);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
@ -28,22 +31,32 @@ export default function Login() {
|
|||||||
<div className="login-box">
|
<div className="login-box">
|
||||||
<h1 className="login-title">Login</h1>
|
<h1 className="login-title">Login</h1>
|
||||||
|
|
||||||
{error && <p className="login-error">{error}</p>}
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<input
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
className="login-input"
|
className="login-input"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<div className="login-password-wrapper">
|
||||||
type="password"
|
<FormInput
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
className="login-input"
|
className="login-input"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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>
|
<button type="submit" className="login-button">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { useContext, useEffect, useState } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { loginRequest, registerRequest } from "../api/auth";
|
import { loginRequest, registerRequest } from "../api/auth";
|
||||||
import { checkIfUserExists } from "../api/users";
|
import { checkIfUserExists } from "../api/users";
|
||||||
|
import ErrorMessage from "../components/ErrorMessage";
|
||||||
|
import FormInput from "../components/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
|
||||||
import "../styles/Register.css";
|
import "../styles/Register.css";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
@ -19,25 +20,25 @@ export default function Register() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkIfUserExistsHandler();
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
useEffect(() => { checkIfUserExistsHandler(); }, [username]);
|
|
||||||
async function checkIfUserExistsHandler() {
|
async function checkIfUserExistsHandler() {
|
||||||
setUserExists((await checkIfUserExists(username)).data);
|
setUserExists((await checkIfUserExists(username)).data);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { setError(userExists ? `Username '${username}' already taken` : ""); }, [userExists]);
|
useEffect(() => {
|
||||||
|
setError(userExists ? `Username '${username}' already taken` : "");
|
||||||
|
}, [userExists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPasswordMatches(
|
setPasswordMatches(!password || !confirm || password === confirm);
|
||||||
!password ||
|
|
||||||
!confirm ||
|
|
||||||
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) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -46,51 +47,46 @@ export default function Register() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await registerRequest(username, password, name);
|
await registerRequest(username, password, name);
|
||||||
console.log("Registered user:", username);
|
|
||||||
const data = await loginRequest(username, password);
|
const data = await loginRequest(username, password);
|
||||||
console.log(data);
|
|
||||||
login(data);
|
login(data);
|
||||||
setSuccess("Account created! Redirecting the grocery list...");
|
setSuccess("Account created! Redirecting to the grocery list...");
|
||||||
setTimeout(() => navigate("/"), 2000);
|
setTimeout(() => navigate("/"), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || "Registration failed");
|
setError(err.response?.data?.message || "Registration failed");
|
||||||
setTimeout(() => {
|
setTimeout(() => setError(""), 1000);
|
||||||
setError("");
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="register-container">
|
<div className="register-container">
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
|
|
||||||
{<p className="error-message">{error}</p>}
|
<ErrorMessage message={error} />
|
||||||
{success && <p className="success-message">{success}</p>}
|
<ErrorMessage message={success} type="success" />
|
||||||
|
|
||||||
<form className="register-form" onSubmit={submit}>
|
<form className="register-form" onSubmit={submit}>
|
||||||
<input
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onKeyUp={(e) => setUsername(e.target.value)}
|
onKeyUp={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
|||||||
176
frontend/src/styles/AddImageModal.css
Normal file
176
frontend/src/styles/AddImageModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
frontend/src/styles/ConfirmBuyModal.css
Normal file
158
frontend/src/styles/ConfirmBuyModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,15 +81,99 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glist-li {
|
.glist-li {
|
||||||
padding: 0.7em;
|
background: #fff;
|
||||||
background: #e9ecef;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
margin-bottom: 0.6em;
|
margin-bottom: 0.8em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-li:hover {
|
.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 */
|
/* Sorting dropdown */
|
||||||
@ -101,6 +185,62 @@
|
|||||||
border-radius: 4px;
|
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) */
|
/* Floating Action Button (FAB) */
|
||||||
.glist-fab {
|
.glist-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
99
frontend/src/styles/ImageModal.css
Normal file
99
frontend/src/styles/ImageModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
frontend/src/styles/ImageUploadModal.css
Normal file
203
frontend/src/styles/ImageUploadModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,40 @@
|
|||||||
border: 1px solid #ccc;
|
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 {
|
.login-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.7em;
|
padding: 0.7em;
|
||||||
|
|||||||
115
frontend/src/styles/SimilarItemModal.css
Normal file
115
frontend/src/styles/SimilarItemModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/styles/UserRoleCard.css
Normal file
40
frontend/src/styles/UserRoleCard.css
Normal 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);
|
||||||
|
}
|
||||||
68
frontend/src/utils/stringSimilarity.js
Normal file
68
frontend/src/utils/stringSimilarity.js
Normal 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
9
package-lock.json
generated
@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "costco-grocery-list",
|
"name": "Costco-Grocery-List",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "Costco-Grocery-List",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
@ -3745,9 +3746,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user