Merge pull request 'dev' (#1) from dev into main
Reviewed-on: https://git.nicosaya.com/nalalangan/costco-grocery-list/pulls/1
This commit is contained in:
commit
0b0283127b
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
|
||||
336
CLASSIFICATION_IMPLEMENTATION.md
Normal file
336
CLASSIFICATION_IMPLEMENTATION.md
Normal file
@ -0,0 +1,336 @@
|
||||
# Item Classification Implementation Guide
|
||||
|
||||
## Overview
|
||||
This implementation adds a classification system to the grocery app allowing users to categorize items by type, group, and store zone. The system supports both creating new items with classification and editing existing items.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Table: `item_classification`
|
||||
```sql
|
||||
CREATE TABLE item_classification (
|
||||
id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE,
|
||||
item_type VARCHAR(50) NOT NULL,
|
||||
item_group VARCHAR(100) NOT NULL,
|
||||
zone VARCHAR(100),
|
||||
confidence DECIMAL(3,2) DEFAULT 1.0,
|
||||
source VARCHAR(20) DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- One-to-one relationship with `grocery_list` (id is both PK and FK)
|
||||
- Cascade delete ensures classification is removed when item is deleted
|
||||
- Classification values are NOT enforced by DB - controlled at app layer
|
||||
- `confidence`: 1.0 for user input, lower values reserved for future ML features
|
||||
- `source`: 'user', 'ml', or 'default'
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
**Constants** ([backend/constants/classifications.js](backend/constants/classifications.js)):
|
||||
- `ITEM_TYPES`: 11 predefined types (produce, meat, dairy, etc.)
|
||||
- `ITEM_GROUPS`: Nested object mapping types to their valid groups
|
||||
- `ZONES`: 10 Costco store zones
|
||||
- Validation helpers: `isValidItemType()`, `isValidItemGroup()`, `isValidZone()`
|
||||
|
||||
**Models** ([backend/models/list.model.js](backend/models/list.model.js)):
|
||||
- `getClassification(itemId)`: Returns classification for an item or null
|
||||
- `upsertClassification(itemId, classification)`: INSERT or UPDATE using ON CONFLICT
|
||||
- `updateItem(id, itemName, quantity)`: Update item name/quantity
|
||||
|
||||
**Controllers** ([backend/controllers/lists.controller.js](backend/controllers/lists.controller.js)):
|
||||
- `getClassification(req, res)`: GET endpoint to fetch classification
|
||||
- `updateItemWithClassification(req, res)`: Validates and updates item + classification
|
||||
|
||||
**Routes** ([backend/routes/list.routes.js](backend/routes/list.routes.js)):
|
||||
- `GET /list/item/:id/classification` - Fetch classification (all roles)
|
||||
- `PUT /list/item/:id` - Update item + classification (editor/admin)
|
||||
|
||||
### Frontend
|
||||
|
||||
**Constants** ([frontend/src/constants/classifications.js](frontend/src/constants/classifications.js)):
|
||||
- Mirrors backend constants for UI rendering
|
||||
- `getItemTypeLabel()`: Converts type keys to display names
|
||||
|
||||
**API Methods** ([frontend/src/api/list.js](frontend/src/api/list.js)):
|
||||
- `getClassification(id)`: Fetch classification for item
|
||||
- `updateItemWithClassification(id, itemName, quantity, classification)`: Update item
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **EditItemModal** ([frontend/src/components/EditItemModal.jsx](frontend/src/components/EditItemModal.jsx))
|
||||
- Triggered by long-press (500ms) on any grocery item
|
||||
- Prepopulates with existing item data + classification
|
||||
- Cascading selects: item_type → item_group (filtered) → zone
|
||||
- Validation: Requires item_group if item_type is selected
|
||||
- Upserts classification with confidence=1.0, source='user'
|
||||
|
||||
2. **ItemClassificationModal** ([frontend/src/components/ItemClassificationModal.jsx](frontend/src/components/ItemClassificationModal.jsx))
|
||||
- Shown after image upload in new item flow
|
||||
- Required fields: item_type, item_group
|
||||
- Optional: zone
|
||||
- User can skip classification entirely
|
||||
|
||||
3. **GroceryListItem** ([frontend/src/components/GroceryListItem.jsx](frontend/src/components/GroceryListItem.jsx))
|
||||
- Long-press detection with 500ms timer
|
||||
- Supports both touch (mobile) and mouse (desktop) events
|
||||
- Cancels long-press if finger/mouse moves >10px
|
||||
|
||||
**Main Page** ([frontend/src/pages/GroceryList.jsx](frontend/src/pages/GroceryList.jsx)):
|
||||
- Orchestrates all modal flows
|
||||
- Add flow: Name → Similar check → Image upload → **Classification** → Save
|
||||
- Edit flow: Long-press → Load classification → Edit → Save
|
||||
|
||||
## User Flows
|
||||
|
||||
### FEATURE 1: Edit Existing Item
|
||||
|
||||
**Trigger:** Long-press (500ms) on any item in the list
|
||||
|
||||
**Steps:**
|
||||
1. User long-presses an item
|
||||
2. System fetches existing classification (if any)
|
||||
3. EditItemModal opens with prepopulated data
|
||||
4. User can edit:
|
||||
- Item name
|
||||
- Quantity
|
||||
- Classification (type, group, zone)
|
||||
5. On save:
|
||||
- Updates `grocery_list` if name/quantity changed
|
||||
- UPSERTS `item_classification` if classification provided
|
||||
- Sets confidence=1.0, source='user' for user-edited classification
|
||||
|
||||
**Validation:**
|
||||
- Item name required
|
||||
- Quantity must be ≥ 1
|
||||
- If item_type selected, item_group is required
|
||||
- item_group options filtered by selected item_type
|
||||
|
||||
### FEATURE 2: Add New Item with Classification
|
||||
|
||||
**Enhanced flow:**
|
||||
1. User enters item name
|
||||
2. System checks for similar items (80% threshold)
|
||||
3. User confirms/edits name
|
||||
4. User uploads image (or skips)
|
||||
5. **NEW:** ItemClassificationModal appears
|
||||
- User selects type, group, zone (optional)
|
||||
- Or skips classification
|
||||
6. Item saved to `grocery_list`
|
||||
7. If classification provided, saved to `item_classification`
|
||||
|
||||
## Data Flow Examples
|
||||
|
||||
### Adding Item with Classification
|
||||
```javascript
|
||||
// 1. Add item to grocery_list
|
||||
const response = await addItem(itemName, quantity, imageFile);
|
||||
|
||||
// 2. Get item ID
|
||||
const item = await getItemByName(itemName);
|
||||
|
||||
// 3. Add classification
|
||||
await updateItemWithClassification(item.id, undefined, undefined, {
|
||||
item_type: 'produce',
|
||||
item_group: 'Fruits',
|
||||
zone: 'Fresh Foods Right'
|
||||
});
|
||||
```
|
||||
|
||||
### Editing Item Classification
|
||||
```javascript
|
||||
// Update both item data and classification in one call
|
||||
await updateItemWithClassification(itemId, newName, newQuantity, {
|
||||
item_type: 'dairy',
|
||||
item_group: 'Cheese',
|
||||
zone: 'Dairy Cooler'
|
||||
});
|
||||
```
|
||||
|
||||
## Backend Request/Response Shapes
|
||||
|
||||
### GET /list/item/:id/classification
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"item_type": "produce",
|
||||
"item_group": "Fruits",
|
||||
"zone": "Fresh Foods Right",
|
||||
"confidence": 1.0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
Or `null` if no classification exists.
|
||||
|
||||
### PUT /list/item/:id
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"itemName": "Organic Apples",
|
||||
"quantity": 5,
|
||||
"classification": {
|
||||
"item_type": "produce",
|
||||
"item_group": "Organic Produce",
|
||||
"zone": "Fresh Foods Right"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Validates item_type against allowed values
|
||||
- Validates item_group is valid for the selected item_type
|
||||
- Validates zone against allowed zones
|
||||
- Returns 400 with error message if invalid
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Item updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Run Database Migration
|
||||
```bash
|
||||
psql -U your_user -d your_database -f backend/migrations/create_item_classification_table.sql
|
||||
```
|
||||
|
||||
### 2. Restart Backend
|
||||
The backend automatically loads the new classification constants and routes.
|
||||
|
||||
### 3. Test Flows
|
||||
|
||||
**Test Edit:**
|
||||
1. Long-press any item in the list
|
||||
2. Verify modal opens with item data
|
||||
3. Select a type, then a group from filtered list
|
||||
4. Save and verify item updates
|
||||
|
||||
**Test Add:**
|
||||
1. Add a new item
|
||||
2. Upload image (or skip)
|
||||
3. Verify classification modal appears
|
||||
4. Complete classification or skip
|
||||
5. Verify item appears in list
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
1. **Item Type → Item Group Dependency**
|
||||
- Must select item_type before item_group becomes available
|
||||
- Item group dropdown shows only groups for selected type
|
||||
|
||||
2. **Required Fields**
|
||||
- When creating: item_type and item_group are required
|
||||
- When editing: Classification is optional (can edit name/quantity only)
|
||||
|
||||
3. **No Free-Text**
|
||||
- All classification values are select dropdowns
|
||||
- Backend validates against predefined constants
|
||||
|
||||
4. **Graceful Handling**
|
||||
- Items without classification display normally
|
||||
- Edit modal works for both classified and unclassified items
|
||||
- Classification is always optional (can be skipped)
|
||||
|
||||
## State Management (Frontend)
|
||||
|
||||
**GroceryList.jsx state:**
|
||||
```javascript
|
||||
{
|
||||
showEditModal: false,
|
||||
editingItem: null, // Item + classification data
|
||||
showClassificationModal: false,
|
||||
classificationPendingItem: {
|
||||
itemName,
|
||||
quantity,
|
||||
imageFile
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Long-Press Implementation Details
|
||||
|
||||
**Timing:**
|
||||
- Desktop (mouse): 500ms hold
|
||||
- Mobile (touch): 500ms hold with <10px movement threshold
|
||||
|
||||
**Event Handlers:**
|
||||
- `onTouchStart`: Start timer, record position
|
||||
- `onTouchMove`: Cancel if movement >10px
|
||||
- `onTouchEnd`: Clear timer
|
||||
- `onMouseDown`: Start timer
|
||||
- `onMouseUp`: Clear timer
|
||||
- `onMouseLeave`: Clear timer (prevent stuck state)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **ML Predictions**: Use confidence <1.0 and source='ml' for auto-classification
|
||||
2. **Bulk Edit**: Select multiple items and apply same classification
|
||||
3. **Smart Suggestions**: Learn from user's classification patterns
|
||||
4. **Zone Optimization**: Suggest optimal shopping route based on zones
|
||||
5. **Analytics**: Most common types/groups, zone coverage
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue:** Classification not saving
|
||||
- Check browser console for validation errors
|
||||
- Verify item_type/item_group combination is valid
|
||||
- Ensure item_classification table exists
|
||||
|
||||
**Issue:** Long-press not triggering
|
||||
- Check that user has editor/admin role
|
||||
- Verify onLongPress prop is passed to GroceryListItem
|
||||
- Test on both mobile (touch) and desktop (mouse)
|
||||
|
||||
**Issue:** Item groups not filtering
|
||||
- Verify item_type is selected first
|
||||
- Check that ITEM_GROUPS constant has entries for the selected type
|
||||
- Ensure state updates are triggering re-render
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Backend
|
||||
- ✅ `backend/constants/classifications.js` (NEW)
|
||||
- ✅ `backend/models/list.model.js` (MODIFIED)
|
||||
- ✅ `backend/controllers/lists.controller.js` (MODIFIED)
|
||||
- ✅ `backend/routes/list.routes.js` (MODIFIED)
|
||||
- ✅ `backend/migrations/create_item_classification_table.sql` (NEW)
|
||||
|
||||
### Frontend
|
||||
- ✅ `frontend/src/constants/classifications.js` (NEW)
|
||||
- ✅ `frontend/src/api/list.js` (MODIFIED)
|
||||
- ✅ `frontend/src/components/EditItemModal.jsx` (NEW)
|
||||
- ✅ `frontend/src/components/ItemClassificationModal.jsx` (NEW)
|
||||
- ✅ `frontend/src/components/GroceryListItem.jsx` (MODIFIED)
|
||||
- ✅ `frontend/src/pages/GroceryList.jsx` (MODIFIED)
|
||||
- ✅ `frontend/src/styles/EditItemModal.css` (NEW)
|
||||
- ✅ `frontend/src/styles/ItemClassificationModal.css` (NEW)
|
||||
|
||||
## API Summary
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/list/item/:id/classification` | All roles | Get item classification |
|
||||
| PUT | `/list/item/:id` | Editor/Admin | Update item + classification |
|
||||
|
||||
## Database Operations
|
||||
|
||||
**Upsert Pattern:**
|
||||
```sql
|
||||
INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
zone = EXCLUDED.zone,
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
RETURNING *;
|
||||
```
|
||||
|
||||
This ensures we INSERT if no classification exists, or UPDATE if it does.
|
||||
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)
|
||||
143
SETUP_CHECKLIST.md
Normal file
143
SETUP_CHECKLIST.md
Normal file
@ -0,0 +1,143 @@
|
||||
# Item Classification - Setup Checklist
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Step 1: Run Database Migration
|
||||
```bash
|
||||
# Connect to your PostgreSQL database and run:
|
||||
psql -U your_username -d your_database_name -f backend/migrations/create_item_classification_table.sql
|
||||
|
||||
# Or copy-paste the SQL directly into your database client
|
||||
```
|
||||
|
||||
### Step 2: Restart Docker Containers
|
||||
```bash
|
||||
# From project root:
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
### Step 3: Test the Features
|
||||
|
||||
#### ✅ Test Edit Flow (Long-Press)
|
||||
1. Open the app and navigate to your grocery list
|
||||
2. **Long-press** (500ms) on any existing item
|
||||
3. EditItemModal should open with prepopulated data
|
||||
4. Try selecting:
|
||||
- Item Type: "Produce"
|
||||
- Item Group: "Fruits" (filtered by type)
|
||||
- Zone: "Fresh Foods Right" (optional)
|
||||
5. Click "Save Changes"
|
||||
6. Item should update successfully
|
||||
|
||||
#### ✅ Test Add Flow (with Classification)
|
||||
1. Click the "+" button to add a new item
|
||||
2. Enter item name: "Organic Bananas"
|
||||
3. Set quantity: 3
|
||||
4. Click "Add Item"
|
||||
5. Upload an image (or skip)
|
||||
6. **NEW:** ItemClassificationModal appears
|
||||
7. Select:
|
||||
- Item Type: "Produce"
|
||||
- Item Group: "Organic Produce"
|
||||
- Zone: "Fresh Foods Right"
|
||||
8. Click "Confirm"
|
||||
9. Item should appear in list
|
||||
|
||||
#### ✅ Test Skip Classification
|
||||
1. Add a new item
|
||||
2. Upload/skip image
|
||||
3. When ItemClassificationModal appears, click "Skip for Now"
|
||||
4. Item should be added without classification
|
||||
5. Long-press the item to add classification later
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### Long-press not working?
|
||||
- Make sure you're logged in as editor or admin
|
||||
- Try both mobile (touch) and desktop (mouse)
|
||||
- Check browser console for errors
|
||||
|
||||
### Classification not saving?
|
||||
- Verify database migration was successful
|
||||
- Check that item_classification table exists
|
||||
- Look for validation errors in browser console
|
||||
|
||||
### Item groups not showing?
|
||||
- Ensure you selected an item type first
|
||||
- The groups are filtered by the selected type
|
||||
|
||||
## 📊 What Was Implemented
|
||||
|
||||
### Backend (Node.js + Express + PostgreSQL)
|
||||
- ✅ Classification constants with validation helpers
|
||||
- ✅ Database model methods (getClassification, upsertClassification, updateItem)
|
||||
- ✅ API endpoints (GET /list/item/:id/classification, PUT /list/item/:id)
|
||||
- ✅ Controller with validation logic
|
||||
- ✅ Database migration script
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
- ✅ Classification constants (mirrored from backend)
|
||||
- ✅ EditItemModal component with cascading selects
|
||||
- ✅ ItemClassificationModal for new items
|
||||
- ✅ Long-press detection (500ms) on GroceryListItem
|
||||
- ✅ Enhanced add flow with classification step
|
||||
- ✅ API integration methods
|
||||
|
||||
## 📝 Key Features
|
||||
|
||||
1. **Edit Existing Items**
|
||||
- Long-press any item (bought or unbought)
|
||||
- Edit name, quantity, and classification
|
||||
- Classification is optional
|
||||
|
||||
2. **Classify New Items**
|
||||
- After image upload, classification step appears
|
||||
- Can skip classification if desired
|
||||
- Required: item_type and item_group
|
||||
- Optional: zone
|
||||
|
||||
3. **Smart Filtering**
|
||||
- Item groups filtered by selected item type
|
||||
- Only valid combinations allowed
|
||||
- No free-text entry
|
||||
|
||||
4. **Confidence Tracking**
|
||||
- User-provided: confidence=1.0, source='user'
|
||||
- Ready for future ML features (confidence<1.0, source='ml')
|
||||
|
||||
## 📖 Full Documentation
|
||||
|
||||
See [CLASSIFICATION_IMPLEMENTATION.md](CLASSIFICATION_IMPLEMENTATION.md) for:
|
||||
- Complete architecture details
|
||||
- API request/response examples
|
||||
- Data flow diagrams
|
||||
- Troubleshooting guide
|
||||
- Future enhancement ideas
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. Run the database migration (Step 1 above)
|
||||
2. Restart your containers (Step 2 above)
|
||||
3. Test both flows (Step 3 above)
|
||||
4. Optionally: Customize the classification constants in:
|
||||
- `backend/constants/classifications.js`
|
||||
- `frontend/src/constants/classifications.js`
|
||||
|
||||
## ⚡ Pro Tips
|
||||
|
||||
- **Long-press timing**: 500ms - not too fast, not too slow
|
||||
- **Movement threshold**: Keep finger/mouse within 10px to trigger
|
||||
- **Desktop testing**: Hold mouse button down for 500ms
|
||||
- **Skip button**: Always available - classification is never forced
|
||||
- **Edit anytime**: Long-press to edit classification after creation
|
||||
|
||||
## 🎨 UI/UX Notes
|
||||
|
||||
- **EditItemModal**: Full-screen on mobile, centered card on desktop
|
||||
- **ItemClassificationModal**: Appears after image upload (seamless flow)
|
||||
- **Long-press**: Provides haptic feedback on supported devices
|
||||
- **Validation**: Inline validation with user-friendly error messages
|
||||
- **Loading states**: "Saving..." text during API calls
|
||||
|
||||
Enjoy your new classification system! 🎉
|
||||
180
backend/constants/classifications.js
Normal file
180
backend/constants/classifications.js
Normal file
@ -0,0 +1,180 @@
|
||||
// Backend classification constants (mirror of frontend)
|
||||
|
||||
const ITEM_TYPES = {
|
||||
PRODUCE: "produce",
|
||||
MEAT: "meat",
|
||||
DAIRY: "dairy",
|
||||
BAKERY: "bakery",
|
||||
FROZEN: "frozen",
|
||||
PANTRY: "pantry",
|
||||
BEVERAGE: "beverage",
|
||||
SNACK: "snack",
|
||||
HOUSEHOLD: "household",
|
||||
PERSONAL_CARE: "personal_care",
|
||||
OTHER: "other",
|
||||
};
|
||||
|
||||
const ITEM_GROUPS = {
|
||||
[ITEM_TYPES.PRODUCE]: [
|
||||
"Fruits",
|
||||
"Vegetables",
|
||||
"Salad Mix",
|
||||
"Herbs",
|
||||
"Organic Produce",
|
||||
],
|
||||
[ITEM_TYPES.MEAT]: [
|
||||
"Beef",
|
||||
"Pork",
|
||||
"Chicken",
|
||||
"Seafood",
|
||||
"Deli Meat",
|
||||
"Prepared Meat",
|
||||
],
|
||||
[ITEM_TYPES.DAIRY]: [
|
||||
"Milk",
|
||||
"Cheese",
|
||||
"Yogurt",
|
||||
"Butter",
|
||||
"Eggs",
|
||||
"Cream",
|
||||
],
|
||||
[ITEM_TYPES.BAKERY]: [
|
||||
"Bread",
|
||||
"Rolls",
|
||||
"Pastries",
|
||||
"Cakes",
|
||||
"Bagels",
|
||||
"Tortillas",
|
||||
],
|
||||
[ITEM_TYPES.FROZEN]: [
|
||||
"Frozen Meals",
|
||||
"Ice Cream",
|
||||
"Frozen Vegetables",
|
||||
"Frozen Meat",
|
||||
"Pizza",
|
||||
"Desserts",
|
||||
],
|
||||
[ITEM_TYPES.PANTRY]: [
|
||||
"Canned Goods",
|
||||
"Pasta",
|
||||
"Rice",
|
||||
"Cereal",
|
||||
"Condiments",
|
||||
"Spices",
|
||||
"Baking",
|
||||
"Oils",
|
||||
],
|
||||
[ITEM_TYPES.BEVERAGE]: [
|
||||
"Water",
|
||||
"Soda",
|
||||
"Juice",
|
||||
"Coffee",
|
||||
"Tea",
|
||||
"Alcohol",
|
||||
"Sports Drinks",
|
||||
],
|
||||
[ITEM_TYPES.SNACK]: [
|
||||
"Chips",
|
||||
"Crackers",
|
||||
"Nuts",
|
||||
"Candy",
|
||||
"Cookies",
|
||||
"Protein Bars",
|
||||
],
|
||||
[ITEM_TYPES.HOUSEHOLD]: [
|
||||
"Cleaning Supplies",
|
||||
"Paper Products",
|
||||
"Laundry",
|
||||
"Kitchen Items",
|
||||
"Storage",
|
||||
],
|
||||
[ITEM_TYPES.PERSONAL_CARE]: [
|
||||
"Bath & Body",
|
||||
"Hair Care",
|
||||
"Oral Care",
|
||||
"Skincare",
|
||||
"Health",
|
||||
],
|
||||
[ITEM_TYPES.OTHER]: [
|
||||
"Miscellaneous",
|
||||
],
|
||||
};
|
||||
|
||||
// Store zones - path-oriented physical shopping areas
|
||||
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||
const ZONES = {
|
||||
ENTRANCE: "Entrance & Seasonal",
|
||||
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||
DELI_PREPARED: "Deli & Prepared Foods",
|
||||
BAKERY_SECTION: "Bakery",
|
||||
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||
FROZEN_FOODS: "Frozen Foods",
|
||||
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
|
||||
BEVERAGES: "Beverages & Water",
|
||||
SNACKS_CANDY: "Snacks & Candy",
|
||||
HOUSEHOLD_CLEANING: "Household & Cleaning",
|
||||
HEALTH_BEAUTY: "Health & Beauty",
|
||||
CHECKOUT_AREA: "Checkout Area",
|
||||
};
|
||||
|
||||
// Default zone mapping for each item type
|
||||
// This determines where items are typically found in the store
|
||||
const ITEM_TYPE_TO_ZONE = {
|
||||
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
|
||||
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
|
||||
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
|
||||
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
|
||||
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
|
||||
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
|
||||
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
|
||||
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
|
||||
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
|
||||
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
|
||||
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
|
||||
};
|
||||
|
||||
// Optimal walking flow through the store
|
||||
// Represents a typical shopping path that minimizes backtracking
|
||||
// Start with perimeter (fresh items), then move to center aisles, end at checkout
|
||||
const ZONE_FLOW = [
|
||||
ZONES.ENTRANCE,
|
||||
ZONES.PRODUCE_SECTION,
|
||||
ZONES.MEAT_SEAFOOD,
|
||||
ZONES.DELI_PREPARED,
|
||||
ZONES.BAKERY_SECTION,
|
||||
ZONES.DAIRY_SECTION,
|
||||
ZONES.FROZEN_FOODS,
|
||||
ZONES.DRY_GOODS_CENTER,
|
||||
ZONES.BEVERAGES,
|
||||
ZONES.SNACKS_CANDY,
|
||||
ZONES.HOUSEHOLD_CLEANING,
|
||||
ZONES.HEALTH_BEAUTY,
|
||||
ZONES.CHECKOUT_AREA,
|
||||
];
|
||||
|
||||
// Validation helpers
|
||||
const isValidItemType = (type) => Object.values(ITEM_TYPES).includes(type);
|
||||
|
||||
const isValidItemGroup = (type, group) => {
|
||||
if (!isValidItemType(type)) return false;
|
||||
return ITEM_GROUPS[type]?.includes(group) || false;
|
||||
};
|
||||
|
||||
const isValidZone = (zone) => Object.values(ZONES).includes(zone);
|
||||
|
||||
const getSuggestedZone = (itemType) => {
|
||||
return ITEM_TYPE_TO_ZONE[itemType] || null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ITEM_TYPES,
|
||||
ITEM_GROUPS,
|
||||
ZONES,
|
||||
ITEM_TYPE_TO_ZONE,
|
||||
ZONE_FLOW,
|
||||
isValidItemType,
|
||||
isValidItemGroup,
|
||||
isValidZone,
|
||||
getSuggestedZone,
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
const List = require("../models/list.model");
|
||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
|
||||
|
||||
exports.getList = async (req, res) => {
|
||||
@ -15,17 +16,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 +42,77 @@ exports.getSuggestions = async (req, res) => {
|
||||
const suggestions = await List.getSuggestions(query);
|
||||
res.json(suggestions);
|
||||
};
|
||||
|
||||
exports.getRecentlyBought = async (req, res) => {
|
||||
const items = await List.getRecentlyBoughtItems();
|
||||
res.json(items);
|
||||
};
|
||||
|
||||
exports.updateItemImage = async (req, res) => {
|
||||
const { id, itemName, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 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" });
|
||||
};
|
||||
|
||||
exports.getClassification = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const classification = await List.getClassification(id);
|
||||
res.json(classification);
|
||||
};
|
||||
|
||||
exports.updateItemWithClassification = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { itemName, quantity, classification } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
// Update item name and quantity if changed
|
||||
if (itemName !== undefined || quantity !== undefined) {
|
||||
await List.updateItem(id, itemName, quantity);
|
||||
}
|
||||
|
||||
// Update classification if provided
|
||||
if (classification) {
|
||||
const { item_type, item_group, zone } = classification;
|
||||
|
||||
// Validate classification data
|
||||
if (item_type && !isValidItemType(item_type)) {
|
||||
return res.status(400).json({ message: "Invalid item_type" });
|
||||
}
|
||||
|
||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||
return res.status(400).json({ message: "Invalid item_group for selected item_type" });
|
||||
}
|
||||
|
||||
if (zone && !isValidZone(zone)) {
|
||||
return res.status(400).json({ message: "Invalid zone" });
|
||||
}
|
||||
|
||||
// Upsert classification with confidence=1.0 and source='user'
|
||||
await List.upsertClassification(id, {
|
||||
item_type,
|
||||
item_group,
|
||||
zone: zone || null,
|
||||
confidence: 1.0,
|
||||
source: 'user'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Item updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error updating item with classification:", error);
|
||||
res.status(500).json({ message: "Failed to update item" });
|
||||
}
|
||||
};
|
||||
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`.
|
||||
8
backend/migrations/add_modified_on_column.sql
Normal file
8
backend/migrations/add_modified_on_column.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- Add modified_on column to grocery_list table
|
||||
ALTER TABLE grocery_list
|
||||
ADD COLUMN modified_on TIMESTAMP DEFAULT NOW();
|
||||
|
||||
-- Set modified_on to NOW() for existing records
|
||||
UPDATE grocery_list
|
||||
SET modified_on = NOW()
|
||||
WHERE modified_on IS NULL;
|
||||
29
backend/migrations/create_item_classification_table.sql
Normal file
29
backend/migrations/create_item_classification_table.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- Migration: Create item_classification table
|
||||
-- This table stores classification data for items in the grocery_list table
|
||||
-- Each row in grocery_list can have ONE corresponding classification row
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_classification (
|
||||
id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE,
|
||||
item_type VARCHAR(50) NOT NULL,
|
||||
item_group VARCHAR(100) NOT NULL,
|
||||
zone VARCHAR(100),
|
||||
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
|
||||
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for faster lookups by type
|
||||
CREATE INDEX IF NOT EXISTS idx_item_classification_type ON item_classification(item_type);
|
||||
|
||||
-- Index for zone-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_item_classification_zone ON item_classification(zone);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE item_classification IS 'Stores classification metadata for grocery list items';
|
||||
COMMENT ON COLUMN item_classification.id IS 'Foreign key to grocery_list.id (one-to-one relationship)';
|
||||
COMMENT ON COLUMN item_classification.item_type IS 'High-level category (produce, meat, dairy, etc.)';
|
||||
COMMENT ON COLUMN item_classification.item_group IS 'Subcategory within item_type (filtered by type)';
|
||||
COMMENT ON COLUMN item_classification.zone IS 'Store zone/location (optional)';
|
||||
COMMENT ON COLUMN item_classification.confidence IS 'Confidence score 0-1 (1.0 for user-provided, lower for ML-predicted)';
|
||||
COMMENT ON COLUMN item_classification.source IS 'Source of classification: user, ml, or default';
|
||||
@ -3,7 +3,26 @@ 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,
|
||||
LOWER(gl.item_name) AS item_name,
|
||||
gl.quantity,
|
||||
gl.bought,
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
gl.image_mime_type,
|
||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
||||
gl.modified_on as last_added_on,
|
||||
ic.item_type,
|
||||
ic.item_group,
|
||||
ic.zone
|
||||
FROM grocery_list gl
|
||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
||||
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||
WHERE gl.bought = FALSE
|
||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone
|
||||
ORDER BY gl.id ASC`
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
@ -18,43 +37,62 @@ exports.getItemByName = async (itemName) => {
|
||||
};
|
||||
|
||||
|
||||
exports.addOrUpdateItem = async (itemName, quantity) => {
|
||||
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
|
||||
const result = await pool.query(
|
||||
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
||||
[itemName]
|
||||
);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
// Update existing item
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET quantity = $1,
|
||||
bought = FALSE
|
||||
bought = FALSE,
|
||||
item_image = $3,
|
||||
image_mime_type = $4,
|
||||
modified_on = NOW()
|
||||
WHERE id = $2`,
|
||||
[quantity, result.rows[0].id, imageBuffer, mimeType]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
modified_on = NOW()
|
||||
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) => {
|
||||
await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]);
|
||||
exports.setBought = async (id, userId) => {
|
||||
await pool.query(
|
||||
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() 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]
|
||||
);
|
||||
};
|
||||
|
||||
@ -71,3 +109,67 @@ exports.getSuggestions = async (query) => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getRecentlyBoughtItems = async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
gl.id,
|
||||
LOWER(gl.item_name) AS item_name,
|
||||
gl.quantity,
|
||||
gl.bought,
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
gl.image_mime_type,
|
||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
||||
gl.modified_on as last_added_on
|
||||
FROM grocery_list gl
|
||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
||||
WHERE gl.bought = TRUE
|
||||
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on
|
||||
ORDER BY gl.modified_on DESC`
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Classification methods
|
||||
exports.getClassification = async (itemId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT item_type, item_group, zone, confidence, source
|
||||
FROM item_classification
|
||||
WHERE id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.upsertClassification = async (itemId, classification) => {
|
||||
const { item_type, item_group, zone, confidence, source } = classification;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
zone = EXCLUDED.zone,
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
RETURNING *`,
|
||||
[itemId, item_type, item_group, zone, confidence, source]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
exports.updateItem = async (id, itemName, quantity) => {
|
||||
const result = await pool.query(
|
||||
`UPDATE grocery_list
|
||||
SET item_name = $2, quantity = $3, modified_on = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, itemName, quantity]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
|
||||
16
backend/nodemon.json
Normal file
16
backend/nodemon.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"watch": [
|
||||
"**/*.js",
|
||||
".env"
|
||||
],
|
||||
"ext": "js,json",
|
||||
"ignore": [
|
||||
"node_modules/**",
|
||||
"dist/**"
|
||||
],
|
||||
"legacyWatch": true,
|
||||
"verbose": true,
|
||||
"execMap": {
|
||||
"js": "node"
|
||||
}
|
||||
}
|
||||
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,16 +4,22 @@ 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");
|
||||
|
||||
|
||||
|
||||
router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList);
|
||||
router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName);
|
||||
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
|
||||
router.get("/recently-bought", auth, requireRole(...Object.values(ROLES)), controller.getRecentlyBought);
|
||||
router.get("/item/:id/classification", auth, requireRole(...Object.values(ROLES)), controller.getClassification);
|
||||
|
||||
|
||||
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
|
||||
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem);
|
||||
router.post("/update-image", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.updateItemImage);
|
||||
router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought);
|
||||
|
||||
router.put("/item/:id", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.updateItemWithClassification);
|
||||
|
||||
|
||||
module.exports = router;
|
||||
|
||||
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:
|
||||
|
||||
238
frontend/COMPONENT_STRUCTURE.md
Normal file
238
frontend/COMPONENT_STRUCTURE.md
Normal file
@ -0,0 +1,238 @@
|
||||
# Frontend Component Organization
|
||||
|
||||
This document describes the organized structure of the frontend codebase, implemented to improve maintainability as the application grows.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── api/ # API client functions
|
||||
│ ├── auth.js # Authentication endpoints
|
||||
│ ├── axios.js # Axios instance with interceptors
|
||||
│ ├── list.js # Grocery list endpoints
|
||||
│ └── users.js # User management endpoints
|
||||
│
|
||||
├── components/ # React components (organized by function)
|
||||
│ ├── common/ # Reusable UI components
|
||||
│ │ ├── ErrorMessage.jsx
|
||||
│ │ ├── FloatingActionButton.jsx
|
||||
│ │ ├── FormInput.jsx
|
||||
│ │ ├── SortDropdown.jsx
|
||||
│ │ ├── UserRoleCard.jsx
|
||||
│ │ └── index.js # Barrel exports
|
||||
│ │
|
||||
│ ├── modals/ # All modal/dialog components
|
||||
│ │ ├── AddImageModal.jsx
|
||||
│ │ ├── AddItemWithDetailsModal.jsx
|
||||
│ │ ├── ConfirmBuyModal.jsx
|
||||
│ │ ├── EditItemModal.jsx
|
||||
│ │ ├── ImageModal.jsx
|
||||
│ │ ├── ImageUploadModal.jsx
|
||||
│ │ ├── ItemClassificationModal.jsx
|
||||
│ │ ├── SimilarItemModal.jsx
|
||||
│ │ └── index.js # Barrel exports
|
||||
│ │
|
||||
│ ├── forms/ # Form components and input sections
|
||||
│ │ ├── AddItemForm.jsx
|
||||
│ │ ├── ClassificationSection.jsx
|
||||
│ │ ├── ImageUploadSection.jsx
|
||||
│ │ └── index.js # Barrel exports
|
||||
│ │
|
||||
│ ├── items/ # Item display and list components
|
||||
│ │ ├── GroceryItem.tsx
|
||||
│ │ ├── GroceryListItem.jsx
|
||||
│ │ ├── SuggestionList.tsx
|
||||
│ │ └── index.js # Barrel exports
|
||||
│ │
|
||||
│ └── layout/ # Layout and navigation components
|
||||
│ ├── AppLayout.jsx
|
||||
│ ├── Navbar.jsx
|
||||
│ └── index.js # Barrel exports
|
||||
│
|
||||
├── constants/ # Application constants
|
||||
│ ├── classifications.js # Item types, groups, zones
|
||||
│ └── roles.js # User roles (viewer, editor, admin)
|
||||
│
|
||||
├── context/ # React context providers
|
||||
│ └── AuthContext.jsx # Authentication context
|
||||
│
|
||||
├── pages/ # Top-level page components
|
||||
│ ├── AdminPanel.jsx # User management dashboard
|
||||
│ ├── GroceryList.jsx # Main grocery list page
|
||||
│ ├── Login.jsx # Login page
|
||||
│ └── Register.jsx # Registration page
|
||||
│
|
||||
├── styles/ # CSS files (organized by type)
|
||||
│ ├── pages/ # Page-specific styles
|
||||
│ │ ├── GroceryList.css
|
||||
│ │ ├── Login.css
|
||||
│ │ └── Register.css
|
||||
│ │
|
||||
│ ├── components/ # Component-specific styles
|
||||
│ │ ├── AddItemWithDetailsModal.css
|
||||
│ │ ├── ClassificationSection.css
|
||||
│ │ ├── EditItemModal.css
|
||||
│ │ ├── ImageUploadSection.css
|
||||
│ │ └── Navbar.css
|
||||
│ │
|
||||
│ ├── theme.css # **GLOBAL THEME VARIABLES** (colors, spacing, typography)
|
||||
│ ├── THEME_USAGE_EXAMPLES.css # Examples of using theme variables
|
||||
│ ├── App.css # Global app styles
|
||||
│ └── index.css # Root styles (uses theme variables)
|
||||
│
|
||||
├── utils/ # Utility functions
|
||||
│ ├── PrivateRoute.jsx # Authentication guard
|
||||
│ ├── RoleGuard.jsx # Role-based access guard
|
||||
│ └── stringSimilarity.js # String matching utilities
|
||||
│
|
||||
├── App.jsx # Root app component
|
||||
├── main.tsx # Application entry point
|
||||
├── config.ts # Configuration (API URL)
|
||||
└── types.ts # TypeScript type definitions
|
||||
```
|
||||
|
||||
## Import Patterns
|
||||
|
||||
### Using Barrel Exports (Recommended)
|
||||
|
||||
Barrel exports (`index.js` files) allow cleaner imports from component groups:
|
||||
|
||||
```javascript
|
||||
// ✅ Clean barrel import
|
||||
import { FloatingActionButton, SortDropdown } from '../components/common';
|
||||
import { EditItemModal, SimilarItemModal } from '../components/modals';
|
||||
import { AddItemForm, ClassificationSection } from '../components/forms';
|
||||
```
|
||||
|
||||
### Direct Imports (Alternative)
|
||||
|
||||
You can also import components directly when needed:
|
||||
|
||||
```javascript
|
||||
// Also valid
|
||||
import FloatingActionButton from '../components/common/FloatingActionButton';
|
||||
import EditItemModal from '../components/modals/EditItemModal';
|
||||
```
|
||||
|
||||
## Component Categories
|
||||
|
||||
### `common/` - Reusable UI Components
|
||||
- **Purpose**: Generic, reusable components used across multiple pages
|
||||
- **Examples**: Buttons, dropdowns, form inputs, error messages
|
||||
- **Characteristics**: Highly reusable, minimal business logic
|
||||
|
||||
### `modals/` - Dialog Components
|
||||
- **Purpose**: All modal/dialog/popup components
|
||||
- **Examples**: Confirmation dialogs, edit forms, image viewers
|
||||
- **Characteristics**: Overlay UI, typically used for focused interactions
|
||||
|
||||
### `forms/` - Form Sections
|
||||
- **Purpose**: Form-related components and input sections
|
||||
- **Examples**: Multi-step forms, reusable form sections
|
||||
- **Characteristics**: Handle user input, validation, form state
|
||||
|
||||
### `items/` - Item Display Components
|
||||
- **Purpose**: Components specific to displaying grocery items
|
||||
- **Examples**: Item cards, item lists, suggestion lists
|
||||
- **Characteristics**: Domain-specific (grocery items)
|
||||
|
||||
### `layout/` - Layout Components
|
||||
- **Purpose**: Application structure and navigation
|
||||
- **Examples**: Navigation bars, page layouts, wrappers
|
||||
- **Characteristics**: Define page structure, persistent UI elements
|
||||
|
||||
## Style Organization
|
||||
|
||||
### `styles/pages/`
|
||||
Page-specific styles that apply to entire page components.
|
||||
|
||||
### `styles/components/`
|
||||
Component-specific styles for individual reusable components.
|
||||
|
||||
## Benefits of This Structure
|
||||
|
||||
1. **Scalability**: Easy to add new components without cluttering directories
|
||||
2. **Discoverability**: Intuitive naming makes components easy to find
|
||||
3. **Maintainability**: Related code is grouped together
|
||||
4. **Separation of Concerns**: Clear boundaries between different types of components
|
||||
5. **Import Clarity**: Barrel exports reduce import statement complexity
|
||||
6. **Team Collaboration**: Clear conventions for where new code should go
|
||||
|
||||
## Adding New Components
|
||||
|
||||
When adding a new component, ask:
|
||||
|
||||
1. **Is it reusable across pages?** → `common/`
|
||||
2. **Is it a modal/dialog?** → `modals/`
|
||||
3. **Is it form-related?** → `forms/`
|
||||
4. **Is it specific to grocery items?** → `items/`
|
||||
5. **Does it define page structure?** → `layout/`
|
||||
6. **Is it a full page?** → `pages/`
|
||||
|
||||
Then:
|
||||
1. Create the component in the appropriate subdirectory
|
||||
2. Add the component to the subdirectory's `index.js` barrel export
|
||||
3. Create corresponding CSS file in `styles/pages/` or `styles/components/`
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This structure was implemented on January 2, 2026 to organize 20+ components and 10+ CSS files that were previously in flat directories. All import paths have been updated to reflect the new structure.
|
||||
|
||||
## Theming System
|
||||
|
||||
The application uses a centralized theming system via CSS custom properties (variables) defined in `styles/theme.css`.
|
||||
|
||||
### Theme Variables
|
||||
|
||||
All design tokens are defined in `theme.css` including:
|
||||
|
||||
- **Colors**: Primary, secondary, semantic (success, danger, warning), neutrals
|
||||
- **Spacing**: Consistent spacing scale (xs, sm, md, lg, xl, 2xl, 3xl)
|
||||
- **Typography**: Font families, sizes, weights, line heights
|
||||
- **Borders**: Widths and radius values
|
||||
- **Shadows**: Box shadow presets
|
||||
- **Transitions**: Timing functions
|
||||
- **Z-index**: Layering system for modals, dropdowns, etc.
|
||||
|
||||
### Using Theme Variables
|
||||
|
||||
```css
|
||||
/* Instead of hardcoded values */
|
||||
.button-old {
|
||||
background: #007bff;
|
||||
padding: 0.6em 1.2em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Use theme variables */
|
||||
.button-new {
|
||||
background: var(--color-primary);
|
||||
padding: var(--button-padding-y) var(--button-padding-x);
|
||||
border-radius: var(--button-border-radius);
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Consistency**: All components use the same design tokens
|
||||
2. **Maintainability**: Change once in `theme.css`, updates everywhere
|
||||
3. **Theme Switching**: Easy to implement dark mode (already scaffolded)
|
||||
4. **Scalability**: Add new tokens without touching component styles
|
||||
5. **Documentation**: Variable names are self-documenting
|
||||
|
||||
### Utility Classes
|
||||
|
||||
The theme file includes utility classes for common patterns:
|
||||
|
||||
```html
|
||||
<!-- Spacing -->
|
||||
<div class="mt-3 mb-4 p-2">Content</div>
|
||||
|
||||
<!-- Text styling -->
|
||||
<span class="text-primary font-weight-bold">Important</span>
|
||||
|
||||
<!-- Flexbox -->
|
||||
<div class="d-flex justify-between align-center gap-2">Items</div>
|
||||
```
|
||||
|
||||
See `styles/THEME_USAGE_EXAMPLES.css` for complete examples of refactoring existing CSS to use theme variables.
|
||||
@ -7,7 +7,7 @@ import GroceryList from "./pages/GroceryList.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
import Register from "./pages/Register.jsx";
|
||||
|
||||
import AppLayout from "./components/AppLayout.jsx";
|
||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||
|
||||
import RoleGuard from "./utils/RoleGuard.jsx";
|
||||
|
||||
@ -2,6 +2,45 @@ 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 getClassification = (id) => api.get(`/list/item/${id}/classification`);
|
||||
|
||||
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
|
||||
return api.put(`/list/item/${id}`, {
|
||||
itemName,
|
||||
quantity,
|
||||
classification
|
||||
});
|
||||
};
|
||||
export const markBought = (id) => api.post("/list/mark-bought", { id });
|
||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
};
|
||||
7
frontend/src/components/common/ErrorMessage.jsx
Normal file
7
frontend/src/components/common/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/common/FloatingActionButton.jsx
Normal file
7
frontend/src/components/common/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/common/FormInput.jsx
Normal file
21
frontend/src/components/common/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/common/SortDropdown.jsx
Normal file
11
frontend/src/components/common/SortDropdown.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
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>
|
||||
<option value="zone">By Zone</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/common/UserRoleCard.jsx
Normal file
21
frontend/src/components/common/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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/common/index.js
Normal file
7
frontend/src/components/common/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
// Barrel export for common components
|
||||
export { default as ErrorMessage } from './ErrorMessage.jsx';
|
||||
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
|
||||
export { default as FormInput } from './FormInput.jsx';
|
||||
export { default as SortDropdown } from './SortDropdown.jsx';
|
||||
export { default as UserRoleCard } from './UserRoleCard.jsx';
|
||||
|
||||
99
frontend/src/components/forms/AddItemForm.jsx
Normal file
99
frontend/src/components/forms/AddItemForm.jsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import "../../styles/components/AddItemForm.css";
|
||||
import SuggestionList from "../items/SuggestionList";
|
||||
|
||||
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) {
|
||||
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);
|
||||
onSuggest(suggestion); // Trigger button text update
|
||||
};
|
||||
|
||||
const incrementQuantity = () => {
|
||||
setQuantity(prev => prev + 1);
|
||||
};
|
||||
|
||||
const decrementQuantity = () => {
|
||||
setQuantity(prev => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const isDisabled = !itemName.trim();
|
||||
|
||||
return (
|
||||
<div className="add-item-form-container">
|
||||
<form onSubmit={handleSubmit} className="add-item-form">
|
||||
<div className="add-item-form-field">
|
||||
<input
|
||||
type="text"
|
||||
className="add-item-form-input"
|
||||
placeholder="Enter 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="add-item-form-actions">
|
||||
<div className="add-item-form-quantity-control">
|
||||
<button
|
||||
type="button"
|
||||
className="quantity-btn quantity-btn-minus"
|
||||
onClick={decrementQuantity}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="add-item-form-quantity-input"
|
||||
value={quantity}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="quantity-btn quantity-btn-plus"
|
||||
onClick={incrementQuantity}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`add-item-form-submit ${isDisabled ? 'disabled' : ''}`}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/forms/ClassificationSection.jsx
Normal file
91
frontend/src/components/forms/ClassificationSection.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||
import "../../styles/components/ClassificationSection.css";
|
||||
|
||||
/**
|
||||
* Reusable classification component with cascading type/group/zone selects
|
||||
* @param {Object} props
|
||||
* @param {string} props.itemType - Selected item type
|
||||
* @param {string} props.itemGroup - Selected item group
|
||||
* @param {string} props.zone - Selected zone
|
||||
* @param {Function} props.onItemTypeChange - Callback for type change (newType)
|
||||
* @param {Function} props.onItemGroupChange - Callback for group change (newGroup)
|
||||
* @param {Function} props.onZoneChange - Callback for zone change (newZone)
|
||||
* @param {string} props.title - Section title (optional)
|
||||
* @param {string} props.fieldClass - CSS class for field containers (optional)
|
||||
* @param {string} props.selectClass - CSS class for select elements (optional)
|
||||
*/
|
||||
export default function ClassificationSection({
|
||||
itemType,
|
||||
itemGroup,
|
||||
zone,
|
||||
onItemTypeChange,
|
||||
onItemGroupChange,
|
||||
onZoneChange,
|
||||
title = "Item Classification (Optional)",
|
||||
fieldClass = "classification-field",
|
||||
selectClass = "classification-select"
|
||||
}) {
|
||||
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||
|
||||
const handleTypeChange = (e) => {
|
||||
const newType = e.target.value;
|
||||
onItemTypeChange(newType);
|
||||
// Parent should reset group when type changes
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="classification-section">
|
||||
<h3 className="classification-title">{title}</h3>
|
||||
|
||||
<div className={fieldClass}>
|
||||
<label>Item Type</label>
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={handleTypeChange}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">-- Select Type --</option>
|
||||
{Object.values(ITEM_TYPES).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getItemTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{itemType && (
|
||||
<div className={fieldClass}>
|
||||
<label>Item Group</label>
|
||||
<select
|
||||
value={itemGroup}
|
||||
onChange={(e) => onItemGroupChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">-- Select Group --</option>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{group}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={fieldClass}>
|
||||
<label>Store Zone</label>
|
||||
<select
|
||||
value={zone}
|
||||
onChange={(e) => onZoneChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{getZoneValues().map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/forms/ImageUploadSection.jsx
Normal file
77
frontend/src/components/forms/ImageUploadSection.jsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useRef } from "react";
|
||||
import "../../styles/components/ImageUploadSection.css";
|
||||
|
||||
/**
|
||||
* Reusable image upload component with camera and gallery options
|
||||
* @param {Object} props
|
||||
* @param {string} props.imagePreview - Base64 preview URL or null
|
||||
* @param {Function} props.onImageChange - Callback when image is selected (file)
|
||||
* @param {Function} props.onImageRemove - Callback to remove image
|
||||
* @param {string} props.title - Section title (optional)
|
||||
*/
|
||||
export default function ImageUploadSection({
|
||||
imagePreview,
|
||||
onImageChange,
|
||||
onImageRemove,
|
||||
title = "Item Image (Optional)"
|
||||
}) {
|
||||
const cameraInputRef = useRef(null);
|
||||
const galleryInputRef = useRef(null);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
onImageChange(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraClick = () => {
|
||||
cameraInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleGalleryClick = () => {
|
||||
galleryInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="image-upload-section">
|
||||
<h3 className="image-upload-title">{title}</h3>
|
||||
<div className="image-upload-content">
|
||||
{!imagePreview ? (
|
||||
<div className="image-upload-options">
|
||||
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button">
|
||||
📷 Use Camera
|
||||
</button>
|
||||
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button">
|
||||
🖼️ Choose from Gallery
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-upload-preview">
|
||||
<img src={imagePreview} alt="Preview" />
|
||||
<button type="button" onClick={onImageRemove} className="image-upload-remove">
|
||||
× Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={galleryInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/forms/index.js
Normal file
5
frontend/src/components/forms/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
// Barrel export for form components
|
||||
export { default as AddItemForm } from './AddItemForm.jsx';
|
||||
export { default as ClassificationSection } from './ClassificationSection.jsx';
|
||||
export { default as ImageUploadSection } from './ImageUploadSection.jsx';
|
||||
|
||||
178
frontend/src/components/items/GroceryListItem.jsx
Normal file
178
frontend/src/components/items/GroceryListItem.jsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useRef, useState } from "react";
|
||||
import AddImageModal from "../modals/AddImageModal";
|
||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||
import ImageModal from "../modals/ImageModal";
|
||||
|
||||
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||
|
||||
const longPressTimer = useRef(null);
|
||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
const touch = e.touches[0];
|
||||
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
|
||||
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (onLongPress) {
|
||||
onLongPress(item);
|
||||
}
|
||||
}, 500); // 500ms for long press
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
// Cancel long press if finger moves too much
|
||||
const touch = e.touches[0];
|
||||
const moveDistance = Math.sqrt(
|
||||
Math.pow(touch.clientX - pressStartPos.current.x, 2) +
|
||||
Math.pow(touch.clientY - pressStartPos.current.y, 2)
|
||||
);
|
||||
|
||||
if (moveDistance > 10) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
clearTimeout(longPressTimer.current);
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (onLongPress) {
|
||||
onLongPress(item);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
clearTimeout(longPressTimer.current);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(longPressTimer.current);
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (onClick) {
|
||||
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;
|
||||
|
||||
const getTimeAgo = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
|
||||
const addedDate = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - addedDate;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks}w ago`;
|
||||
} else {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `${months}m ago`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className="glist-li"
|
||||
onClick={handleItemClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<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.last_added_on && `${getTimeAgo(item.last_added_on)} -- `}
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
suggestions: string[];
|
||||
onSelect: (value: string) => void;
|
||||
@ -8,15 +10,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="suggestion-list"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #ccc",
|
||||
maxHeight: "150px",
|
||||
overflowY: "auto",
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
left: "1em",
|
||||
right: "1em",
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
5
frontend/src/components/items/index.js
Normal file
5
frontend/src/components/items/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
// Barrel export for item-related components
|
||||
export { default as GroceryItem } from './GroceryItem.tsx';
|
||||
export { default as GroceryListItem } from './GroceryListItem.jsx';
|
||||
export { default as SuggestionList } from './SuggestionList.tsx';
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import "../styles/Navbar.css";
|
||||
import "../../styles/components/Navbar.css";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
|
||||
export default function Navbar() {
|
||||
const { role, logout, username } = useContext(AuthContext);
|
||||
4
frontend/src/components/layout/index.js
Normal file
4
frontend/src/components/layout/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
// Barrel export for layout components
|
||||
export { default as AppLayout } from './AppLayout.jsx';
|
||||
export { default as Navbar } from './Navbar.jsx';
|
||||
|
||||
99
frontend/src/components/modals/AddImageModal.jsx
Normal file
99
frontend/src/components/modals/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>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/modals/AddItemWithDetailsModal.jsx
Normal file
96
frontend/src/components/modals/AddItemWithDetailsModal.jsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import "../../styles/components/AddItemWithDetailsModal.css";
|
||||
import ClassificationSection from "../forms/ClassificationSection";
|
||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||
|
||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemGroup, setItemGroup] = useState("");
|
||||
const [zone, setZone] = useState("");
|
||||
|
||||
const handleImageChange = (file) => {
|
||||
setSelectedImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleImageRemove = () => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
};
|
||||
|
||||
const handleItemTypeChange = (newType) => {
|
||||
setItemType(newType);
|
||||
setItemGroup(""); // Reset group when type changes
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// Validate classification if provided
|
||||
if (itemType && !itemGroup) {
|
||||
alert("Please select an item group");
|
||||
return;
|
||||
}
|
||||
|
||||
const classification = itemType ? {
|
||||
item_type: itemType,
|
||||
item_group: itemGroup,
|
||||
zone: zone || null
|
||||
} : null;
|
||||
|
||||
onConfirm(selectedImage, classification);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="add-item-details-overlay" onClick={onCancel}>
|
||||
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
||||
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
||||
|
||||
{/* Image Section */}
|
||||
<div className="add-item-details-section">
|
||||
<ImageUploadSection
|
||||
imagePreview={imagePreview}
|
||||
onImageChange={handleImageChange}
|
||||
onImageRemove={handleImageRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Classification Section */}
|
||||
<div className="add-item-details-section">
|
||||
<ClassificationSection
|
||||
itemType={itemType}
|
||||
itemGroup={itemGroup}
|
||||
zone={zone}
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
fieldClass="add-item-details-field"
|
||||
selectClass="add-item-details-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="add-item-details-actions">
|
||||
<button onClick={onCancel} className="add-item-details-btn cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSkip} className="add-item-details-btn skip">
|
||||
Skip All
|
||||
</button>
|
||||
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/modals/ConfirmBuyModal.jsx
Normal file
67
frontend/src/components/modals/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>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/modals/EditItemModal.jsx
Normal file
119
frontend/src/components/modals/EditItemModal.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import "../../styles/components/EditItemModal.css";
|
||||
import ClassificationSection from "../forms/ClassificationSection";
|
||||
|
||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
const [itemName, setItemName] = useState(item.item_name || "");
|
||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemGroup, setItemGroup] = useState("");
|
||||
const [zone, setZone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load existing classification
|
||||
useEffect(() => {
|
||||
if (item.classification) {
|
||||
setItemType(item.classification.item_type || "");
|
||||
setItemGroup(item.classification.item_group || "");
|
||||
setZone(item.classification.zone || "");
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const handleItemTypeChange = (newType) => {
|
||||
setItemType(newType);
|
||||
setItemGroup(""); // Reset group when type changes
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!itemName.trim()) {
|
||||
alert("Item name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (quantity < 1) {
|
||||
alert("Quantity must be at least 1");
|
||||
return;
|
||||
}
|
||||
|
||||
// If classification fields are filled, validate them
|
||||
if (itemType && !itemGroup) {
|
||||
alert("Please select an item group");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const classification = itemType ? {
|
||||
item_type: itemType,
|
||||
item_group: itemGroup,
|
||||
zone: zone || null
|
||||
} : null;
|
||||
|
||||
await onSave(item.id, itemName, quantity, classification);
|
||||
} catch (error) {
|
||||
console.error("Failed to save:", error);
|
||||
alert("Failed to save changes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="edit-modal-title">Edit Item</h2>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label>Item Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={itemName}
|
||||
onChange={(e) => setItemName(e.target.value)}
|
||||
className="edit-modal-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label>Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(parseInt(e.target.value))}
|
||||
className="edit-modal-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-divider" />
|
||||
|
||||
<ClassificationSection
|
||||
itemType={itemType}
|
||||
itemGroup={itemGroup}
|
||||
zone={zone}
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
fieldClass="edit-modal-field"
|
||||
selectClass="edit-modal-select"
|
||||
/>
|
||||
|
||||
<div className="edit-modal-actions">
|
||||
<button
|
||||
className="edit-modal-btn edit-modal-btn-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="edit-modal-btn edit-modal-btn-save"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/modals/ImageModal.jsx
Normal file
24
frontend/src/components/modals/ImageModal.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
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={onClose}>
|
||||
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
||||
<p className="image-modal-caption">{itemName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/modals/ImageUploadModal.jsx
Normal file
102
frontend/src/components/modals/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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/modals/ItemClassificationModal.jsx
Normal file
110
frontend/src/components/modals/ItemClassificationModal.jsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import "../../styles/ItemClassificationModal.css";
|
||||
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
||||
|
||||
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemGroup, setItemGroup] = useState("");
|
||||
const [zone, setZone] = useState("");
|
||||
|
||||
const handleItemTypeChange = (e) => {
|
||||
const newType = e.target.value;
|
||||
setItemType(newType);
|
||||
// Reset item group when type changes
|
||||
setItemGroup("");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!itemType) {
|
||||
alert("Please select an item type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!itemGroup) {
|
||||
alert("Please select an item group");
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
item_type: itemType,
|
||||
item_group: itemGroup,
|
||||
zone: zone || null
|
||||
});
|
||||
};
|
||||
|
||||
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||
|
||||
return (
|
||||
<div className="classification-modal-overlay">
|
||||
<div className="classification-modal-content">
|
||||
<h2 className="classification-modal-title">Classify Item</h2>
|
||||
<p className="classification-modal-subtitle">Help organize "{itemName}" in your list</p>
|
||||
|
||||
<div className="classification-modal-field">
|
||||
<label>Item Type <span className="required">*</span></label>
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={handleItemTypeChange}
|
||||
className="classification-modal-select"
|
||||
>
|
||||
<option value="">-- Select Type --</option>
|
||||
{Object.values(ITEM_TYPES).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getItemTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{itemType && (
|
||||
<div className="classification-modal-field">
|
||||
<label>Item Group <span className="required">*</span></label>
|
||||
<select
|
||||
value={itemGroup}
|
||||
onChange={(e) => setItemGroup(e.target.value)}
|
||||
className="classification-modal-select"
|
||||
>
|
||||
<option value="">-- Select Group --</option>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{group}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="classification-modal-field">
|
||||
<label>Store Zone (Optional)</label>
|
||||
<select
|
||||
value={zone}
|
||||
onChange={(e) => setZone(e.target.value)}
|
||||
className="classification-modal-select"
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{ZONES.map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="classification-modal-actions">
|
||||
<button
|
||||
className="classification-modal-btn classification-modal-btn-skip"
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<button
|
||||
className="classification-modal-btn classification-modal-btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/modals/SimilarItemModal.jsx
Normal file
29
frontend/src/components/modals/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/modals/index.js
Normal file
10
frontend/src/components/modals/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
// Barrel export for modal components
|
||||
export { default as AddImageModal } from './AddImageModal.jsx';
|
||||
export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx';
|
||||
export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx';
|
||||
export { default as EditItemModal } from './EditItemModal.jsx';
|
||||
export { default as ImageModal } from './ImageModal.jsx';
|
||||
export { default as ImageUploadModal } from './ImageUploadModal.jsx';
|
||||
export { default as ItemClassificationModal } from './ItemClassificationModal.jsx';
|
||||
export { default as SimilarItemModal } from './SimilarItemModal.jsx';
|
||||
|
||||
181
frontend/src/constants/classifications.js
Normal file
181
frontend/src/constants/classifications.js
Normal file
@ -0,0 +1,181 @@
|
||||
// Item classification constants - app-level controlled values
|
||||
|
||||
export const ITEM_TYPES = {
|
||||
PRODUCE: "produce",
|
||||
MEAT: "meat",
|
||||
DAIRY: "dairy",
|
||||
BAKERY: "bakery",
|
||||
FROZEN: "frozen",
|
||||
PANTRY: "pantry",
|
||||
BEVERAGE: "beverage",
|
||||
SNACK: "snack",
|
||||
HOUSEHOLD: "household",
|
||||
PERSONAL_CARE: "personal_care",
|
||||
OTHER: "other",
|
||||
};
|
||||
|
||||
// Item groups filtered by item type
|
||||
export const ITEM_GROUPS = {
|
||||
[ITEM_TYPES.PRODUCE]: [
|
||||
"Fruits",
|
||||
"Vegetables",
|
||||
"Salad Mix",
|
||||
"Herbs",
|
||||
"Organic Produce",
|
||||
],
|
||||
[ITEM_TYPES.MEAT]: [
|
||||
"Beef",
|
||||
"Pork",
|
||||
"Chicken",
|
||||
"Seafood",
|
||||
"Deli Meat",
|
||||
"Prepared Meat",
|
||||
],
|
||||
[ITEM_TYPES.DAIRY]: [
|
||||
"Milk",
|
||||
"Cheese",
|
||||
"Yogurt",
|
||||
"Butter",
|
||||
"Eggs",
|
||||
"Cream",
|
||||
],
|
||||
[ITEM_TYPES.BAKERY]: [
|
||||
"Bread",
|
||||
"Rolls",
|
||||
"Pastries",
|
||||
"Cakes",
|
||||
"Bagels",
|
||||
"Tortillas",
|
||||
],
|
||||
[ITEM_TYPES.FROZEN]: [
|
||||
"Frozen Meals",
|
||||
"Ice Cream",
|
||||
"Frozen Vegetables",
|
||||
"Frozen Meat",
|
||||
"Pizza",
|
||||
"Desserts",
|
||||
],
|
||||
[ITEM_TYPES.PANTRY]: [
|
||||
"Canned Goods",
|
||||
"Pasta",
|
||||
"Rice",
|
||||
"Cereal",
|
||||
"Condiments",
|
||||
"Spices",
|
||||
"Baking",
|
||||
"Oils",
|
||||
],
|
||||
[ITEM_TYPES.BEVERAGE]: [
|
||||
"Water",
|
||||
"Soda",
|
||||
"Juice",
|
||||
"Coffee",
|
||||
"Tea",
|
||||
"Alcohol",
|
||||
"Sports Drinks",
|
||||
],
|
||||
[ITEM_TYPES.SNACK]: [
|
||||
"Chips",
|
||||
"Crackers",
|
||||
"Nuts",
|
||||
"Candy",
|
||||
"Cookies",
|
||||
"Protein Bars",
|
||||
],
|
||||
[ITEM_TYPES.HOUSEHOLD]: [
|
||||
"Cleaning Supplies",
|
||||
"Paper Products",
|
||||
"Laundry",
|
||||
"Kitchen Items",
|
||||
"Storage",
|
||||
],
|
||||
[ITEM_TYPES.PERSONAL_CARE]: [
|
||||
"Bath & Body",
|
||||
"Hair Care",
|
||||
"Oral Care",
|
||||
"Skincare",
|
||||
"Health",
|
||||
],
|
||||
[ITEM_TYPES.OTHER]: [
|
||||
"Miscellaneous",
|
||||
],
|
||||
};
|
||||
|
||||
// Store zones - path-oriented physical shopping areas
|
||||
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||
export const ZONES = {
|
||||
ENTRANCE: "Entrance & Seasonal",
|
||||
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||
DELI_PREPARED: "Deli & Prepared Foods",
|
||||
BAKERY_SECTION: "Bakery",
|
||||
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||
FROZEN_FOODS: "Frozen Foods",
|
||||
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
|
||||
BEVERAGES: "Beverages & Water",
|
||||
SNACKS_CANDY: "Snacks & Candy",
|
||||
HOUSEHOLD_CLEANING: "Household & Cleaning",
|
||||
HEALTH_BEAUTY: "Health & Beauty",
|
||||
CHECKOUT_AREA: "Checkout Area",
|
||||
};
|
||||
|
||||
// Default zone mapping for each item type
|
||||
// This determines where items are typically found in the store
|
||||
export const ITEM_TYPE_TO_ZONE = {
|
||||
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
|
||||
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
|
||||
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
|
||||
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
|
||||
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
|
||||
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
|
||||
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
|
||||
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
|
||||
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
|
||||
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
|
||||
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
|
||||
};
|
||||
|
||||
// Optimal walking flow through the store
|
||||
// Represents a typical shopping path that minimizes backtracking
|
||||
// Start with perimeter (fresh items), then move to center aisles, end at checkout
|
||||
export const ZONE_FLOW = [
|
||||
ZONES.ENTRANCE,
|
||||
ZONES.PRODUCE_SECTION,
|
||||
ZONES.MEAT_SEAFOOD,
|
||||
ZONES.DELI_PREPARED,
|
||||
ZONES.BAKERY_SECTION,
|
||||
ZONES.DAIRY_SECTION,
|
||||
ZONES.FROZEN_FOODS,
|
||||
ZONES.DRY_GOODS_CENTER,
|
||||
ZONES.BEVERAGES,
|
||||
ZONES.SNACKS_CANDY,
|
||||
ZONES.HOUSEHOLD_CLEANING,
|
||||
ZONES.HEALTH_BEAUTY,
|
||||
ZONES.CHECKOUT_AREA,
|
||||
];
|
||||
|
||||
// Helper to get display label for item type
|
||||
export const getItemTypeLabel = (type) => {
|
||||
const labels = {
|
||||
[ITEM_TYPES.PRODUCE]: "Produce",
|
||||
[ITEM_TYPES.MEAT]: "Meat & Seafood",
|
||||
[ITEM_TYPES.DAIRY]: "Dairy & Eggs",
|
||||
[ITEM_TYPES.BAKERY]: "Bakery",
|
||||
[ITEM_TYPES.FROZEN]: "Frozen",
|
||||
[ITEM_TYPES.PANTRY]: "Pantry & Dry Goods",
|
||||
[ITEM_TYPES.BEVERAGE]: "Beverages",
|
||||
[ITEM_TYPES.SNACK]: "Snacks",
|
||||
[ITEM_TYPES.HOUSEHOLD]: "Household",
|
||||
[ITEM_TYPES.PERSONAL_CARE]: "Personal Care",
|
||||
[ITEM_TYPES.OTHER]: "Other",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// Helper to get all zone values as array (for dropdowns)
|
||||
export const getZoneValues = () => Object.values(ZONES);
|
||||
|
||||
// Helper to get suggested zone for an item type
|
||||
export const getSuggestedZone = (itemType) => {
|
||||
return ITEM_TYPE_TO_ZONE[itemType] || null;
|
||||
};
|
||||
@ -68,16 +68,32 @@ button:focus-visible {
|
||||
} */
|
||||
|
||||
|
||||
/**
|
||||
* Global Base Styles
|
||||
* Uses theme variables defined in theme.css
|
||||
*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-body);
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
padding: var(--spacing-md);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
max-width: var(--container-max-width);
|
||||
margin: auto;
|
||||
padding: var(--container-padding);
|
||||
}
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
|
||||
@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import './styles/theme.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
34
frontend/src/move-components.sh
Normal file
34
frontend/src/move-components.sh
Normal file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Move to common/
|
||||
mv components/FloatingActionButton.jsx components/common/
|
||||
mv components/SortDropdown.jsx components/common/
|
||||
mv components/ErrorMessage.jsx components/common/
|
||||
mv components/FormInput.jsx components/common/
|
||||
mv components/UserRoleCard.jsx components/common/
|
||||
|
||||
# Move to modals/
|
||||
mv components/AddItemWithDetailsModal.jsx components/modals/
|
||||
mv components/EditItemModal.jsx components/modals/
|
||||
mv components/SimilarItemModal.jsx components/modals/
|
||||
mv components/ConfirmBuyModal.jsx components/modals/
|
||||
mv components/ImageModal.jsx components/modals/
|
||||
mv components/AddImageModal.jsx components/modals/
|
||||
mv components/ImageUploadModal.jsx components/modals/
|
||||
mv components/ItemClassificationModal.jsx components/modals/
|
||||
|
||||
# Move to forms/
|
||||
mv components/AddItemForm.jsx components/forms/
|
||||
mv components/ImageUploadSection.jsx components/forms/
|
||||
mv components/ClassificationSection.jsx components/forms/
|
||||
|
||||
# Move to items/
|
||||
mv components/GroceryListItem.jsx components/items/
|
||||
mv components/GroceryItem.tsx components/items/
|
||||
mv components/SuggestionList.tsx components/items/
|
||||
|
||||
# Move to layout/
|
||||
mv components/AppLayout.jsx components/layout/
|
||||
mv components/Navbar.jsx components/layout/
|
||||
|
||||
echo "Components moved successfully!"
|
||||
15
frontend/src/move-styles.sh
Normal file
15
frontend/src/move-styles.sh
Normal file
@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Move page styles
|
||||
mv styles/GroceryList.css styles/pages/
|
||||
mv styles/Login.css styles/pages/
|
||||
mv styles/Register.css styles/pages/
|
||||
|
||||
# Move component styles
|
||||
mv styles/Navbar.css styles/components/
|
||||
mv styles/AddItemWithDetailsModal.css styles/components/
|
||||
mv styles/EditItemModal.css styles/components/
|
||||
mv styles/ImageUploadSection.css styles/components/
|
||||
mv styles/ClassificationSection.css styles/components/
|
||||
|
||||
echo "Styles moved successfully!"
|
||||
@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAllUsers, updateRole } from "../api/users";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import UserRoleCard from "../components/common/UserRoleCard";
|
||||
import "../styles/UserRoleCard.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
async function loadUsers() {
|
||||
const allUsers = await getAllUsers();
|
||||
console.log(allUsers);
|
||||
setUsers(allUsers.data);
|
||||
}
|
||||
|
||||
@ -22,19 +22,17 @@ export default function AdminPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "2rem" }}>
|
||||
<h1>Admin Panel</h1>
|
||||
<div style={{ marginTop: "2rem" }}>
|
||||
{users.map((user) => (
|
||||
<div key={user.id}>
|
||||
<strong>{user.username}</strong> - {user.role}
|
||||
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
|
||||
<option value={ROLES.VIEWER}>Viewer</option>
|
||||
<option value={ROLES.EDITOR}>Editor</option>
|
||||
<option value={ROLES.ADMIN}>Admin</option>
|
||||
</select>
|
||||
<UserRoleCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
onRoleChange={changeRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,62 +1,113 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list";
|
||||
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||
import SortDropdown from "../components/common/SortDropdown";
|
||||
import AddItemForm from "../components/forms/AddItemForm";
|
||||
import GroceryListItem from "../components/items/GroceryListItem";
|
||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||
import EditItemModal from "../components/modals/EditItemModal";
|
||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/GroceryList.css";
|
||||
import "../styles/pages/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 [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
||||
const [sortedItems, setSortedItems] = useState([]);
|
||||
|
||||
const [sortMode, setSortMode] = useState("az");
|
||||
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [sortMode, setSortMode] = useState("zone");
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buttonText, setButtonText] = useState("Add Item");
|
||||
const [pendingItem, setPendingItem] = useState(null);
|
||||
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
|
||||
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
const res = await getList();
|
||||
console.log(res.data);
|
||||
setItems(res.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadRecentlyBought = async () => {
|
||||
try {
|
||||
const res = await getRecentlyBought();
|
||||
setRecentlyBoughtItems(res.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load recently bought items:", error);
|
||||
setRecentlyBoughtItems([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
loadRecentlyBought();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let sorted = [...items];
|
||||
|
||||
if (sortMode === "az")
|
||||
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||
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 === "zone") {
|
||||
sorted.sort((a, b) => {
|
||||
// Items without classification go to the end
|
||||
if (!a.item_type && b.item_type) return 1;
|
||||
if (a.item_type && !b.item_type) return -1;
|
||||
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
||||
|
||||
if (sortMode === "za")
|
||||
sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||
// Sort by item_type
|
||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||
if (typeCompare !== 0) return typeCompare;
|
||||
|
||||
if (sortMode === "qty-high")
|
||||
sorted.sort((a, b) => b.quantity - a.quantity);
|
||||
// Then by item_group
|
||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||
if (groupCompare !== 0) return groupCompare;
|
||||
|
||||
if (sortMode === "qty-low")
|
||||
sorted.sort((a, b) => a.quantity - b.quantity);
|
||||
// Then by zone
|
||||
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
||||
if (zoneCompare !== 0) return zoneCompare;
|
||||
|
||||
// Finally by name
|
||||
return a.item_name.localeCompare(b.item_name);
|
||||
});
|
||||
}
|
||||
|
||||
setSortedItems(sorted);
|
||||
}, [items, sortMode]);
|
||||
|
||||
const handleSuggest = async (text) => {
|
||||
setItemName(text);
|
||||
|
||||
if (!text.trim()) {
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine both unbought and recently bought items for similarity checking
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
|
||||
// Check if exact match exists (case-insensitive)
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
||||
|
||||
if (exactMatch) {
|
||||
setButtonText("Add");
|
||||
} else {
|
||||
setButtonText("Create + Add");
|
||||
}
|
||||
|
||||
try {
|
||||
let suggestions = await getSuggestions(text);
|
||||
suggestions = suggestions.data.map(s => s.item_name);
|
||||
@ -66,38 +117,213 @@ 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();
|
||||
|
||||
// First check if exact item exists in database (case-insensitive)
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
existingItem = null;
|
||||
}
|
||||
|
||||
// If exact item exists, skip similarity check and process directly
|
||||
if (existingItem) {
|
||||
await processItemAddition(itemName, quantity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check for similar items if exact item doesn't exist
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
const similar = findSimilarItems(itemName, allItems, 80);
|
||||
if (similar.length > 0) {
|
||||
// Show modal and wait for user decision
|
||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||
setShowSimilarModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with normal flow for new items
|
||||
await processItemAddition(itemName, quantity);
|
||||
};
|
||||
|
||||
const processItemAddition = async (itemName, quantity) => {
|
||||
|
||||
// Check if item exists in database (case-insensitive)
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
existingItem = null;
|
||||
}
|
||||
|
||||
if (existingItem && existingItem.bought === false) {
|
||||
// Item exists and is unbought - update quantity
|
||||
const currentQuantity = existingItem.quantity;
|
||||
const newQuantity = currentQuantity + quantity;
|
||||
const yes = window.confirm(
|
||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${currentQuantity + newQuantity}?`
|
||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
||||
);
|
||||
if (!yes) return;
|
||||
|
||||
newQuantity += currentQuantity;
|
||||
}
|
||||
|
||||
await addItem(itemName, newQuantity);
|
||||
|
||||
setItemName("");
|
||||
setQuantity(1);
|
||||
await addItem(itemName, newQuantity, null);
|
||||
setSuggestions([]);
|
||||
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} else if (existingItem) {
|
||||
// Item exists in database (was previously bought) - just add quantity
|
||||
await addItem(itemName, quantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} else {
|
||||
// NEW ITEM - show combined add details modal
|
||||
setPendingItem({ itemName, quantity });
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBought = async (id) => {
|
||||
const yes = window.confirm("Mark this item as bought?");
|
||||
if (!yes) return;
|
||||
const handleSimilarCancel = () => {
|
||||
setShowSimilarModal(false);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleSimilarNo = async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Create new item with original name
|
||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleSimilarYes = async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Use suggested item name
|
||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
try {
|
||||
// Add item to grocery_list with image
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
|
||||
// If classification provided, add it
|
||||
if (classification) {
|
||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||
const itemId = itemResponse.data.id;
|
||||
await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||
}
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDetailsSkip = async () => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
try {
|
||||
// Add item without image or classification
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDetailsCancel = () => {
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleBought = async (id, quantity) => {
|
||||
await markBought(id);
|
||||
loadItems();
|
||||
loadRecentlyBought();
|
||||
};
|
||||
|
||||
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.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = async (item) => {
|
||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||
|
||||
try {
|
||||
// Fetch existing classification
|
||||
const classificationResponse = await getClassification(item.id);
|
||||
setEditingItem({
|
||||
...item,
|
||||
classification: classificationResponse.data
|
||||
});
|
||||
setShowEditModal(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load classification:", error);
|
||||
setEditingItem({ ...item, classification: null });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSave = async (id, itemName, quantity, classification) => {
|
||||
try {
|
||||
await updateItemWithClassification(id, itemName, quantity, classification);
|
||||
setShowEditModal(false);
|
||||
setEditingItem(null);
|
||||
loadItems();
|
||||
loadRecentlyBought();
|
||||
} catch (error) {
|
||||
console.error("Failed to update item:", error);
|
||||
throw error; // Re-throw to let modal handle it
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setShowEditModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
// Group items by zone for classification view
|
||||
const groupItemsByZone = (items) => {
|
||||
const groups = {};
|
||||
items.forEach(item => {
|
||||
const zone = item.zone || 'unclassified';
|
||||
if (!groups[zone]) {
|
||||
groups[zone] = [];
|
||||
}
|
||||
groups[zone].push(item);
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
if (loading) return <p>Loading...</p>;
|
||||
@ -107,86 +333,132 @@ 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>
|
||||
|
||||
{/* Add Item form (editor/admin only) */}
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="glist-input"
|
||||
placeholder="Item name"
|
||||
value={itemName}
|
||||
onChange={(e) => handleSuggest(e.target.value)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onClick={() => setShowSuggestions(true)}
|
||||
<AddItemForm
|
||||
onAdd={handleAdd}
|
||||
onSuggest={handleSuggest}
|
||||
suggestions={suggestions}
|
||||
buttonText={buttonText}
|
||||
/>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="glist-suggest-box">
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="glist-suggest-item"
|
||||
onClick={() => {
|
||||
setItemName(s);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="glist-input"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number(e.target.value))}
|
||||
/>
|
||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||
|
||||
<button className="glist-btn" onClick={handleAdd}>
|
||||
Add Item
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Grocery list */}
|
||||
{sortMode === "zone" ? (
|
||||
// Grouped view by zone
|
||||
(() => {
|
||||
const grouped = groupItemsByZone(sortedItems);
|
||||
return Object.keys(grouped).map(zone => (
|
||||
<div key={zone} className="glist-classification-group">
|
||||
<h3 className="glist-classification-header">
|
||||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||
</h3>
|
||||
<ul className="glist-ul">
|
||||
{sortedItems.map((item) => (
|
||||
<li
|
||||
{grouped[zone].map((item) => (
|
||||
<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
|
||||
}
|
||||
onLongPress={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
));
|
||||
})()
|
||||
) : (
|
||||
// Regular flat list view
|
||||
<ul className="glist-ul">
|
||||
{sortedItems.map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(quantity) =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||
}
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
}
|
||||
onLongPress={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Floating Button (editor/admin only) */}
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
{recentlyBoughtItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||
<ul className="glist-ul">
|
||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={null}
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
}
|
||||
onLongPress={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||
<button
|
||||
className="glist-fab"
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="glist-show-more-btn"
|
||||
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
|
||||
>
|
||||
{showAddForm ? "−" : "+"}
|
||||
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
<FloatingActionButton
|
||||
isOpen={showAddForm}
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
onConfirm={handleAddDetailsConfirm}
|
||||
onSkip={handleAddDetailsSkip}
|
||||
onCancel={handleAddDetailsCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSimilarModal && similarItemSuggestion && (
|
||||
<SimilarItemModal
|
||||
originalName={similarItemSuggestion.originalName}
|
||||
suggestedName={similarItemSuggestion.suggestedItem.item_name}
|
||||
onCancel={handleSimilarCancel}
|
||||
onNo={handleSimilarNo}
|
||||
onYes={handleSimilarYes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && editingItem && (
|
||||
<EditItemModal
|
||||
item={editingItem}
|
||||
onSave={handleEditSave}
|
||||
onCancel={handleEditCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { loginRequest } from "../api/auth";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/Login.css";
|
||||
import "../styles/pages/Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useContext(AuthContext);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (e) => {
|
||||
@ -28,22 +31,32 @@ export default function Login() {
|
||||
<div className="login-box">
|
||||
<h1 className="login-title">Login</h1>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<input
|
||||
<FormInput
|
||||
type="text"
|
||||
className="login-input"
|
||||
placeholder="Username"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
<div className="login-password-wrapper">
|
||||
<FormInput
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-password-toggle"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? "👀" : "🙈"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-button">Login</button>
|
||||
</form>
|
||||
|
||||
@ -2,9 +2,10 @@ import { useContext, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { loginRequest, registerRequest } from "../api/auth";
|
||||
import { checkIfUserExists } from "../api/users";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
import "../styles/Register.css";
|
||||
import "../styles/pages/Register.css";
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
/* Container */
|
||||
.glist-body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.glist-container {
|
||||
max-width: 480px;
|
||||
margin: auto;
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.glist-title {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.glist-input {
|
||||
font-size: 1em;
|
||||
padding: 0.5em;
|
||||
margin: 0.3em 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.glist-btn {
|
||||
font-size: 1em;
|
||||
padding: 0.55em;
|
||||
width: 100%;
|
||||
margin-top: 0.4em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.glist-btn:hover {
|
||||
background: #0067d8;
|
||||
}
|
||||
|
||||
/* Suggestion dropdown */
|
||||
.glist-suggest-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||
padding: 1em;
|
||||
width: calc(100% - 8em);
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.glist-suggest-item {
|
||||
padding: 0.5em;
|
||||
padding-inline: 2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glist-suggest-item:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
/* Grocery list items */
|
||||
.glist-ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.glist-li {
|
||||
padding: 0.7em;
|
||||
background: #e9ecef;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 0.6em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glist-li:hover {
|
||||
background: #dee2e6;
|
||||
}
|
||||
|
||||
/* Sorting dropdown */
|
||||
.glist-sort {
|
||||
width: 100%;
|
||||
margin: 0.3em 0;
|
||||
padding: 0.5em;
|
||||
font-size: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.glist-fab {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
font-size: 2em;
|
||||
line-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glist-fab:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.glist-container {
|
||||
padding: 1em 0.8em;
|
||||
}
|
||||
|
||||
.glist-fab {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
102
frontend/src/styles/ItemClassificationModal.css
Normal file
102
frontend/src/styles/ItemClassificationModal.css
Normal file
@ -0,0 +1,102 @@
|
||||
.classification-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.classification-modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5em;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.classification-modal-title {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.classification-modal-subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 0 0 1.5em 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.classification-modal-field {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.classification-modal-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.classification-modal-field label .required {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.classification-modal-select {
|
||||
width: 100%;
|
||||
padding: 0.6em;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.classification-modal-select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.classification-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.classification-modal-btn {
|
||||
flex: 1;
|
||||
padding: 0.7em;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.classification-modal-btn-skip {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.classification-modal-btn-skip:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.classification-modal-btn-confirm {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.classification-modal-btn-confirm:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
130
frontend/src/styles/THEME_USAGE_EXAMPLES.css
Normal file
130
frontend/src/styles/THEME_USAGE_EXAMPLES.css
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Theme Variable Usage Examples
|
||||
*
|
||||
* This file demonstrates how to refactor existing CSS to use theme variables.
|
||||
* Copy these patterns when updating component styles.
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
BEFORE: Hardcoded values
|
||||
============================================ */
|
||||
.button-old {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 0.6em 1.2em;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 1em;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.button-old:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
AFTER: Using theme variables
|
||||
============================================ */
|
||||
.button-new {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
padding: var(--button-padding-y) var(--button-padding-x);
|
||||
border-radius: var(--button-border-radius);
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-new:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MORE EXAMPLES
|
||||
============================================ */
|
||||
|
||||
/* Input Field */
|
||||
.input-field {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family-base);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border-color);
|
||||
box-shadow: var(--input-focus-shadow);
|
||||
}
|
||||
|
||||
/* Card Component */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
padding: var(--card-padding);
|
||||
border-radius: var(--card-border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
background: var(--modal-backdrop-bg);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--modal-bg);
|
||||
padding: var(--modal-padding);
|
||||
border-radius: var(--modal-border-radius);
|
||||
max-width: var(--modal-max-width);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
/* Text Styles */
|
||||
.heading-primary {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Spacing Examples */
|
||||
.section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Border Examples */
|
||||
.divider {
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BENEFITS OF USING THEME VARIABLES
|
||||
============================================
|
||||
|
||||
1. Consistency: All components use the same colors/spacing
|
||||
2. Maintainability: Change once, update everywhere
|
||||
3. Theme switching: Easy to implement dark mode
|
||||
4. Scalability: Add new colors/sizes without touching components
|
||||
5. Documentation: Variable names are self-documenting
|
||||
|
||||
*/
|
||||
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);
|
||||
}
|
||||
172
frontend/src/styles/components/AddItemForm.css
Normal file
172
frontend/src/styles/components/AddItemForm.css
Normal file
@ -0,0 +1,172 @@
|
||||
/* Add Item Form Container */
|
||||
.add-item-form-container {
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.add-item-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Form Fields */
|
||||
.add-item-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-item-form-input {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family-base);
|
||||
transition: var(--transition-base);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-item-form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border-color);
|
||||
box-shadow: var(--input-focus-shadow);
|
||||
}
|
||||
|
||||
/* Suggestion List Positioning */
|
||||
.add-item-form-field .suggestion-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: var(--spacing-xs);
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
/* Actions Row */
|
||||
.add-item-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Quantity Control */
|
||||
.add-item-form-quantity-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.quantity-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-light);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.quantity-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.quantity-btn:disabled {
|
||||
background: var(--color-bg-disabled);
|
||||
color: var(--color-text-disabled);
|
||||
border-color: var(--color-border-disabled);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.add-item-form-quantity-input {
|
||||
width: 40px;
|
||||
max-width: 40px;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family-base);
|
||||
text-align: center;
|
||||
transition: var(--transition-base);
|
||||
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
||||
}
|
||||
|
||||
/* Remove spinner arrows in Chrome/Safari */
|
||||
.add-item-form-quantity-input::-webkit-outer-spin-button,
|
||||
.add-item-form-quantity-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-item-form-quantity-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus-border-color);
|
||||
box-shadow: var(--input-focus-shadow);
|
||||
}
|
||||
|
||||
/* Submit Button */
|
||||
.add-item-form-submit {
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-lg);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--button-border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.add-item-form-submit:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.add-item-form-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.add-item-form-submit.disabled,
|
||||
.add-item-form-submit:disabled {
|
||||
background: var(--color-bg-disabled);
|
||||
color: var(--color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
.add-item-form-container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
218
frontend/src/styles/components/AddItemWithDetailsModal.css
Normal file
218
frontend/src/styles/components/AddItemWithDetailsModal.css
Normal file
@ -0,0 +1,218 @@
|
||||
.add-item-details-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.add-item-details-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5em;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.add-item-details-title {
|
||||
font-size: 1.4em;
|
||||
margin: 0 0 0.3em 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-item-details-subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 0 0 1.5em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.add-item-details-section {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1.5em;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.add-item-details-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.add-item-details-section-title {
|
||||
font-size: 1.1em;
|
||||
margin: 0 0 1em 0;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Image Upload Section */
|
||||
.add-item-details-image-content {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.add-item-details-image-options {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-item-details-image-btn {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 0.8em;
|
||||
font-size: 0.95em;
|
||||
border: 2px solid #007bff;
|
||||
background: white;
|
||||
color: #007bff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-item-details-image-btn:hover {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-item-details-image-preview {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.add-item-details-image-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
max-height: 300px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.add-item-details-remove-image {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4em 0.8em;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-item-details-remove-image:hover {
|
||||
background: rgba(220, 53, 69, 1);
|
||||
}
|
||||
|
||||
/* Classification Section */
|
||||
.add-item-details-field {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.add-item-details-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.4em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.add-item-details-select {
|
||||
width: 100%;
|
||||
padding: 0.6em;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.add-item-details-select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.add-item-details-actions {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
margin-top: 1.5em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.add-item-details-btn {
|
||||
flex: 1;
|
||||
padding: 0.7em;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-item-details-btn.cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-item-details-btn.cancel:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.add-item-details-btn.skip {
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-item-details-btn.skip:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.add-item-details-btn.confirm {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-item-details-btn.confirm:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 480px) {
|
||||
.add-item-details-modal {
|
||||
padding: 1.2em;
|
||||
}
|
||||
|
||||
.add-item-details-title {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.add-item-details-image-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-item-details-image-btn {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.add-item-details-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
44
frontend/src/styles/components/ClassificationSection.css
Normal file
44
frontend/src/styles/components/ClassificationSection.css
Normal file
@ -0,0 +1,44 @@
|
||||
/* Classification Section */
|
||||
.classification-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.classification-title {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.classification-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.classification-field label {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.classification-select {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.classification-select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.classification-select:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
112
frontend/src/styles/components/EditItemModal.css
Normal file
112
frontend/src/styles/components/EditItemModal.css
Normal file
@ -0,0 +1,112 @@
|
||||
.edit-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5em;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.edit-modal-title {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 1em 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-modal-subtitle {
|
||||
font-size: 1.1em;
|
||||
margin: 0.5em 0 0.8em 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.edit-modal-field {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.edit-modal-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.edit-modal-input,
|
||||
.edit-modal-select {
|
||||
width: 100%;
|
||||
padding: 0.6em;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.edit-modal-input:focus,
|
||||
.edit-modal-select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.edit-modal-divider {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.edit-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.edit-modal-btn {
|
||||
flex: 1;
|
||||
padding: 0.7em;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.edit-modal-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.edit-modal-btn-cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-modal-btn-cancel:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.edit-modal-btn-save {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-modal-btn-save:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
86
frontend/src/styles/components/ImageUploadSection.css
Normal file
86
frontend/src/styles/components/ImageUploadSection.css
Normal file
@ -0,0 +1,86 @@
|
||||
/* Image Upload Section */
|
||||
.image-upload-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.image-upload-title {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.image-upload-content {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.image-upload-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.image-upload-btn {
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 1em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-upload-btn.camera {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.image-upload-btn.camera:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.image-upload-btn.gallery {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.image-upload-btn.gallery:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.image-upload-preview {
|
||||
position: relative;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.image-upload-preview img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-upload-remove {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.image-upload-remove:hover {
|
||||
background: rgba(255, 0, 0, 1);
|
||||
}
|
||||
323
frontend/src/styles/pages/GroceryList.css
Normal file
323
frontend/src/styles/pages/GroceryList.css
Normal file
@ -0,0 +1,323 @@
|
||||
/* Container */
|
||||
.glist-body {
|
||||
font-family: var(--font-family-base);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-bg-body);
|
||||
}
|
||||
|
||||
.glist-container {
|
||||
max-width: var(--container-max-width);
|
||||
margin: auto;
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.glist-title {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.glist-section-title {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--color-gray-700);
|
||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||
padding-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Classification Groups */
|
||||
.glist-classification-group {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.glist-classification-header {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-primary);
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-primary-light);
|
||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.glist-input {
|
||||
font-size: 1em;
|
||||
padding: 0.5em;
|
||||
margin: 0.3em 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.glist-btn {
|
||||
font-size: var(--font-size-base);
|
||||
padding: var(--button-padding-y);
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--button-border-radius);
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.glist-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.glist-show-more-btn {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
border: var(--border-width-thin) solid var(--color-primary);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--button-border-radius);
|
||||
transition: var(--transition-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
}
|
||||
|
||||
.glist-show-more-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
/* Suggestion dropdown */
|
||||
.glist-suggest-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||
padding: 1em;
|
||||
width: calc(100% - 8em);
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.glist-suggest-item {
|
||||
padding: 0.5em;
|
||||
padding-inline: 2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glist-suggest-item:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
/* Grocery list items */
|
||||
.glist-ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.glist-li {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.8em;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glist-li:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glist-item-layout {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 0em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glist-item-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
min-width: 50px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image {
|
||||
border-color: #007bff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.glist-item-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image:hover {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.glist-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glist-item-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.glist-item-name {
|
||||
font-weight: 800;
|
||||
font-size: 0.8em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.glist-item-quantity {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 123, 255, 0.9);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.3em;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0 6px 0 4px;
|
||||
min-width: 20%;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.glist-item-users {
|
||||
font-size: 0.7em;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sorting dropdown */
|
||||
.glist-sort {
|
||||
width: 100%;
|
||||
margin: 0.3em 0;
|
||||
padding: 0.5em;
|
||||
font-size: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Image upload */
|
||||
.glist-image-upload {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.glist-image-label {
|
||||
display: block;
|
||||
padding: 0.6em;
|
||||
background: #f0f0f0;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.glist-image-label:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.glist-image-preview {
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.glist-image-preview img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.glist-remove-image {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.glist-remove-image:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.glist-fab {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
font-size: 2em;
|
||||
line-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.glist-fab:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.glist-container {
|
||||
padding: 1em 0.8em;
|
||||
}
|
||||
|
||||
.glist-fab {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
270
frontend/src/styles/theme.css
Normal file
270
frontend/src/styles/theme.css
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Global Theme Variables
|
||||
*
|
||||
* This file defines the design system for the entire application.
|
||||
* All colors, spacing, typography, and other design tokens are centralized here.
|
||||
*
|
||||
* Usage: var(--variable-name)
|
||||
* Example: color: var(--color-primary);
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ============================================
|
||||
COLOR PALETTE
|
||||
============================================ */
|
||||
|
||||
/* Primary Colors */
|
||||
--color-primary: #007bff;
|
||||
--color-primary-hover: #0056b3;
|
||||
--color-primary-light: #e7f3ff;
|
||||
--color-primary-dark: #0067d8;
|
||||
|
||||
/* Secondary Colors */
|
||||
--color-secondary: #6c757d;
|
||||
--color-secondary-hover: #545b62;
|
||||
--color-secondary-light: #f8f9fa;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #28a745;
|
||||
--color-success-hover: #218838;
|
||||
--color-success-light: #d4edda;
|
||||
|
||||
--color-danger: #dc3545;
|
||||
--color-danger-hover: #c82333;
|
||||
--color-danger-light: #f8d7da;
|
||||
|
||||
--color-warning: #ffc107;
|
||||
--color-warning-hover: #e0a800;
|
||||
--color-warning-light: #fff3cd;
|
||||
|
||||
--color-info: #17a2b8;
|
||||
--color-info-hover: #138496;
|
||||
--color-info-light: #d1ecf1;
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-gray-50: #f9f9f9;
|
||||
--color-gray-100: #f8f9fa;
|
||||
--color-gray-200: #e9ecef;
|
||||
--color-gray-300: #dee2e6;
|
||||
--color-gray-400: #ced4da;
|
||||
--color-gray-500: #adb5bd;
|
||||
--color-gray-600: #6c757d;
|
||||
--color-gray-700: #495057;
|
||||
--color-gray-800: #343a40;
|
||||
--color-gray-900: #212529;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #212529;
|
||||
--color-text-secondary: #6c757d;
|
||||
--color-text-muted: #adb5bd;
|
||||
--color-text-inverse: #ffffff;
|
||||
--color-text-disabled: #6c757d;
|
||||
|
||||
/* Background Colors */
|
||||
--color-bg-body: #f8f9fa;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-hover: #f5f5f5;
|
||||
--color-bg-disabled: #e9ecef;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border-light: #e0e0e0;
|
||||
--color-border-medium: #ccc;
|
||||
--color-border-dark: #999;
|
||||
--color-border-disabled: #dee2e6;
|
||||
|
||||
/* ============================================
|
||||
SPACING
|
||||
============================================ */
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
--spacing-3xl: 4rem; /* 64px */
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-family-base: Arial, sans-serif;
|
||||
--font-family-heading: Arial, sans-serif;
|
||||
--font-family-mono: 'Courier New', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 2rem; /* 32px */
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Line Heights */
|
||||
--line-height-tight: 1.2;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* ============================================
|
||||
BORDERS & RADIUS
|
||||
============================================ */
|
||||
--border-width-thin: 1px;
|
||||
--border-width-medium: 2px;
|
||||
--border-width-thick: 4px;
|
||||
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 6px;
|
||||
--border-radius-lg: 8px;
|
||||
--border-radius-xl: 12px;
|
||||
--border-radius-full: 50%;
|
||||
|
||||
/* ============================================
|
||||
SHADOWS
|
||||
============================================ */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* ============================================
|
||||
TRANSITIONS
|
||||
============================================ */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
|
||||
/* ============================================
|
||||
Z-INDEX LAYERS
|
||||
============================================ */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 900;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 1100;
|
||||
|
||||
/* ============================================
|
||||
LAYOUT
|
||||
============================================ */
|
||||
--container-max-width: 480px;
|
||||
--container-padding: var(--spacing-md);
|
||||
|
||||
/* ============================================
|
||||
COMPONENT-SPECIFIC
|
||||
============================================ */
|
||||
|
||||
/* Buttons */
|
||||
--button-padding-y: 0.6rem;
|
||||
--button-padding-x: 1.5rem;
|
||||
--button-border-radius: var(--border-radius-sm);
|
||||
--button-font-weight: var(--font-weight-medium);
|
||||
|
||||
/* Inputs */
|
||||
--input-padding-y: 0.6rem;
|
||||
--input-padding-x: 0.75rem;
|
||||
--input-border-color: var(--color-border-medium);
|
||||
--input-border-radius: var(--border-radius-sm);
|
||||
--input-focus-border-color: var(--color-primary);
|
||||
--input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
|
||||
/* Cards */
|
||||
--card-bg: var(--color-bg-surface);
|
||||
--card-padding: var(--spacing-md);
|
||||
--card-border-radius: var(--border-radius-lg);
|
||||
--card-shadow: var(--shadow-card);
|
||||
|
||||
/* Modals */
|
||||
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||
--modal-bg: var(--color-white);
|
||||
--modal-border-radius: var(--border-radius-lg);
|
||||
--modal-padding: var(--spacing-lg);
|
||||
--modal-max-width: 500px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE SUPPORT (Future Implementation)
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Uncomment to enable dark mode
|
||||
:root {
|
||||
--color-text-primary: #f8f9fa;
|
||||
--color-text-secondary: #adb5bd;
|
||||
--color-bg-body: #212529;
|
||||
--color-bg-surface: #343a40;
|
||||
--color-border-light: #495057;
|
||||
--color-border-medium: #6c757d;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/* Manual dark mode class override */
|
||||
.dark-mode {
|
||||
--color-text-primary: #f8f9fa;
|
||||
--color-text-secondary: #adb5bd;
|
||||
--color-bg-body: #212529;
|
||||
--color-bg-surface: #343a40;
|
||||
--color-border-light: #495057;
|
||||
--color-border-medium: #6c757d;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Spacing Utilities */
|
||||
.m-0 { margin: 0 !important; }
|
||||
.mt-1 { margin-top: var(--spacing-xs) !important; }
|
||||
.mt-2 { margin-top: var(--spacing-sm) !important; }
|
||||
.mt-3 { margin-top: var(--spacing-md) !important; }
|
||||
.mt-4 { margin-top: var(--spacing-lg) !important; }
|
||||
|
||||
.mb-1 { margin-bottom: var(--spacing-xs) !important; }
|
||||
.mb-2 { margin-bottom: var(--spacing-sm) !important; }
|
||||
.mb-3 { margin-bottom: var(--spacing-md) !important; }
|
||||
.mb-4 { margin-bottom: var(--spacing-lg) !important; }
|
||||
|
||||
.p-0 { padding: 0 !important; }
|
||||
.p-1 { padding: var(--spacing-xs) !important; }
|
||||
.p-2 { padding: var(--spacing-sm) !important; }
|
||||
.p-3 { padding: var(--spacing-md) !important; }
|
||||
.p-4 { padding: var(--spacing-lg) !important; }
|
||||
|
||||
/* Text Utilities */
|
||||
.text-center { text-align: center !important; }
|
||||
.text-left { text-align: left !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
|
||||
.text-primary { color: var(--color-primary) !important; }
|
||||
.text-secondary { color: var(--color-text-secondary) !important; }
|
||||
.text-muted { color: var(--color-text-muted) !important; }
|
||||
.text-danger { color: var(--color-danger) !important; }
|
||||
.text-success { color: var(--color-success) !important; }
|
||||
|
||||
.font-weight-normal { font-weight: var(--font-weight-normal) !important; }
|
||||
.font-weight-medium { font-weight: var(--font-weight-medium) !important; }
|
||||
.font-weight-semibold { font-weight: var(--font-weight-semibold) !important; }
|
||||
.font-weight-bold { font-weight: var(--font-weight-bold) !important; }
|
||||
|
||||
/* Display Utilities */
|
||||
.d-none { display: none !important; }
|
||||
.d-block { display: block !important; }
|
||||
.d-flex { display: flex !important; }
|
||||
.d-inline-block { display: inline-block !important; }
|
||||
|
||||
/* Flex Utilities */
|
||||
.flex-column { flex-direction: column !important; }
|
||||
.flex-row { flex-direction: row !important; }
|
||||
.justify-center { justify-content: center !important; }
|
||||
.justify-between { justify-content: space-between !important; }
|
||||
.align-center { align-items: center !important; }
|
||||
.gap-1 { gap: var(--spacing-xs) !important; }
|
||||
.gap-2 { gap: var(--spacing-sm) !important; }
|
||||
.gap-3 { gap: var(--spacing-md) !important; }
|
||||
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