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) => {
|
||||
const { itemName, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
const id = await List.addOrUpdateItem(itemName, quantity);
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
await List.addHistoryRecord(id, quantity);
|
||||
const id = await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType);
|
||||
|
||||
res.json({ message: "Item added/updated" });
|
||||
await List.addHistoryRecord(id, quantity, userId);
|
||||
|
||||
res.json({ message: "Item added/updated", addedBy: userId });
|
||||
};
|
||||
|
||||
|
||||
exports.markBought = async (req, res) => {
|
||||
await List.setBought(req.body.id);
|
||||
const userId = req.user.id;
|
||||
await List.setBought(req.body.id, userId);
|
||||
res.json({ message: "Item marked bought" });
|
||||
};
|
||||
|
||||
@ -35,3 +41,21 @@ exports.getSuggestions = async (req, res) => {
|
||||
const suggestions = await List.getSuggestions(query);
|
||||
res.json(suggestions);
|
||||
};
|
||||
|
||||
exports.updateItemImage = async (req, res) => {
|
||||
const { id, itemName, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get processed image
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
if (!imageBuffer) {
|
||||
return res.status(400).json({ message: "No image provided" });
|
||||
}
|
||||
|
||||
// Update the item with new image
|
||||
await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType);
|
||||
|
||||
res.json({ message: "Image updated successfully" });
|
||||
};
|
||||
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 () => {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM grocery_list WHERE bought = FALSE ORDER BY id ASC"
|
||||
`SELECT
|
||||
gl.id,
|
||||
gl.item_name,
|
||||
gl.quantity,
|
||||
gl.bought,
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
gl.image_mime_type,
|
||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users
|
||||
FROM grocery_list gl
|
||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
||||
WHERE gl.bought = FALSE
|
||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type
|
||||
ORDER BY gl.id ASC`
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
@ -18,43 +32,57 @@ exports.getItemByName = async (itemName) => {
|
||||
};
|
||||
|
||||
|
||||
exports.addOrUpdateItem = async (itemName, quantity) => {
|
||||
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
|
||||
const result = await pool.query(
|
||||
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
||||
[itemName]
|
||||
);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET quantity = $1,
|
||||
bought = FALSE
|
||||
WHERE id = $2`,
|
||||
[quantity, result.rows[0].id]
|
||||
);
|
||||
// Update existing item
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
item_image = $3,
|
||||
image_mime_type = $4
|
||||
WHERE id = $2`,
|
||||
[quantity, result.rows[0].id, imageBuffer, mimeType]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET quantity = $1,
|
||||
bought = FALSE
|
||||
WHERE id = $2`,
|
||||
[quantity, result.rows[0].id]
|
||||
);
|
||||
}
|
||||
return result.rows[0].id;
|
||||
} else {
|
||||
// Insert new item
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO grocery_list
|
||||
(item_name, quantity)
|
||||
VALUES ($1, $2) RETURNING id`,
|
||||
[itemName, quantity]
|
||||
(item_name, quantity, added_by, item_image, image_mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[itemName, quantity, userId, imageBuffer, mimeType]
|
||||
);
|
||||
return insert.rows[0].id;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
exports.setBought = async (id) => {
|
||||
exports.setBought = async (id, userId) => {
|
||||
await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]);
|
||||
};
|
||||
|
||||
|
||||
exports.addHistoryRecord = async (itemId, quantity) => {
|
||||
exports.addHistoryRecord = async (itemId, quantity, userId) => {
|
||||
await pool.query(
|
||||
`INSERT INTO grocery_history (list_item_id, quantity, added_on)
|
||||
VALUES ($1, $2, NOW())`,
|
||||
[itemId, quantity]
|
||||
`INSERT INTO grocery_history (list_item_id, quantity, added_by, added_on)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
[itemId, quantity, userId]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
636
backend/package-lock.json
generated
636
backend/package-lock.json
generated
@ -10,7 +10,9 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.16.0"
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.16.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpx": "^1.5.0",
|
||||
@ -19,6 +21,15 @@
|
||||
"rimraf": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
||||
@ -419,6 +430,446 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@ -482,6 +933,11 @@
|
||||
"normalize-path": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
||||
},
|
||||
"node_modules/arr-diff": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz",
|
||||
@ -684,6 +1140,22 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@ -862,6 +1334,33 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
@ -1023,6 +1522,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
@ -2138,7 +2645,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@ -2181,7 +2687,6 @@
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@ -2194,6 +2699,62 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
|
||||
@ -3503,6 +4064,49 @@
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@ -3842,11 +4446,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@ -3854,8 +4465,7 @@
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
@ -4055,6 +4665,12 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
@ -4068,6 +4684,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@ -4173,8 +4794,7 @@
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.16.0"
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.16.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpx": "^1.5.0",
|
||||
|
||||
@ -4,6 +4,7 @@ const auth = require("../middleware/auth");
|
||||
const requireRole = require("../middleware/rbac");
|
||||
const { ROLES } = require("../models/user.model");
|
||||
const User = require("../models/user.model");
|
||||
const { upload, processImage } = require("../middleware/image");
|
||||
|
||||
|
||||
|
||||
@ -12,7 +13,8 @@ router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controll
|
||||
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
|
||||
|
||||
|
||||
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
|
||||
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem);
|
||||
router.post("/update-image", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.updateItemImage);
|
||||
router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought);
|
||||
|
||||
|
||||
|
||||
8
dev-rebuild.sh
Normal file
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
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- frontend_node_modules:/app/node_modules
|
||||
ports:
|
||||
- "3000:5173"
|
||||
depends_on:
|
||||
@ -21,9 +21,13 @@ services:
|
||||
command: npm run dev
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
- backend_node_modules:/app/node_modules
|
||||
ports:
|
||||
- "5000:5000"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
backend_node_modules:
|
||||
|
||||
@ -2,6 +2,36 @@ import api from "./axios";
|
||||
|
||||
export const getList = () => api.get("/list");
|
||||
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
|
||||
export const addItem = (itemName, quantity) => api.post("/list/add", { itemName, quantity });
|
||||
|
||||
export const addItem = (itemName, quantity, imageFile = null) => {
|
||||
const formData = new FormData();
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
|
||||
if (imageFile) {
|
||||
formData.append("image", imageFile);
|
||||
}
|
||||
|
||||
return api.post("/list/add", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const markBought = (id) => api.post("/list/mark-bought", { id });
|
||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||
|
||||
export const updateItemImage = (id, itemName, quantity, imageFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", id);
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
formData.append("image", imageFile);
|
||||
|
||||
return api.post("/list/update-image", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
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 { getAllUsers, updateRole } from "../api/users";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import UserRoleCard from "../components/UserRoleCard";
|
||||
import "../styles/UserRoleCard.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
async function loadUsers() {
|
||||
const allUsers = await getAllUsers();
|
||||
console.log(allUsers);
|
||||
setUsers(allUsers.data);
|
||||
}
|
||||
|
||||
@ -22,19 +22,17 @@ export default function AdminPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "2rem" }}>
|
||||
<h1>Admin Panel</h1>
|
||||
{users.map((user) => (
|
||||
<div key={user.id}>
|
||||
<strong>{user.username}</strong> - {user.role}
|
||||
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
|
||||
<option value={ROLES.VIEWER}>Viewer</option>
|
||||
<option value={ROLES.EDITOR}>Editor</option>
|
||||
<option value={ROLES.ADMIN}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div >
|
||||
<div style={{ marginTop: "2rem" }}>
|
||||
{users.map((user) => (
|
||||
<UserRoleCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
onRoleChange={changeRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,28 +1,35 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list";
|
||||
import { addItem, getItemByName, getList, getSuggestions, markBought, updateItemImage } from "../api/list";
|
||||
import AddItemForm from "../components/AddItemForm";
|
||||
import FloatingActionButton from "../components/FloatingActionButton";
|
||||
import GroceryListItem from "../components/GroceryListItem";
|
||||
import ImageUploadModal from "../components/ImageUploadModal";
|
||||
import SimilarItemModal from "../components/SimilarItemModal";
|
||||
import SortDropdown from "../components/SortDropdown";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
export default function GroceryList() {
|
||||
const { role, username } = useContext(AuthContext);
|
||||
const { role } = useContext(AuthContext);
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
const [sortedItems, setSortedItems] = useState([]);
|
||||
|
||||
const [sortMode, setSortMode] = useState("az");
|
||||
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(true);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buttonText, setButtonText] = useState("Add Item");
|
||||
const [pendingItem, setPendingItem] = useState(null);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
const res = await getList();
|
||||
console.log(res.data);
|
||||
setItems(res.data);
|
||||
setLoading(false);
|
||||
};
|
||||
@ -34,29 +41,38 @@ export default function GroceryList() {
|
||||
useEffect(() => {
|
||||
let sorted = [...items];
|
||||
|
||||
if (sortMode === "az")
|
||||
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||
|
||||
if (sortMode === "za")
|
||||
sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||
|
||||
if (sortMode === "qty-high")
|
||||
sorted.sort((a, b) => b.quantity - a.quantity);
|
||||
|
||||
if (sortMode === "qty-low")
|
||||
sorted.sort((a, b) => a.quantity - b.quantity);
|
||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
||||
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
||||
|
||||
setSortedItems(sorted);
|
||||
}, [items, sortMode]);
|
||||
|
||||
const handleSuggest = async (text) => {
|
||||
setItemName(text);
|
||||
|
||||
if (!text.trim()) {
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if exact match exists (case-insensitive)
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
const exactMatch = items.find(item => item.item_name.toLowerCase() === lowerText);
|
||||
|
||||
if (exactMatch) {
|
||||
setButtonText("Add Item");
|
||||
} else {
|
||||
// Check for similar items (80% match)
|
||||
const similar = findSimilarItems(text, items, 80);
|
||||
if (similar.length > 0) {
|
||||
// Show suggestion in button but allow creation
|
||||
setButtonText("Create and Add Item");
|
||||
} else {
|
||||
setButtonText("Create and Add Item");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let suggestions = await getSuggestions(text);
|
||||
suggestions = suggestions.data.map(s => s.item_name);
|
||||
@ -66,40 +82,126 @@ export default function GroceryList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
const handleAdd = async (itemName, quantity) => {
|
||||
if (!itemName.trim()) return;
|
||||
let newQuantity = quantity;
|
||||
|
||||
const item = await getItemByName(itemName);
|
||||
if (item.data && item.data.bought === false) {
|
||||
console.log("Item exists:", item.data);
|
||||
let currentQuantity = item.data.quantity;
|
||||
const lowerItemName = itemName.toLowerCase().trim();
|
||||
|
||||
// Check for 80% similar items
|
||||
const similar = findSimilarItems(itemName, items, 80);
|
||||
if (similar.length > 0) {
|
||||
// Show modal and wait for user decision
|
||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||
setShowSimilarModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with normal flow
|
||||
await processItemAddition(itemName, quantity);
|
||||
};
|
||||
|
||||
const processItemAddition = async (itemName, quantity) => {
|
||||
|
||||
// Check if item exists in database (case-insensitive)
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
existingItem = null;
|
||||
}
|
||||
|
||||
if (existingItem && existingItem.bought === false) {
|
||||
// Item exists and is unbought - update quantity
|
||||
const currentQuantity = existingItem.quantity;
|
||||
const newQuantity = currentQuantity + quantity;
|
||||
const yes = window.confirm(
|
||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${currentQuantity + newQuantity}?`
|
||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
||||
);
|
||||
if (!yes) return;
|
||||
|
||||
newQuantity += currentQuantity;
|
||||
await addItem(itemName, newQuantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} else if (existingItem) {
|
||||
// Item exists in database (was previously bought) - just add quantity
|
||||
await addItem(itemName, quantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} else {
|
||||
// NEW ITEM - show image upload modal
|
||||
setPendingItem({ itemName, quantity });
|
||||
setShowImageModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
await addItem(itemName, newQuantity);
|
||||
const handleSimilarCancel = () => {
|
||||
setShowSimilarModal(false);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
setItemName("");
|
||||
setQuantity(1);
|
||||
const handleSimilarNo = async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Create new item with original name
|
||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleSimilarYes = async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Use suggested item name
|
||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleImageConfirm = async (imageFile) => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
setShowImageModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
};
|
||||
|
||||
const handleBought = async (id) => {
|
||||
const yes = window.confirm("Mark this item as bought?");
|
||||
if (!yes) return;
|
||||
const handleImageSkip = async () => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
setShowImageModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
};
|
||||
|
||||
const handleImageCancel = () => {
|
||||
setShowImageModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
};
|
||||
|
||||
const handleBought = async (id, quantity) => {
|
||||
await markBought(id);
|
||||
loadItems();
|
||||
};
|
||||
|
||||
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
|
||||
try {
|
||||
await updateItemImage(id, itemName, quantity, imageFile);
|
||||
loadItems(); // Reload to show new image
|
||||
} catch (error) {
|
||||
console.error("Failed to add image:", error);
|
||||
alert("Failed to add image. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
@ -107,86 +209,57 @@ export default function GroceryList() {
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">Costco Grocery List</h1>
|
||||
|
||||
{/* Sorting dropdown */}
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value)}
|
||||
className="glist-sort"
|
||||
>
|
||||
<option value="az">A → Z</option>
|
||||
<option value="za">Z → A</option>
|
||||
<option value="qty-high">Quantity: High → Low</option>
|
||||
<option value="qty-low">Quantity: Low → High</option>
|
||||
</select>
|
||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||
|
||||
{/* Add Item form (editor/admin only) */}
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="glist-input"
|
||||
placeholder="Item name"
|
||||
value={itemName}
|
||||
onChange={(e) => handleSuggest(e.target.value)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onClick={() => setShowSuggestions(true)}
|
||||
/>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="glist-suggest-box">
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="glist-suggest-item"
|
||||
onClick={() => {
|
||||
setItemName(s);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="glist-input"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
<button className="glist-btn" onClick={handleAdd}>
|
||||
Add Item
|
||||
</button>
|
||||
</>
|
||||
<AddItemForm
|
||||
onAdd={handleAdd}
|
||||
onSuggest={handleSuggest}
|
||||
suggestions={suggestions}
|
||||
buttonText={buttonText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grocery list */}
|
||||
<ul className="glist-ul">
|
||||
{sortedItems.map((item) => (
|
||||
<li
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
className="glist-li"
|
||||
onClick={() =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id)
|
||||
item={item}
|
||||
onClick={(quantity) =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||
}
|
||||
>
|
||||
{item.item_name} ({item.quantity})
|
||||
</li>
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Floating Button (editor/admin only) */}
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
<button
|
||||
className="glist-fab"
|
||||
<FloatingActionButton
|
||||
isOpen={showAddForm}
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
>
|
||||
{showAddForm ? "−" : "+"}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImageModal && pendingItem && (
|
||||
<ImageUploadModal
|
||||
itemName={pendingItem.itemName}
|
||||
onConfirm={handleImageConfirm}
|
||||
onSkip={handleImageSkip}
|
||||
onCancel={handleImageCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSimilarModal && similarItemSuggestion && (
|
||||
<SimilarItemModal
|
||||
originalName={similarItemSuggestion.originalName}
|
||||
suggestedName={similarItemSuggestion.suggestedItem.item_name}
|
||||
onCancel={handleSimilarCancel}
|
||||
onNo={handleSimilarNo}
|
||||
onYes={handleSimilarYes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { loginRequest } from "../api/auth";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import FormInput from "../components/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/Login.css";
|
||||
|
||||
@ -8,6 +10,7 @@ export default function Login() {
|
||||
const { login } = useContext(AuthContext);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (e) => {
|
||||
@ -28,22 +31,32 @@ export default function Login() {
|
||||
<div className="login-box">
|
||||
<h1 className="login-title">Login</h1>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<input
|
||||
<FormInput
|
||||
type="text"
|
||||
className="login-input"
|
||||
placeholder="Username"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
className="login-input"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="login-password-wrapper">
|
||||
<FormInput
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-password-toggle"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? "👀" : "🙈"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-button">Login</button>
|
||||
</form>
|
||||
|
||||
@ -2,8 +2,9 @@ import { useContext, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { loginRequest, registerRequest } from "../api/auth";
|
||||
import { checkIfUserExists } from "../api/users";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import FormInput from "../components/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
import "../styles/Register.css";
|
||||
|
||||
export default function Register() {
|
||||
@ -19,25 +20,25 @@ export default function Register() {
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
checkIfUserExistsHandler();
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => { checkIfUserExistsHandler(); }, [username]);
|
||||
async function checkIfUserExistsHandler() {
|
||||
setUserExists((await checkIfUserExists(username)).data);
|
||||
}
|
||||
|
||||
useEffect(() => { setError(userExists ? `Username '${username}' already taken` : ""); }, [userExists]);
|
||||
useEffect(() => {
|
||||
setError(userExists ? `Username '${username}' already taken` : "");
|
||||
}, [userExists]);
|
||||
|
||||
useEffect(() => {
|
||||
setPasswordMatches(
|
||||
!password ||
|
||||
!confirm ||
|
||||
password === confirm
|
||||
);
|
||||
setPasswordMatches(!password || !confirm || password === confirm);
|
||||
}, [password, confirm]);
|
||||
|
||||
useEffect(() => { setError(passwordMatches ? "" : "Passwords are not matching"); }, [passwordMatches]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setError(passwordMatches ? "" : "Passwords are not matching");
|
||||
}, [passwordMatches]);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
@ -46,51 +47,46 @@ export default function Register() {
|
||||
|
||||
try {
|
||||
await registerRequest(username, password, name);
|
||||
console.log("Registered user:", username);
|
||||
const data = await loginRequest(username, password);
|
||||
console.log(data);
|
||||
login(data);
|
||||
setSuccess("Account created! Redirecting the grocery list...");
|
||||
setSuccess("Account created! Redirecting to the grocery list...");
|
||||
setTimeout(() => navigate("/"), 2000);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || "Registration failed");
|
||||
setTimeout(() => {
|
||||
setError("");
|
||||
}, 1000);
|
||||
setTimeout(() => setError(""), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="register-container">
|
||||
<h1>Register</h1>
|
||||
|
||||
{<p className="error-message">{error}</p>}
|
||||
{success && <p className="success-message">{success}</p>}
|
||||
<ErrorMessage message={error} />
|
||||
<ErrorMessage message={success} type="success" />
|
||||
|
||||
<form className="register-form" onSubmit={submit}>
|
||||
<input
|
||||
<FormInput
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
<FormInput
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
onKeyUp={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
<FormInput
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
<FormInput
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
|
||||
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 {
|
||||
padding: 0.7em;
|
||||
background: #e9ecef;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 0.6em;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.8em;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glist-li:hover {
|
||||
background: #dee2e6;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glist-item-layout {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 0em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glist-item-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
min-width: 80px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image {
|
||||
border-color: #007bff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.glist-item-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image:hover {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.glist-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glist-item-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.glist-item-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.glist-item-quantity {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 123, 255, 0.9);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.3em;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0 6px 0 4px;
|
||||
min-width: 20%;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.glist-item-users {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sorting dropdown */
|
||||
@ -101,6 +185,62 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Image upload */
|
||||
.glist-image-upload {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.glist-image-label {
|
||||
display: block;
|
||||
padding: 0.6em;
|
||||
background: #f0f0f0;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.glist-image-label:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.glist-image-preview {
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.glist-image-preview img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.glist-remove-image {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.glist-remove-image:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.glist-fab {
|
||||
position: fixed;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.login-password-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.login-password-wrapper .login-input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-password-toggle {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
padding: 0.4em;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.login-password-toggle:hover {
|
||||
opacity: 1;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 0.7em;
|
||||
|
||||
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,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Costco-Grocery-List",
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"jest": "^30.2.0",
|
||||
@ -3745,9 +3746,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user