Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

70 changed files with 1059 additions and 5828 deletions

View File

@ -62,7 +62,7 @@ jobs:
docker build \ docker build \
-t $REGISTRY/frontend:${{ github.sha }} \ -t $REGISTRY/frontend:${{ github.sha }} \
-t $REGISTRY/frontend:latest \ -t $REGISTRY/frontend:latest \
-f frontend/Dockerfile.dev frontend/ -f frontend/Dockerfile frontend/
- name: Push Frontend Image - name: Push Frontend Image
run: | run: |

View File

@ -1,198 +0,0 @@
# Account Management Implementation (Phase 4)
## Overview
Phase 4 adds account management features allowing users to:
- Change their display name (friendly name separate from username)
- Change their password with current password verification
## Database Changes
### Migration: `add_display_name_column.sql`
```sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
UPDATE users
SET display_name = name
WHERE display_name IS NULL;
```
**To run migration:**
Connect to your PostgreSQL database and execute:
```bash
psql -U your_user -d your_database -f backend/migrations/add_display_name_column.sql
```
## Backend Implementation
### New Model Functions (`backend/models/user.model.js`)
- `getUserById(id)` - Fetch user by ID including display_name
- `updateUserProfile(id, updates)` - Update user profile (display_name)
- `updateUserPassword(id, hashedPassword)` - Update user password
- `getUserPasswordHash(id)` - Get current password hash for verification
### New Controllers (`backend/controllers/users.controller.js`)
- `getCurrentUser` - GET authenticated user's profile
- `updateCurrentUser` - PATCH user's display name
- Validates: 1-100 characters, trims whitespace
- `changePassword` - POST password change
- Validates: current password correct, new password min 6 chars, passwords match
- Uses bcrypt for password verification and hashing
### New Routes (`backend/routes/users.routes.js`)
All routes require authentication (`auth` middleware):
- `GET /api/users/me` - Get current user profile
- `PATCH /api/users/me` - Update display name
- `POST /api/users/me/change-password` - Change password
**Request bodies:**
```javascript
// Update display name
PATCH /api/users/me
{
"display_name": "New Display Name"
}
// Change password
POST /api/users/me/change-password
{
"current_password": "oldpass123",
"new_password": "newpass123"
}
```
## Frontend Implementation
### API Functions (`frontend/src/api/users.js`)
- `getCurrentUser()` - Fetch current user profile
- `updateCurrentUser(display_name)` - Update display name
- `changePassword(current_password, new_password)` - Change password
### Settings Page Updates (`frontend/src/pages/Settings.jsx`)
Added new "Account" tab with two sections:
**Display Name Section:**
- Input field with character counter (max 100)
- Real-time validation
- Save button with loading state
**Password Change Section:**
- Current password field
- New password field (min 6 chars)
- Confirm password field
- Client-side validation before submission
- Loading state during submission
**Features:**
- Success/error messages displayed at top of tab
- Form validation (character limits, password matching)
- Disabled buttons during API calls
- Auto-clears password fields on success
### Styling (`frontend/src/styles/pages/Settings.css`)
Added:
- `.account-form` - Form container styling
- `.account-message` - Success/error message styling
- `.account-message.success` - Green success messages
- `.account-message.error` - Red error messages
## Security Features
### Password Requirements
- **Backend validation:**
- Minimum 6 characters
- Current password verification before change
- bcrypt hashing (10 rounds)
- **Frontend validation:**
- HTML5 minlength attribute
- Client-side password matching check
- Current password required
### Display Name Validation
- **Backend:**
- 1-100 character limit
- Whitespace trimming
- **Frontend:**
- HTML5 maxlength attribute
- Character counter
## Usage
### For Users
1. Navigate to Settings → Account tab
2. **Change Display Name:**
- Enter new display name (1-100 chars)
- Click "Save Display Name"
3. **Change Password:**
- Enter current password
- Enter new password (min 6 chars)
- Confirm new password
- Click "Change Password"
### For Developers
**Testing the endpoints:**
```bash
# Get current user
curl -X GET http://localhost:5000/api/users/me \
-H "Authorization: Bearer YOUR_TOKEN"
# Update display name
curl -X PATCH http://localhost:5000/api/users/me \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name": "New Name"}'
# Change password
curl -X POST http://localhost:5000/api/users/me/change-password \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"current_password": "oldpass",
"new_password": "newpass"
}'
```
## Next Steps
### Optional Enhancements
1. **Password strength indicator** - Visual feedback on password complexity
2. **Display name in navbar** - Show display_name instead of username in UI
3. **Email verification** - Add email field and verification
4. **2FA support** - Two-factor authentication option
5. **Password history** - Prevent reusing recent passwords
6. **Session management** - View/revoke active sessions
### Integration with AuthContext
Consider updating `AuthContext` to:
- Store and expose display_name
- Refresh user data after profile updates
- Show display_name in navbar/profile components
## Files Modified
### Backend
- ✅ `backend/migrations/add_display_name_column.sql` (NEW)
- ✅ `backend/models/user.model.js`
- ✅ `backend/controllers/users.controller.js`
- ✅ `backend/routes/users.routes.js`
### Frontend
- ✅ `frontend/src/api/users.js`
- ✅ `frontend/src/pages/Settings.jsx`
- ✅ `frontend/src/styles/pages/Settings.css`
## Testing Checklist
- [ ] Run database migration
- [ ] Test GET /api/users/me endpoint
- [ ] Test display name update with valid data
- [ ] Test display name update with invalid data (empty, too long)
- [ ] Test password change with correct current password
- [ ] Test password change with incorrect current password
- [ ] Test password change with mismatched new passwords
- [ ] Test password change with weak password (< 6 chars)
- [ ] Verify frontend validation prevents invalid submissions
- [ ] Verify success/error messages display correctly
- [ ] Test UI responsiveness on mobile

774
README.md
View File

@ -1,774 +0,0 @@
# Costco Grocery List
A full-stack web application for managing grocery shopping lists with role-based access control, image support, and intelligent item classification.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Node](https://img.shields.io/badge/node-20.x-green.svg)
![React](https://img.shields.io/badge/react-19.2.0-blue.svg)
![Express](https://img.shields.io/badge/express-5.1.0-lightgrey.svg)
## 📋 Table of Contents
- [Overview](#overview)
- [System Architecture](#system-architecture)
- [Key Features](#key-features)
- [Technology Stack](#technology-stack)
- [Data Flow](#data-flow)
- [Role-Based Access Control](#role-based-access-control)
- [Getting Started](#getting-started)
- [API Documentation](#api-documentation)
- [Project Structure](#project-structure)
- [Development Workflow](#development-workflow)
- [Deployment](#deployment)
---
## 🎯 Overview
The Costco Grocery List application provides a collaborative platform for managing household grocery shopping. Users can add items with photos, track quantities, mark items as purchased, and organize items by store zones. The system supports multiple users with different permission levels, making it ideal for families or shared households.
**Live Demo:** [https://costco.nicosaya.com](https://costco.nicosaya.com)
---
## 🏗️ System Architecture
```mermaid
graph TB
subgraph "Client Layer"
A[React 19 SPA]
B[Vite Dev Server]
end
subgraph "Application Layer"
C[Express 5 API]
D[JWT Auth Middleware]
E[RBAC Middleware]
F[Image Processing]
end
subgraph "Data Layer"
G[(PostgreSQL)]
H[grocery_list]
I[users]
J[item_classification]
K[grocery_history]
end
A -->|HTTP/REST| C
C --> D
D --> E
E --> F
C --> G
G --> H
G --> I
G --> J
G --> K
style A fill:#61dafb
style C fill:#259dff
style G fill:#336791
```
### Architecture Layers
1. **Presentation Layer (Frontend)**
- React 19 with modern hooks (useState, useEffect, useContext)
- Component-based architecture organized by feature
- CSS custom properties for theming
- Axios for HTTP requests with interceptors
2. **Business Logic Layer (Backend)**
- Express.js REST API
- JWT-based authentication
- Role-based access control (RBAC)
- Image optimization middleware (Sharp)
- Centralized error handling
3. **Data Persistence Layer**
- PostgreSQL relational database
- Normalized schema with foreign key constraints
- Junction table for item history tracking
- Binary storage for optimized images
---
## ✨ Key Features
### 🔐 Authentication & Authorization
- JWT token-based authentication (1 year expiration)
- Three-tier role system (Viewer, Editor, Admin)
- Secure password hashing with bcrypt
- Token-based session management
### 📝 Grocery List Management
- Add items with optional images
- Update item quantities
- Mark items as bought/unbought
- View recently bought items (24-hour window)
- Long-press to edit items (mobile-friendly)
### 🖼️ Image Support
- Upload product images
- Automatic image optimization (800x800px, JPEG 85%)
- Base64 encoding for efficient storage
- 5MB maximum file size
- Support for JPEG, PNG, GIF, WebP
### 🏪 Smart Organization
- Item classification system (type, group, zone)
- 13 predefined store zones
- Sort by zone, alphabetically, or quantity
- Visual grouping by store location
- Intelligent item suggestions
### 🔍 Search & Suggestions
- Real-time autocomplete suggestions
- Fuzzy string matching (80% similarity threshold)
- Substring detection for partial matches
- Case-insensitive search
### 👥 User Management (Admin)
- View all registered users
- Update user roles
- Delete user accounts
- User activity tracking
---
## 🛠️ Technology Stack
### Frontend
| Technology | Version | Purpose |
|------------|---------|---------|
| React | 19.2.0 | UI framework |
| React Router | 7.9.6 | Client-side routing |
| Axios | 1.13.2 | HTTP client |
| Vite | 7.2.2 | Build tool & dev server |
| TypeScript | 5.9.3 | Type safety |
### Backend
| Technology | Version | Purpose |
|------------|---------|---------|
| Node.js | 20.x | Runtime environment |
| Express | 5.1.0 | Web framework |
| PostgreSQL | 8.16.0 | Database |
| JWT | 9.0.2 | Authentication |
| Bcrypt | 3.0.3 | Password hashing |
| Sharp | 0.34.5 | Image processing |
| Multer | 2.0.2 | File upload handling |
### DevOps
| Technology | Purpose |
|------------|---------|
| Docker | Containerization |
| Docker Compose | Multi-container orchestration |
| Gitea Actions | CI/CD pipeline |
| Nginx | Production static file serving |
| Nodemon | Development hot-reload |
---
## 🔄 Data Flow
### Adding an Item
```mermaid
sequenceDiagram
participant User
participant Frontend
participant API
participant Auth
participant RBAC
participant Database
User->>Frontend: Enter item name & quantity
User->>Frontend: Upload image (optional)
Frontend->>API: POST /list/add (FormData)
API->>Auth: Verify JWT token
Auth->>RBAC: Check role (Editor/Admin)
RBAC->>API: Authorization granted
API->>API: Process & optimize image
API->>Database: Check if item exists
alt Item exists & unbought
Database-->>API: Return existing item
API->>Database: UPDATE quantity
else Item exists & bought
Database-->>API: Return existing item
API->>Database: SET bought=false, UPDATE quantity
else Item doesn't exist
API->>Database: INSERT new item
end
API->>Database: INSERT grocery_history record
Database-->>API: Success
API-->>Frontend: 200 OK {message, addedBy}
Frontend-->>User: Show success message
Frontend->>API: GET /list (refresh)
API->>Database: SELECT unbought items
Database-->>API: Return items
API-->>Frontend: Item list
Frontend-->>User: Update UI
```
### Authentication Flow
```mermaid
sequenceDiagram
participant User
participant Frontend
participant API
participant Database
User->>Frontend: Enter credentials
Frontend->>API: POST /auth/login
API->>Database: SELECT user WHERE username
Database-->>API: User record
API->>API: Compare password hash
alt Valid credentials
API->>API: Generate JWT token
API-->>Frontend: {token, role, username}
Frontend->>Frontend: Store token in localStorage
Frontend->>Frontend: Store role in AuthContext
Frontend->>API: GET /list (with token)
API->>API: Verify token
API-->>Frontend: Protected data
else Invalid credentials
API-->>Frontend: 401 Unauthorized
Frontend-->>User: Show error message
end
```
### Item Classification Flow
```mermaid
graph LR
A[Add/Edit Item] --> B{Classification Exists?}
B -->|Yes| C[Display Existing]
B -->|No| D[Show Empty Form]
C --> E[User Edits]
D --> E
E --> F{Validate Classification}
F -->|Valid| G[Upsert to DB]
F -->|Invalid| H[Show Error]
G --> I[confidence=1.0, source='user']
I --> J[Update Item List]
H --> E
style G fill:#90EE90
style H fill:#FFB6C1
```
---
## 🔒 Role-Based Access Control
### Role Hierarchy
```
Admin (Full Access)
├── User Management
├── Item Management
└── View Access
Editor (Modify Access)
├── Add Items
├── Edit Items
├── Mark as Bought
└── View Access
Viewer (Read-Only)
└── View Lists Only
```
### Permission Matrix
| Feature | Viewer | Editor | Admin |
|---------|--------|--------|-------|
| View grocery list | ✅ | ✅ | ✅ |
| View recently bought | ✅ | ✅ | ✅ |
| Get suggestions | ✅ | ✅ | ✅ |
| View classifications | ✅ | ✅ | ✅ |
| Add items | ❌ | ✅ | ✅ |
| Edit items | ❌ | ✅ | ✅ |
| Upload images | ❌ | ✅ | ✅ |
| Mark items bought | ❌ | ✅ | ✅ |
| Update classifications | ❌ | ✅ | ✅ |
| View all users | ❌ | ❌ | ✅ |
| Update user roles | ❌ | ❌ | ✅ |
| Delete users | ❌ | ❌ | ✅ |
### Middleware Chain
Protected routes use a middleware chain pattern:
```javascript
router.post("/add",
auth, // Verify JWT token
requireRole(ROLES.EDITOR, ROLES.ADMIN), // Check role
upload.single("image"), // Handle file upload
processImage, // Optimize image
controller.addItem // Execute business logic
);
```
---
## 🚀 Getting Started
### Prerequisites
- **Node.js** 20.x or higher
- **PostgreSQL** 8.x or higher
- **Docker** (optional, recommended)
- **Git** for version control
### Local Development Setup
1. **Clone the repository**
```bash
git clone https://github.com/your-org/costco-grocery-list.git
cd costco-grocery-list
```
2. **Configure environment variables**
Create `backend/.env`:
```env
# Database Configuration
DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=your_password
DB_DATABASE=grocery_list
DB_PORT=5432
# Authentication
JWT_SECRET=your_secret_key_here
# CORS Configuration
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# Server
PORT=5000
```
3. **Start with Docker (Recommended)**
```bash
# Development mode with hot-reload
docker-compose -f docker-compose.dev.yml up
# Access application:
# Frontend: http://localhost:3000
# Backend: http://localhost:5000
```
4. **Manual Setup (Alternative)**
**Backend:**
```bash
cd backend
npm install
npm run dev
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev
```
5. **Database Setup**
Create PostgreSQL database and tables:
```sql
CREATE DATABASE grocery_list;
-- See backend/models/*.js for table schemas
```
### First User Registration
The first registered user should be manually promoted to admin:
```sql
UPDATE users SET role = 'admin' WHERE username = 'your_username';
```
---
## 📚 API Documentation
For detailed API documentation including all endpoints, request/response formats, and examples, see [API_DOCUMENTATION.md](./API_DOCUMENTATION.md).
### Quick Reference
**Base URL:** `http://localhost:5000/api`
**Common Endpoints:**
- `POST /auth/register` - Register new user
- `POST /auth/login` - Authenticate user
- `GET /list` - Get all unbought items
- `POST /list/add` - Add or update item
- `POST /list/mark-bought` - Mark item as bought
- `GET /list/recently-bought` - Get items bought in last 24h
- `GET /admin/users` - Get all users (Admin only)
**Authentication:**
All protected endpoints require a JWT token in the Authorization header:
```
Authorization: Bearer <your_jwt_token>
```
---
## 📁 Project Structure
```
costco-grocery-list/
├── .gitea/
│ └── workflows/
│ └── deploy.yml # CI/CD pipeline configuration
├── backend/
│ ├── constants/
│ │ └── classifications.js # Item type/zone definitions
│ ├── controllers/
│ │ ├── auth.controller.js # Authentication logic
│ │ ├── lists.controller.js # Grocery list logic
│ │ └── users.controller.js # User management logic
│ ├── db/
│ │ └── pool.js # PostgreSQL connection pool
│ ├── middleware/
│ │ ├── auth.js # JWT verification
│ │ ├── rbac.js # Role-based access control
│ │ └── image.js # Image upload & processing
│ ├── models/
│ │ ├── list.model.js # Grocery list database queries
│ │ └── user.model.js # User database queries
│ ├── routes/
│ │ ├── auth.routes.js # Authentication routes
│ │ ├── list.routes.js # Grocery list routes
│ │ ├── admin.routes.js # Admin routes
│ │ └── users.routes.js # User routes
│ ├── app.js # Express app configuration
│ ├── server.js # Server entry point
│ ├── Dockerfile # Backend container config
│ └── package.json
├── frontend/
│ ├── public/ # Static assets
│ ├── src/
│ │ ├── api/
│ │ │ ├── axios.js # Axios instance with interceptors
│ │ │ ├── auth.js # Auth API calls
│ │ │ ├── list.js # List API calls
│ │ │ └── users.js # User API calls
│ │ ├── components/
│ │ │ ├── common/ # Reusable components
│ │ │ ├── forms/ # Form components
│ │ │ ├── items/ # Item-related components
│ │ │ ├── layout/ # Layout components
│ │ │ └── modals/ # Modal dialogs
│ │ ├── constants/
│ │ │ └── roles.js # Role constants
│ │ ├── context/
│ │ │ └── AuthContext.jsx # Authentication context
│ │ ├── pages/
│ │ │ ├── AdminPanel.jsx # Admin user management
│ │ │ ├── GroceryList.jsx # Main grocery list page
│ │ │ ├── Login.jsx # Login page
│ │ │ └── Register.jsx # Registration page
│ │ ├── styles/
│ │ │ ├── theme.css # CSS custom properties
│ │ │ ├── components/ # Component-specific styles
│ │ │ └── pages/ # Page-specific styles
│ │ ├── utils/
│ │ │ ├── PrivateRoute.jsx # Route protection
│ │ │ ├── RoleGuard.jsx # Role-based component guard
│ │ │ └── stringSimilarity.js # Fuzzy matching algorithm
│ │ ├── App.jsx # Root component with routing
│ │ └── main.tsx # Application entry point
│ ├── Dockerfile # Production build (nginx)
│ ├── Dockerfile.dev # Development build (Vite)
│ └── package.json
├── docker-compose.yml # Production compose file
├── docker-compose.dev.yml # Development compose file
├── docker-compose.prod.yml # Local production testing
├── API_DOCUMENTATION.md # Detailed API reference
└── README.md # This file
```
### Key Directories
- **`backend/constants/`** - Classification definitions (item types, groups, zones)
- **`backend/middleware/`** - Authentication, authorization, and image processing
- **`frontend/src/components/`** - Organized into 5 categories (common, forms, items, layout, modals)
- **`frontend/src/styles/`** - Theme system with CSS custom properties
- **`.gitea/workflows/`** - CI/CD pipeline for automated deployment
---
## 🔨 Development Workflow
### Component Organization
Frontend components are organized by feature:
```
components/
├── common/ # Reusable UI components
│ ├── ErrorMessage.jsx
│ ├── FloatingActionButton.jsx
│ ├── FormInput.jsx
│ ├── SortDropdown.jsx
│ └── UserRoleCard.jsx
├── forms/ # Form components
│ ├── AddItemForm.jsx
│ ├── ClassificationSection.jsx
│ └── ImageUploadSection.jsx
├── items/ # Item-related components
│ ├── GroceryItem.tsx
│ ├── GroceryListItem.jsx
│ └── SuggestionList.tsx
├── layout/ # Layout components
│ ├── AppLayout.jsx
│ └── Navbar.jsx
└── modals/ # Modal dialogs
├── AddImageModal.jsx
├── AddItemWithDetailsModal.jsx
├── ConfirmBuyModal.jsx
├── EditItemModal.jsx
├── ImageModal.jsx
├── ImageUploadModal.jsx
├── ItemClassificationModal.jsx
└── SimilarItemModal.jsx
```
Each subdirectory has an `index.js` barrel export for cleaner imports.
### Theme System
The application uses CSS custom properties for consistent theming:
```css
/* Theme variables defined in frontend/src/styles/theme.css */
:root {
/* Colors */
--color-primary: #0066cc;
--color-secondary: #6c757d;
--color-success: #28a745;
--color-danger: #dc3545;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
/* Typography */
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', ...;
--font-size-base: 1rem;
/* And many more... */
}
```
### Code Standards
- **Backend**: CommonJS modules, async/await for asynchronous operations
- **Frontend**: ES6 modules, functional components with hooks
- **TypeScript**: Used for type-safe components (gradually migrating)
- **Naming**: camelCase for functions/variables, PascalCase for components
- **File Structure**: Feature-based organization over type-based
### Testing
Currently, the project uses manual testing. Automated testing infrastructure is planned for future development.
---
## 🚢 Deployment
### CI/CD Pipeline
The application uses Gitea Actions for automated deployment:
```yaml
# .gitea/workflows/deploy.yml
Workflow:
1. Build Stage:
- Install dependencies
- Run tests (if present)
- Build Docker images
- Tag with :latest and :<commit-sha>
- Push to private registry
2. Deploy Stage:
- SSH to production server
- Upload docker-compose.yml
- Pull latest images
- Restart containers
- Prune old images
3. Notify Stage:
- Send deployment status via webhook
```
### Production Architecture
```
Production Server
├── Nginx Reverse Proxy (Port 80/443)
│ ├── /api → Backend Container (Port 5000)
│ └── /* → Frontend Container (Port 3000)
├── Docker Compose
│ ├── backend:latest (from registry)
│ └── frontend:latest (from registry)
└── PostgreSQL (External, not containerized)
```
### Environment Configuration
**Production (`docker-compose.yml`):**
- Pulls pre-built images from registry
- Uses external PostgreSQL database
- Environment configured via `backend.env` and `frontend.env`
- Automatic restart on failure
**Development (`docker-compose.dev.yml`):**
- Builds images locally
- Volume mounts for hot-reload
- Uses local `.env` files
- Nodemon for backend, Vite HMR for frontend
### Deployment Process
1. **Commit and push to `main` branch**
```bash
git add .
git commit -m "Your commit message"
git push origin main
```
2. **CI/CD automatically:**
- Runs tests
- Builds Docker images
- Pushes to registry
- Deploys to production
3. **Manual deployment (if needed):**
```bash
ssh user@production-server
cd /opt/costco-app
docker compose pull
docker compose up -d --remove-orphans
```
---
## 🗄️ Database Schema
### Entity Relationship Diagram
```mermaid
erDiagram
users ||--o{ grocery_list : creates
users ||--o{ grocery_history : adds
grocery_list ||--o{ grocery_history : tracks
grocery_list ||--o| item_classification : has
users {
int id PK
string username UK
string password
string name
string role
}
grocery_list {
int id PK
string item_name
int quantity
boolean bought
bytea item_image
string image_mime_type
int added_by FK
timestamp modified_on
}
item_classification {
int id PK,FK
string item_type
string item_group
string zone
float confidence
string source
}
grocery_history {
int id PK
int list_item_id FK
int quantity
int added_by FK
timestamp added_on
}
```
### Key Relationships
- **users → grocery_list**: One-to-many (creator relationship)
- **users → grocery_history**: One-to-many (contributor relationship)
- **grocery_list → grocery_history**: One-to-many (tracks all additions/modifications)
- **grocery_list → item_classification**: One-to-one (optional classification data)
---
## 🤝 Contributing
Contributions are welcome! Please follow these guidelines:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Guidelines
- Follow existing code style and structure
- Add comments for complex logic
- Update documentation for new features
- Test thoroughly before submitting PR
---
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
---
## 👤 Author
**Nico Saya**
- Repository: [git.nicosaya.com/nalalangan/costco-grocery-list](https://git.nicosaya.com/nalalangan/costco-grocery-list)
---
## 🙏 Acknowledgments
- React team for the excellent framework
- Express.js community for robust server framework
- PostgreSQL for reliable data persistence
- Sharp for efficient image processing
- All contributors and users of this application
---
## 📞 Support
For issues, questions, or feature requests, please:
1. Check existing issues in the repository
2. Create a new issue with detailed description
3. Include steps to reproduce (for bugs)
4. Tag appropriately (bug, enhancement, question, etc.)
---
**Last Updated:** January 4, 2026

View File

@ -40,7 +40,4 @@ app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes"); const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes); app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes");
app.use("/config", configRoutes);
module.exports = app; module.exports = app;

View File

@ -1,15 +0,0 @@
/**
* Application-wide constants
* These are non-secret configuration values shared across the application
*/
module.exports = {
// File upload limits
MAX_FILE_SIZE_MB: 20,
MAX_FILE_SIZE_BYTES: 20 * 1024 * 1024,
// Image processing
MAX_IMAGE_DIMENSION: 800,
IMAGE_QUALITY: 85,
IMAGE_FORMAT: 'jpeg'
};

View File

@ -1,14 +0,0 @@
/**
* Configuration endpoints
* Public endpoints that provide application configuration to clients
*/
const { MAX_FILE_SIZE_MB, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
exports.getConfig = (req, res) => {
res.json({
maxFileSizeMB: MAX_FILE_SIZE_MB,
maxImageDimension: MAX_IMAGE_DIMENSION,
imageQuality: IMAGE_QUALITY
});
};

View File

@ -32,8 +32,7 @@ exports.addItem = async (req, res) => {
exports.markBought = async (req, res) => { exports.markBought = async (req, res) => {
const userId = req.user.id; const userId = req.user.id;
const { id, quantity } = req.body; await List.setBought(req.body.id, userId);
await List.setBought(id, userId, quantity);
res.json({ message: "Item marked bought" }); res.json({ message: "Item marked bought" });
}; };

View File

@ -1,5 +1,4 @@
const User = require("../models/user.model"); const User = require("../models/user.model");
const bcrypt = require("bcryptjs");
exports.test = async (req, res) => { exports.test = async (req, res) => {
console.log("User route is working"); console.log("User route is working");
@ -46,92 +45,11 @@ exports.deleteUser = async (req, res) => {
} }
}; };
exports.checkIfUserExists = async (req, res) => { exports.checkIfUserExists = async (req, res) => {
const { username } = req.query; const { username } = req.query;
const exists = await User.checkIfUserExists(username); const users = await User.checkIfUserExists(username);
res.json(exists); res.json(users);
};
exports.getCurrentUser = async (req, res) => {
try {
const userId = req.user.id;
const user = await User.getUserById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
} catch (err) {
console.error("Error getting current user:", err);
res.status(500).json({ error: "Failed to get user profile" });
}
};
exports.updateCurrentUser = async (req, res) => {
try {
const userId = req.user.id;
const { display_name } = req.body;
if (!display_name || display_name.trim().length === 0) {
return res.status(400).json({ error: "Display name is required" });
}
if (display_name.length > 100) {
return res.status(400).json({ error: "Display name must be 100 characters or less" });
}
const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() });
if (!updated) {
return res.status(404).json({ error: "User not found" });
}
res.json({ message: "Profile updated successfully", user: updated });
} catch (err) {
console.error("Error updating user profile:", err);
res.status(500).json({ error: "Failed to update profile" });
}
};
exports.changePassword = async (req, res) => {
try {
const userId = req.user.id;
const { current_password, new_password } = req.body;
// Validation
if (!current_password || !new_password) {
return res.status(400).json({ error: "Current password and new password are required" });
}
if (new_password.length < 6) {
return res.status(400).json({ error: "New password must be at least 6 characters" });
}
// Get current password hash
const currentHash = await User.getUserPasswordHash(userId);
if (!currentHash) {
return res.status(404).json({ error: "User not found" });
}
// Verify current password
const isValidPassword = await bcrypt.compare(current_password, currentHash);
if (!isValidPassword) {
return res.status(401).json({ error: "Current password is incorrect" });
}
// Hash new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(new_password, salt);
// Update password
await User.updateUserPassword(userId, hashedPassword);
res.json({ message: "Password changed successfully" });
} catch (err) {
console.error("Error changing password:", err);
res.status(500).json({ error: "Failed to change password" });
}
}; };

View File

@ -1,12 +1,11 @@
const multer = require("multer"); const multer = require("multer");
const sharp = require("sharp"); const sharp = require("sharp");
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
// Configure multer for memory storage (we'll process before saving to DB) // Configure multer for memory storage (we'll process before saving to DB)
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { limits: {
fileSize: MAX_FILE_SIZE_BYTES, fileSize: 10 * 1024 * 1024, // 10MB max file size
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Only accept images // Only accept images
@ -25,13 +24,13 @@ const processImage = async (req, res, next) => {
} }
try { try {
// Compress and resize image using constants // Compress and resize image to 800x800px, JPEG quality 85
const processedBuffer = await sharp(req.file.buffer) const processedBuffer = await sharp(req.file.buffer)
.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, { .resize(800, 800, {
fit: "inside", fit: "inside",
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({ quality: IMAGE_QUALITY }) .jpeg({ quality: 85 })
.toBuffer(); .toBuffer();
// Attach processed image to request // Attach processed image to request

View File

@ -1,10 +0,0 @@
-- Add display_name column to users table
-- This allows users to have a friendly name separate from their username
ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
-- Set display_name to name for existing users (as default)
UPDATE users
SET display_name = name
WHERE display_name IS NULL;

View File

@ -10,23 +10,18 @@ exports.getUnboughtItems = async () => {
gl.bought, gl.bought,
ENCODE(gl.item_image, 'base64') as item_image, ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type, 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,
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT gh.added_by
FROM grocery_history gh
WHERE gh.list_item_id = gl.id
ORDER BY gh.added_by
) gh
JOIN users u ON gh.added_by = u.id
) as added_by_users,
gl.modified_on as last_added_on, gl.modified_on as last_added_on,
ic.item_type, ic.item_type,
ic.item_group, ic.item_group,
ic.zone ic.zone
FROM grocery_list gl 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 LEFT JOIN item_classification ic ON gl.id = ic.id
WHERE gl.bought = FALSE 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` ORDER BY gl.id ASC`
); );
return result.rows; return result.rows;
@ -34,30 +29,7 @@ exports.getUnboughtItems = async () => {
exports.getItemByName = async (itemName) => { exports.getItemByName = async (itemName) => {
const result = await pool.query( const result = await pool.query(
`SELECT "SELECT * FROM grocery_list WHERE item_name ILIKE $1",
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,
(
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT gh.added_by
FROM grocery_history gh
WHERE gh.list_item_id = gl.id
ORDER BY gh.added_by
) gh
JOIN users u ON gh.added_by = u.id
) 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 item_classification ic ON gl.id = ic.id
WHERE gl.item_name ILIKE $1`,
[itemName] [itemName]
); );
@ -66,11 +38,9 @@ exports.getItemByName = async (itemName) => {
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => { exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
const lowerItemName = itemName.toLowerCase();
const result = await pool.query( const result = await pool.query(
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1", "SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
[lowerItemName] [itemName]
); );
if (result.rowCount > 0) { if (result.rowCount > 0) {
@ -103,38 +73,18 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
`INSERT INTO grocery_list `INSERT INTO grocery_list
(item_name, quantity, added_by, item_image, image_mime_type) (item_name, quantity, added_by, item_image, image_mime_type)
VALUES ($1, $2, $3, $4, $5) RETURNING id`, VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[lowerItemName, quantity, userId, imageBuffer, mimeType] [itemName, quantity, userId, imageBuffer, mimeType]
); );
return insert.rows[0].id; return insert.rows[0].id;
} }
}; };
exports.setBought = async (id, userId, quantityBought) => { exports.setBought = async (id, userId) => {
// Get current item
const item = await pool.query(
"SELECT quantity FROM grocery_list WHERE id = $1",
[id]
);
if (!item.rows[0]) return;
const currentQuantity = item.rows[0].quantity;
const remainingQuantity = currentQuantity - quantityBought;
if (remainingQuantity <= 0) {
// Mark as bought if all quantity is purchased
await pool.query( await pool.query(
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1", "UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[id] [id]
); );
} else {
// Reduce quantity if partial purchase
await pool.query(
"UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, id]
);
}
}; };
@ -149,12 +99,13 @@ exports.addHistoryRecord = async (itemId, quantity, userId) => {
exports.getSuggestions = async (query) => { exports.getSuggestions = async (query) => {
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT LOWER(item_name) as item_name `SELECT DISTINCT item_name
FROM grocery_list FROM grocery_list
WHERE item_name ILIKE $1 WHERE item_name ILIKE $1
LIMIT 10`, LIMIT 10`,
[`%${query}%`] [`%${query}%`]
); );
res = result.rows;
return result.rows; return result.rows;
}; };
@ -167,20 +118,15 @@ exports.getRecentlyBoughtItems = async () => {
gl.bought, gl.bought,
ENCODE(gl.item_image, 'base64') as item_image, ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type, 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,
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT gh.added_by
FROM grocery_history gh
WHERE gh.list_item_id = gl.id
ORDER BY gh.added_by
) gh
JOIN users u ON gh.added_by = u.id
) as added_by_users,
gl.modified_on as last_added_on gl.modified_on as last_added_on
FROM grocery_list gl 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 WHERE gl.bought = TRUE
AND gl.modified_on >= NOW() - INTERVAL '24 hours' 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` ORDER BY gl.modified_on DESC`
); );
return result.rows; return result.rows;

View File

@ -24,49 +24,10 @@ exports.createUser = async (username, hashedPassword, name) => {
exports.getAllUsers = async () => { exports.getAllUsers = async () => {
const result = await pool.query("SELECT id, username, name, role, display_name FROM users ORDER BY id ASC"); const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
return result.rows; return result.rows;
}; };
exports.getUserById = async (id) => {
const result = await pool.query(
"SELECT id, username, name, role, display_name FROM users WHERE id = $1",
[id]
);
return result.rows[0];
};
exports.updateUserProfile = async (id, updates) => {
const { display_name } = updates;
const result = await pool.query(
`UPDATE users
SET display_name = COALESCE($1, display_name)
WHERE id = $2
RETURNING id, username, name, role, display_name`,
[display_name, id]
);
return result.rows[0];
};
exports.updateUserPassword = async (id, hashedPassword) => {
const result = await pool.query(
`UPDATE users
SET password = $1
WHERE id = $2
RETURNING id`,
[hashedPassword, id]
);
return result.rows[0];
};
exports.getUserPasswordHash = async (id) => {
const result = await pool.query(
"SELECT password FROM users WHERE id = $1",
[id]
);
return result.rows[0]?.password;
};
exports.updateUserRole = async (id, role) => { exports.updateUserRole = async (id, role) => {
const result = await pool.query( const result = await pool.query(

View File

@ -1,8 +0,0 @@
const express = require("express");
const router = express.Router();
const configController = require("../controllers/config.controller");
// Public endpoint - no authentication required
router.get("/", configController.getConfig);
module.exports = router;

View File

@ -7,9 +7,4 @@ const { ROLES } = require("../models/user.model");
router.get("/exists", usersController.checkIfUserExists); router.get("/exists", usersController.checkIfUserExists);
router.get("/test", usersController.test); router.get("/test", usersController.test);
// Current user profile routes (authenticated)
router.get("/me", auth, usersController.getCurrentUser);
router.patch("/me", auth, usersController.updateCurrentUser);
router.post("/me/change-password", auth, usersController.changePassword);
module.exports = router; module.exports = router;

16
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,16 @@
services:
frontend:
build: ./frontend
environment:
- MODE_ENV=production
ports:
- "3000:80"
depends_on:
- backend
backend:
build: ./backend
ports:
- "5000:5000"
env_file:
- ./backend/.env

View File

@ -1,3 +1,5 @@
version: "3.9"
services: services:
backend: backend:
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:latest image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:latest

View File

@ -1,771 +0,0 @@
# Costco Grocery List API Documentation
Base URL: `http://localhost:5000/api`
## Table of Contents
- [Authentication](#authentication)
- [Grocery List Management](#grocery-list-management)
- [User Management](#user-management)
- [Admin Operations](#admin-operations)
- [Role-Based Access Control](#role-based-access-control)
- [Error Responses](#error-responses)
---
## Authentication
All authenticated endpoints require a JWT token in the `Authorization` header:
```
Authorization: Bearer <token>
```
### POST /auth/register
Register a new user account.
**Access:** Public
**Request Body:**
```json
{
"username": "string (lowercase)",
"password": "string",
"name": "string"
}
```
**Response:** `200 OK`
```json
{
"message": "User registered",
"user": {
"id": 1,
"username": "johndoe",
"name": "John Doe",
"role": "viewer"
}
}
```
**Error:** `400 Bad Request`
```json
{
"message": "Registration failed",
"error": "Error details"
}
```
---
### POST /auth/login
Authenticate and receive a JWT token.
**Access:** Public
**Request Body:**
```json
{
"username": "string (lowercase)",
"password": "string"
}
```
**Response:** `200 OK`
```json
{
"token": "jwt_token_string",
"username": "johndoe",
"role": "editor"
}
```
**Errors:**
- `401 Unauthorized` - User not found or invalid credentials
```json
{
"message": "User not found"
}
```
or
```json
{
"message": "Invalid credentials"
}
```
---
## Grocery List Management
### GET /list
Retrieve all unbought grocery items.
**Access:** Authenticated (All roles)
**Query Parameters:** None
**Response:** `200 OK`
```json
[
{
"id": 1,
"item_name": "milk",
"quantity": 2,
"bought": false,
"item_image": "base64_encoded_string",
"image_mime_type": "image/jpeg",
"added_by_users": ["John Doe", "Jane Smith"],
"modified_on": "2026-01-03T12:00:00Z",
"item_type": "dairy",
"item_group": "milk",
"zone": "DAIRY"
}
]
```
**Fields:**
- `id` - Unique item identifier
- `item_name` - Name of grocery item (lowercase)
- `quantity` - Number of items needed
- `bought` - Purchase status (always false for this endpoint)
- `item_image` - Base64 encoded image (nullable)
- `image_mime_type` - MIME type of image (nullable)
- `added_by_users` - Array of user names who added/modified this item
- `modified_on` - Last modification timestamp
- `item_type` - Classification type (nullable)
- `item_group` - Classification group (nullable)
- `zone` - Store zone location (nullable)
---
### GET /list/item-by-name
Retrieve a specific item by name (case-insensitive).
**Access:** Authenticated (All roles)
**Query Parameters:**
- `itemName` (required) - Name of the item to search
**Example:** `GET /list/item-by-name?itemName=milk`
**Response:** `200 OK`
```json
{
"id": 1,
"item_name": "milk",
"quantity": 2,
"bought": false,
"item_image": "base64_encoded_string",
"image_mime_type": "image/jpeg"
}
```
Returns `null` if item not found.
---
### GET /list/suggest
Get item name suggestions based on partial input.
**Access:** Authenticated (All roles)
**Query Parameters:**
- `query` (required) - Partial item name to search
**Example:** `GET /list/suggest?query=mil`
**Response:** `200 OK`
```json
[
{ "item_name": "milk" },
{ "item_name": "almond milk" },
{ "item_name": "oat milk" }
]
```
**Behavior:**
- Case-insensitive partial matching (ILIKE)
- Searches all items in `grocery_list` (bought and unbought)
- Returns up to 10 suggestions
- Ordered by database query result
---
### GET /list/recently-bought
Retrieve items bought within the last 24 hours.
**Access:** Authenticated (All roles)
**Query Parameters:** None
**Response:** `200 OK`
```json
[
{
"id": 5,
"item_name": "bread",
"quantity": 1,
"bought": true,
"item_image": "base64_encoded_string",
"image_mime_type": "image/jpeg",
"added_by_users": ["John Doe"],
"modified_on": "2026-01-03T10:30:00Z",
"item_type": "bakery",
"item_group": "bread",
"zone": "BAKERY"
}
]
```
**Behavior:**
- Returns items with `bought = true`
- Filters by `modified_on` within last 24 hours
- Includes classification data if available
---
### GET /list/item/:id/classification
Get classification data for a specific item.
**Access:** Authenticated (All roles)
**Path Parameters:**
- `id` (required) - Item ID
**Example:** `GET /list/item/1/classification`
**Response:** `200 OK`
```json
{
"item_type": "dairy",
"item_group": "milk",
"zone": "DAIRY",
"confidence": 1.0,
"source": "user"
}
```
Returns empty object `{}` if no classification exists.
**Fields:**
- `item_type` - Classification type (e.g., "dairy", "produce", "meat")
- `item_group` - Sub-group within type (e.g., "milk", "cheese")
- `zone` - Store zone (e.g., "DAIRY", "PRODUCE", "MEAT")
- `confidence` - Classification confidence (0.0 to 1.0)
- `source` - Origin of classification ("user" or "auto")
---
### POST /list/add
Add a new item or update quantity of existing item.
**Access:** Editor, Admin
**Content-Type:** `multipart/form-data`
**Request Body:**
```
itemName: string (required)
quantity: number (required)
image: file (optional) - Image file
```
**Response:** `200 OK`
```json
{
"message": "Item added/updated",
"addedBy": 1
}
```
**Behavior:**
- If item exists and is unbought: updates quantity
- If item exists and is bought: marks as unbought with new quantity
- If item doesn't exist: creates new item
- Adds record to `grocery_history` table
- Processes and optimizes uploaded image (800x800px, JPEG 85% quality)
**Image Processing:**
- Accepted formats: JPEG, PNG, GIF, WebP
- Max file size: 5MB
- Output: 800x800px JPEG at 85% quality
- Stored as binary in database
---
### POST /list/update-image
Update or add image to an existing item.
**Access:** Editor, Admin
**Content-Type:** `multipart/form-data`
**Request Body:**
```
id: number (required)
itemName: string (required)
quantity: number (required)
image: file (required) - Image file
```
**Response:** `200 OK`
```json
{
"message": "Image updated successfully"
}
```
**Error:** `400 Bad Request`
```json
{
"message": "No image provided"
}
```
---
### POST /list/mark-bought
Mark an item as purchased.
**Access:** Editor, Admin
**Request Body:**
```json
{
"id": 1
}
```
**Response:** `200 OK`
```json
{
"message": "Item marked bought"
}
```
**Behavior:**
- Sets `bought = true`
- Updates `modified_on` timestamp
- Item moves to "Recently Bought" list
---
### PUT /list/item/:id
Update item details and/or classification.
**Access:** Editor, Admin
**Path Parameters:**
- `id` (required) - Item ID
**Request Body:**
```json
{
"itemName": "string (optional)",
"quantity": 2,
"classification": {
"item_type": "dairy",
"item_group": "milk",
"zone": "DAIRY"
}
}
```
**Response:** `200 OK`
```json
{
"message": "Item updated successfully"
}
```
**Errors:**
- `400 Bad Request` - Invalid classification values
```json
{
"message": "Invalid item_type"
}
```
or
```json
{
"message": "Invalid item_group for selected item_type"
}
```
or
```json
{
"message": "Invalid zone"
}
```
**Classification Validation:**
- `item_type`: Must be one of the valid types defined in `backend/constants/classifications.js`
- `item_group`: Must be valid for the selected `item_type`
- `zone`: Must be one of the predefined store zones
**Valid Zones:**
- ENTRANCE, PRODUCE, MEAT, DELI, BAKERY, DAIRY, FROZEN, DRY_GOODS, BEVERAGES, SNACKS, HOUSEHOLD, HEALTH, CHECKOUT
---
## User Management
### GET /users/exists
Check if a username already exists.
**Access:** Public
**Query Parameters:**
- `username` (required) - Username to check
**Example:** `GET /users/exists?username=johndoe`
**Response:** `200 OK`
```json
{
"exists": true
}
```
or
```json
{
"exists": false
}
```
---
### GET /users/test
Health check endpoint for user routes.
**Access:** Public
**Response:** `200 OK`
```json
{
"message": "User route is working"
}
```
---
## Admin Operations
### GET /admin/users
Retrieve all registered users.
**Access:** Admin only
**Query Parameters:** None
**Response:** `200 OK`
```json
[
{
"id": 1,
"username": "johndoe",
"name": "John Doe",
"role": "editor"
},
{
"id": 2,
"username": "janesmith",
"name": "Jane Smith",
"role": "viewer"
}
]
```
---
### PUT /admin/users
Update a user's role.
**Access:** Admin only
**Request Body:**
```json
{
"id": 1,
"role": "admin"
}
```
**Valid Roles:**
- `viewer` - Read-only access
- `editor` - Can add/modify/buy items
- `admin` - Full access including user management
**Response:** `200 OK`
```json
{
"message": "Role updated",
"id": 1,
"role": "admin"
}
```
**Errors:**
- `400 Bad Request` - Invalid role
```json
{
"error": "Invalid role"
}
```
- `404 Not Found` - User not found
```json
{
"error": "User not found"
}
```
- `500 Internal Server Error`
```json
{
"error": "Failed to update role"
}
```
---
### DELETE /admin/users
Delete a user account.
**Access:** Admin only
**Path Parameters:**
- `id` (required) - User ID
**Example:** `DELETE /admin/users?id=5`
**Response:** `200 OK`
```json
{
"message": "User deleted",
"id": 5
}
```
**Errors:**
- `404 Not Found`
```json
{
"error": "User not found"
}
```
- `500 Internal Server Error`
```json
{
"error": "Failed to delete user"
}
```
---
## Role-Based Access Control
### Roles and Permissions
| Role | View Lists | Add/Edit Items | Buy Items | User Management |
|------|-----------|----------------|-----------|-----------------|
| **viewer** | ✅ | ❌ | ❌ | ❌ |
| **editor** | ✅ | ✅ | ✅ | ❌ |
| **admin** | ✅ | ✅ | ✅ | ✅ |
### Protected Endpoints by Role
**All Authenticated Users (viewer, editor, admin):**
- GET /list
- GET /list/item-by-name
- GET /list/suggest
- GET /list/recently-bought
- GET /list/item/:id/classification
**Editor and Admin Only:**
- POST /list/add
- POST /list/update-image
- POST /list/mark-bought
- PUT /list/item/:id
**Admin Only:**
- GET /admin/users
- PUT /admin/users
- DELETE /admin/users
---
## Error Responses
### Standard Error Format
```json
{
"message": "Error description",
"error": "Detailed error information (optional)"
}
```
### Common HTTP Status Codes
| Code | Meaning | Common Causes |
|------|---------|---------------|
| 200 | OK | Successful request |
| 400 | Bad Request | Invalid input data, missing required fields |
| 401 | Unauthorized | Missing/invalid token, invalid credentials |
| 403 | Forbidden | Insufficient permissions for operation |
| 404 | Not Found | Resource doesn't exist |
| 500 | Internal Server Error | Server-side error |
### Authentication Errors
**Missing Token:**
```json
{
"message": "No token provided"
}
```
**Invalid Token:**
```json
{
"message": "Invalid or expired token"
}
```
**Insufficient Permissions:**
```json
{
"message": "Access denied"
}
```
---
## Data Models
### User
```typescript
{
id: number
username: string (lowercase, unique)
password: string (bcrypt hashed)
name: string
role: "viewer" | "editor" | "admin"
}
```
### Grocery Item
```typescript
{
id: number
item_name: string (lowercase)
quantity: number
bought: boolean
item_image: Buffer | null (binary image data)
image_mime_type: string | null
added_by: number (user id)
modified_on: timestamp
}
```
### Item Classification
```typescript
{
id: number (references grocery_list.id)
item_type: string | null
item_group: string | null
zone: string | null
confidence: number (0.0 - 1.0)
source: "user" | "auto"
}
```
### Grocery History
```typescript
{
id: number
list_item_id: number (references grocery_list.id)
quantity: number
added_by: number (user id)
added_on: timestamp
}
```
---
## Notes
### Case Sensitivity
- Usernames are stored and compared in **lowercase**
- Item names are stored in **lowercase**
- All searches are **case-insensitive** (ILIKE in PostgreSQL)
### Token Expiration
- JWT tokens expire after **1 year**
- Include token expiration handling in client applications
### Image Optimization
- Images are automatically processed:
- Resized to 800x800px (maintains aspect ratio)
- Converted to JPEG format
- Compressed to 85% quality
- Max upload size: 5MB
### Database Transaction Handling
- Item additions/updates include history tracking
- Classification updates are atomic operations
### CORS Configuration
- Allowed origins configured via `ALLOWED_ORIGINS` environment variable
- Supports both static origins and IP range patterns (e.g., `192.168.*.*`)
---
## Frontend Integration Examples
### Login Flow
```javascript
const response = await axios.post('/api/auth/login', {
username: 'johndoe',
password: 'password123'
});
const { token, role, username } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('role', role);
localStorage.setItem('username', username);
```
### Authenticated Request
```javascript
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:5000/api',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const items = await api.get('/list');
```
### Adding Item with Image
```javascript
const formData = new FormData();
formData.append('itemName', 'milk');
formData.append('quantity', 2);
formData.append('image', imageFile); // File object from input
await api.post('/list/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
```
---
## Version Information
- API Version: 1.0
- Last Updated: January 3, 2026
- Backend Framework: Express 5.1.0
- Database: PostgreSQL 8.16.0
- Authentication: JWT (jsonwebtoken 9.0.2)

View File

@ -1,473 +0,0 @@
# Code Cleanup Guide
This guide documents the cleanup patterns and best practices applied to the codebase, starting with `GroceryList.jsx`. Use this as a reference for maintaining consistent, clean, and readable code across all files.
## Table of Contents
1. [Spacing & Organization](#spacing--organization)
2. [Comment Formatting](#comment-formatting)
3. [Code Simplification](#code-simplification)
4. [React Performance Patterns](#react-performance-patterns)
5. [Cleanup Checklist](#cleanup-checklist)
---
## Spacing & Organization
### Two-Line Separation
Use **2 blank lines** to separate logical groups and functions.
**Before:**
```javascript
const handleAdd = async (itemName, quantity) => {
// function body
};
const handleBought = async (id) => {
// function body
};
```
**After:**
```javascript
const handleAdd = async (itemName, quantity) => {
// function body
};
const handleBought = async (id) => {
// function body
};
```
### Logical Grouping
Organize code into clear sections:
- State declarations
- Data loading functions
- Computed values (useMemo)
- Event handlers grouped by functionality
- Helper functions
- Render logic
---
## Comment Formatting
### Section Headers
Use the `=== Section Name ===` format for major sections, followed by 2 blank lines before the next code block.
**Pattern:**
```javascript
// === State ===
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
// === Data Loading ===
const loadItems = async () => {
// implementation
};
// === Event Handlers ===
const handleClick = () => {
// implementation
};
```
### Common Section Names
- `=== State ===`
- `=== Data Loading ===`
- `=== Computed Values ===` or `=== Sorted Items Computation ===`
- `=== Event Handlers ===` or specific groups like `=== Item Addition Handlers ===`
- `=== Helper Functions ===`
- `=== Render ===`
---
## Code Simplification
### 1. Optional Chaining
Replace `&&` null checks with optional chaining when accessing nested properties.
**Before:**
```javascript
if (existingItem && existingItem.bought === false) {
// do something
}
```
**After:**
```javascript
if (existingItem?.bought === false) {
// do something
}
```
**When to use:**
- Accessing properties on potentially undefined/null objects
- Checking nested properties: `user?.profile?.name`
- Method calls: `item?.toString?.()`
**When NOT to use:**
- When you need to check if the object exists first (use explicit check)
- For boolean coercion: `if (item)` is clearer than `if (item?.)`
---
### 2. Ternary Operators
Use ternary operators for simple conditional assignments and returns.
**Before:**
```javascript
let result;
if (condition) {
result = "yes";
} else {
result = "no";
}
```
**After:**
```javascript
const result = condition ? "yes" : "no";
```
**When to use:**
- Simple conditional assignments
- Inline JSX conditionals
- Return statements with simple conditions
**When NOT to use:**
- Complex multi-line logic (use if/else for readability)
- Nested ternaries (hard to read)
---
### 3. Early Returns
Use early returns to reduce nesting.
**Before:**
```javascript
const handleSuggest = async (text) => {
if (text.trim()) {
// long implementation
} else {
setSuggestions([]);
setButtonText("Add Item");
}
};
```
**After:**
```javascript
const handleSuggest = async (text) => {
if (!text.trim()) {
setSuggestions([]);
setButtonText("Add Item");
return;
}
// main implementation without nesting
};
```
---
### 4. Destructuring
Use destructuring for cleaner variable access.
**Before:**
```javascript
const username = user.username;
const email = user.email;
const role = user.role;
```
**After:**
```javascript
const { username, email, role } = user;
```
---
### 5. Array Methods Over Loops
Prefer array methods (`.map()`, `.filter()`, `.find()`) over traditional loops.
**Before:**
```javascript
const activeItems = [];
for (let i = 0; i < items.length; i++) {
if (!items[i].bought) {
activeItems.push(items[i]);
}
}
```
**After:**
```javascript
const activeItems = items.filter(item => !item.bought);
```
---
## React Performance Patterns
### 1. useCallback for Event Handlers
Wrap event handlers in `useCallback` to prevent unnecessary re-renders of child components.
```javascript
const handleBought = useCallback(async (id, quantity) => {
await markBought(id);
setItems(prevItems => prevItems.filter(item => item.id !== id));
loadRecentlyBought();
}, []); // Add dependencies if needed
```
**When to use:**
- Handler functions passed as props to memoized child components
- Functions used as dependencies in other hooks
- Functions in frequently re-rendering components
---
### 2. useMemo for Expensive Computations
Use `useMemo` for computationally expensive operations or large transformations.
```javascript
const sortedItems = useMemo(() => {
const sorted = [...items];
if (sortMode === "az") {
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
}
return sorted;
}, [items, sortMode]);
```
**When to use:**
- Sorting/filtering large arrays
- Complex calculations
- Derived state that's expensive to compute
---
### 3. React.memo for Components
Wrap components with `React.memo` and provide custom comparison functions to prevent unnecessary re-renders.
```javascript
const GroceryListItem = React.memo(
({ item, onClick, onLongPress }) => {
// component implementation
},
(prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.item_name === nextProps.item_name &&
prevProps.quantity === nextProps.quantity &&
prevProps.item_image === nextProps.item_image
);
}
);
```
**When to use:**
- List item components that render frequently
- Components with stable props
- Pure components (output depends only on props)
---
### 4. In-Place State Updates
Update specific items in state instead of reloading entire datasets.
**Before:**
```javascript
const handleUpdate = async (id, newData) => {
await updateItem(id, newData);
loadItems(); // Reloads entire list from server
};
```
**After:**
```javascript
const handleUpdate = useCallback(async (id, newData) => {
const response = await updateItem(id, newData);
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
}, []);
```
**Benefits:**
- Faster updates (no server round-trip for entire list)
- Preserves scroll position
- Better user experience (no full re-render)
---
## Cleanup Checklist
Use this checklist when cleaning up a file:
### Structure & Organization
- [ ] Group related state variables together
- [ ] Use 2-line spacing between logical sections
- [ ] Add section comments using `=== Format ===`
- [ ] Order sections logically (state → data loading → computed → handlers → helpers → render)
### Code Simplification
- [ ] Replace `&&` null checks with optional chaining where appropriate
- [ ] Convert simple if/else to ternary operators
- [ ] Use early returns to reduce nesting
- [ ] Apply destructuring for cleaner variable access
- [ ] Use array methods instead of loops
### React Performance
- [ ] Wrap stable event handlers in `useCallback`
- [ ] Use `useMemo` for expensive computations
- [ ] Consider `React.memo` for list items or frequently re-rendering components
- [ ] Update state in-place instead of reloading from server
### Consistency
- [ ] Check naming conventions (camelCase for functions/variables)
- [ ] Ensure consistent spacing and indentation
- [ ] Remove unused imports and variables
- [ ] Remove console.logs (except intentional debugging aids)
### Testing After Cleanup
- [ ] Verify no functionality broke
- [ ] Check that performance improved (using React DevTools Profiler)
- [ ] Test all interactive features
- [ ] Verify mobile/responsive behavior still works
---
## Example: Before & After
### Before Cleanup
```javascript
import { useState, useEffect } from "react";
export default function MyComponent() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const loadItems = async () => {
setLoading(true);
const res = await getItems();
setItems(res.data);
setLoading(false);
};
useEffect(() => {
loadItems();
}, []);
const handleUpdate = async (id, data) => {
await updateItem(id, data);
loadItems();
};
const handleDelete = async (id) => {
await deleteItem(id);
loadItems();
};
if (loading) return <p>Loading...</p>;
return (
<div>
{items.map(item => (
<Item key={item.id} item={item} onUpdate={handleUpdate} onDelete={handleDelete} />
))}
</div>
);
}
```
### After Cleanup
```javascript
import { useCallback, useEffect, useMemo, useState } from "react";
export default function MyComponent() {
// === State ===
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
// === Data Loading ===
const loadItems = async () => {
setLoading(true);
const res = await getItems();
setItems(res.data);
setLoading(false);
};
useEffect(() => {
loadItems();
}, []);
// === Event Handlers ===
const handleUpdate = useCallback(async (id, data) => {
const response = await updateItem(id, data);
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
}, []);
const handleDelete = useCallback(async (id) => {
await deleteItem(id);
setItems(prevItems => prevItems.filter(item => item.id !== id));
}, []);
// === Render ===
if (loading) return <p>Loading...</p>;
return (
<div>
{items.map(item => (
<Item
key={item.id}
item={item}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
);
}
```
**Key improvements:**
1. Added section comments for clarity
2. Proper 2-line spacing between sections
3. Wrapped handlers in `useCallback` for performance
4. In-place state updates instead of reloading entire list
5. Better import organization
---
## Additional Resources
- [React Performance Optimization](https://react.dev/learn/render-and-commit)
- [useCallback Hook](https://react.dev/reference/react/useCallback)
- [useMemo Hook](https://react.dev/reference/react/useMemo)
- [React.memo](https://react.dev/reference/react/memo)
---
## Notes
- **Don't over-optimize**: Not every component needs `useCallback`/`useMemo`. Apply these patterns when you have measurable performance issues or when working with large lists.
- **Readability first**: If a simplification makes code harder to understand, skip it. Code should be optimized for human reading first.
- **Test thoroughly**: Always test after cleanup to ensure no functionality broke.
- **Incremental cleanup**: Don't try to clean up everything at once. Focus on one file at a time.
---
**Last Updated**: Based on GroceryList.jsx cleanup (January 2026)

View File

@ -1,415 +0,0 @@
# Settings & Dark Mode Implementation
**Status**: ✅ Phase 1 Complete, Phase 2 Complete
**Last Updated**: January 2026
---
## Overview
A comprehensive user settings system with persistent preferences, dark mode support, and customizable list display options. Settings are stored per-user in localStorage and automatically applied across the application.
---
## Architecture
### Context Hierarchy
```
<ConfigProvider> ← Server config (image limits, etc.)
<AuthProvider> ← Authentication state
<SettingsProvider> ← User preferences (NEW)
<App />
</SettingsProvider>
</AuthProvider>
</ConfigProvider>
```
### Key Components
#### SettingsContext ([frontend/src/context/SettingsContext.jsx](frontend/src/context/SettingsContext.jsx))
- Manages user preferences with localStorage persistence
- Storage key pattern: `user_preferences_${username}`
- Automatically applies theme to `document.documentElement`
- Listens for system theme changes in auto mode
- Provides `updateSettings()` and `resetSettings()` methods
#### Settings Page ([frontend/src/pages/Settings.jsx](frontend/src/pages/Settings.jsx))
- Tabbed interface: Appearance, List Display, Behavior
- Real-time preview of setting changes
- Reset to defaults functionality
---
## Settings Schema
```javascript
{
// === Appearance ===
theme: "light" | "dark" | "auto", // Theme mode
compactView: false, // Reduced spacing for denser lists
// === List Display ===
defaultSortMode: "zone", // Default: "zone" | "az" | "za" | "qty-high" | "qty-low"
showRecentlyBought: true, // Toggle recently bought section
recentlyBoughtCount: 10, // Initial items shown (5-50)
recentlyBoughtCollapsed: false, // Start section collapsed
// === Behavior ===
confirmBeforeBuy: true, // Show confirmation modal
autoReloadInterval: 0, // Auto-refresh in minutes (0 = disabled)
hapticFeedback: true, // Vibration on mobile interactions
// === Advanced ===
debugMode: false // Developer tools (future)
}
```
---
## Dark Mode Implementation
### Theme System
**Three modes**:
1. **Light**: Force light theme
2. **Dark**: Force dark theme
3. **Auto**: Follow system preferences with live updates
### CSS Variable Architecture
All colors use CSS custom properties defined in [frontend/src/styles/theme.css](frontend/src/styles/theme.css):
**Light Mode** (`:root`):
```css
:root {
--color-text-primary: #212529;
--color-bg-body: #f8f9fa;
--color-bg-surface: #ffffff;
/* ... */
}
```
**Dark Mode** (`[data-theme="dark"]`):
```css
[data-theme="dark"] {
--color-text-primary: #f1f5f9;
--color-bg-body: #0f172a;
--color-bg-surface: #1e293b;
/* ... */
}
```
### Theme Application Logic
```javascript
// In SettingsContext.jsx
useEffect(() => {
const applyTheme = () => {
let theme = settings.theme;
// Auto mode: check system preference
if (theme === "auto") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
theme = prefersDark ? "dark" : "light";
}
document.documentElement.setAttribute("data-theme", theme);
};
applyTheme();
// Listen for system theme changes
if (settings.theme === "auto") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", applyTheme);
return () => mediaQuery.removeEventListener("change", applyTheme);
}
}, [settings.theme]);
```
---
## Integration with Existing Features
### GroceryList Integration
**Changed**:
```javascript
// Before
const [sortMode, setSortMode] = useState("zone");
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
// After
const { settings } = useContext(SettingsContext);
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
```
**Features**:
- Sort mode persists across sessions
- Recently bought section respects visibility setting
- Collapse state controlled by user preference
- Initial display count uses user's preference
---
## File Structure
```
frontend/src/
├── context/
│ └── SettingsContext.jsx ← Settings state management
├── pages/
│ └── Settings.jsx ← Settings UI
├── styles/
│ ├── theme.css ← Dark mode CSS variables
│ └── pages/
│ └── Settings.css ← Settings page styles
└── App.jsx ← Settings route & provider
```
---
## Usage Examples
### Access Settings in Component
```javascript
import { useContext } from "react";
import { SettingsContext } from "../context/SettingsContext";
function MyComponent() {
const { settings, updateSettings } = useContext(SettingsContext);
// Read setting
const isDark = settings.theme === "dark";
// Update setting
const toggleTheme = () => {
updateSettings({
theme: settings.theme === "dark" ? "light" : "dark"
});
};
return <button onClick={toggleTheme}>Toggle Theme</button>;
}
```
### Conditional Rendering Based on Settings
```javascript
{settings.showRecentlyBought && (
<RecentlyBoughtSection />
)}
```
### Using Theme Colors
```css
.my-component {
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border-light);
}
```
---
## localStorage Structure
**Key**: `user_preferences_${username}`
**Example stored value**:
```json
{
"theme": "dark",
"compactView": false,
"defaultSortMode": "zone",
"showRecentlyBought": true,
"recentlyBoughtCount": 20,
"recentlyBoughtCollapsed": false,
"confirmBeforeBuy": true,
"autoReloadInterval": 0,
"hapticFeedback": true,
"debugMode": false
}
```
---
## Testing Checklist
### Settings Page
- [ ] All three tabs accessible
- [ ] Theme toggle works (light/dark/auto)
- [ ] Auto mode follows system preference
- [ ] Settings persist after logout/login
- [ ] Reset button restores defaults
### Dark Mode
- [ ] All pages render correctly in dark mode
- [ ] Modals readable in dark mode
- [ ] Forms and inputs visible in dark mode
- [ ] Navigation and buttons styled correctly
- [ ] Images and borders contrast properly
### GroceryList Integration
- [ ] Default sort mode applied on load
- [ ] Recently bought visibility respected
- [ ] Collapse state persists during session
- [ ] Display count uses user preference
---
## Future Enhancements (Not Implemented)
### Phase 3: Advanced Preferences
- **Compact View**: Reduced padding/font sizes for power users
- **Confirm Before Buy**: Toggle for confirmation modal
- **Auto-reload**: Refresh list every X minutes for shared lists
### Phase 4: Account Management
- **Change Password**: Security feature (needs backend endpoint)
- **Display Name**: Friendly name separate from username
### Phase 5: Data Management
- **Export List**: Download as CSV/JSON
- **Clear History**: Remove recently bought items
- **Import Items**: Bulk add from file
### Phase 6: Accessibility
- **Font Size**: Adjustable text sizing
- **High Contrast Mode**: Increased contrast for visibility
- **Reduce Motion**: Disable animations
---
## API Endpoints
**None required** - all settings are client-side only.
Future backend endpoints may include:
- `PATCH /api/users/me` - Update user profile (password, display name)
- `GET /api/list/export` - Export grocery list data
---
## Browser Compatibility
### Theme Detection
- Chrome/Edge: ✅ Full support
- Firefox: ✅ Full support
- Safari: ✅ Full support (iOS 12.2+)
- Mobile browsers: ✅ Full support
### localStorage
- All modern browsers: ✅ Supported
- Fallback: Settings work but don't persist (rare)
---
## Troubleshooting
### Settings Don't Persist
**Issue**: Settings reset after logout
**Cause**: Settings tied to username
**Solution**: Working as designed - each user has separate preferences
### Dark Mode Not Applied
**Issue**: Page stays light after selecting dark
**Cause**: Missing `data-theme` attribute
**Solution**: Check SettingsContext is wrapped around App
### System Theme Not Detected
**Issue**: Auto mode doesn't work
**Cause**: Browser doesn't support `prefers-color-scheme`
**Solution**: Fallback to light mode (handled automatically)
---
## Development Notes
### Adding New Settings
1. **Update DEFAULT_SETTINGS** in SettingsContext.jsx:
```javascript
const DEFAULT_SETTINGS = {
// ...existing settings
myNewSetting: defaultValue,
};
```
2. **Add UI in Settings.jsx**:
```javascript
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.myNewSetting}
onChange={() => handleToggle("myNewSetting")}
/>
<span>My New Setting</span>
</label>
<p className="settings-description">Description here</p>
</div>
```
3. **Use in components**:
```javascript
const { settings } = useContext(SettingsContext);
if (settings.myNewSetting) {
// Do something
}
```
### Adding Theme Colors
1. Define in both light (`:root`) and dark (`[data-theme="dark"]`) modes
2. Use descriptive semantic names: `--color-purpose-variant`
3. Always provide fallbacks for older code
---
## Performance Considerations
- Settings load once per user session
- Theme changes apply instantly (no page reload)
- localStorage writes are debounced by React state updates
- No network requests for settings (all client-side)
---
## Accessibility
- ✅ Keyboard navigation works in Settings page
- ✅ Theme buttons have clear active states
- ✅ Range sliders show current values
- ✅ Color contrast meets WCAG AA in both themes
- ⚠️ Screen reader announcements for theme changes (future enhancement)
---
## Migration Notes
**Upgrading from older versions**:
- Old settings are preserved (merged with defaults)
- Missing settings use default values
- Invalid values are reset to defaults
- No migration script needed - handled automatically
---
## Related Documentation
- [Code Cleanup Guide](code-cleanup-guide.md) - Code organization patterns
- [Component Structure](component-structure.md) - Component architecture
- [Theme Usage Examples](../frontend/src/styles/THEME_USAGE_EXAMPLES.css) - CSS variable usage
---
**Implementation Status**: ✅ Complete
**Phase 1 (Foundation)**: ✅ Complete
**Phase 2 (Dark Mode)**: ✅ Complete
**Phase 3 (List Preferences)**: ✅ Complete
**Phase 4+ (Future)**: ⏳ Planned

View File

@ -1,14 +1,11 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles"; import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx"; import { AuthProvider } from "./context/AuthContext.jsx";
import { ConfigProvider } from "./context/ConfigContext.jsx";
import { SettingsProvider } from "./context/SettingsContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx"; import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx"; import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
import Settings from "./pages/Settings.jsx";
import AppLayout from "./components/layout/AppLayout.jsx"; import AppLayout from "./components/layout/AppLayout.jsx";
import PrivateRoute from "./utils/PrivateRoute.jsx"; import PrivateRoute from "./utils/PrivateRoute.jsx";
@ -18,9 +15,7 @@ import RoleGuard from "./utils/RoleGuard.jsx";
function App() { function App() {
return ( return (
<ConfigProvider>
<AuthProvider> <AuthProvider>
<SettingsProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@ -37,7 +32,6 @@ function App() {
} }
> >
<Route path="/" element={<GroceryList />} /> <Route path="/" element={<GroceryList />} />
<Route path="/settings" element={<Settings />} />
<Route <Route
path="/admin" path="/admin"
@ -51,9 +45,7 @@ function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</SettingsProvider>
</AuthProvider> </AuthProvider>
</ConfigProvider>
); );
} }

View File

@ -1,10 +0,0 @@
import api from "./axios";
/**
* Fetch application configuration
* @returns {Promise<Object>} Configuration object with maxFileSizeMB, maxImageDimension, etc.
*/
export const getConfig = async () => {
const response = await api.get("/config");
return response.data;
};

View File

@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
classification classification
}); });
}; };
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity }); export const markBought = (id) => api.post("/list/mark-bought", { id });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getRecentlyBought = () => api.get("/list/recently-bought"); export const getRecentlyBought = () => api.get("/list/recently-bought");

View File

@ -4,15 +4,3 @@ export const getAllUsers = () => api.get("/admin/users");
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role }); export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
export const deleteUser = (id) => api.delete(`/admin/users/${id}`); export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } }); export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });
export const getCurrentUser = async () => {
return api.get("/users/me");
};
export const updateCurrentUser = async (display_name) => {
return api.patch("/users/me", { display_name });
};
export const changePassword = async (current_password, new_password) => {
return api.post("/users/me/change-password", { current_password, new_password });
};

View File

@ -2,7 +2,7 @@ import { ROLES } from "../../constants/roles";
export default function UserRoleCard({ user, onRoleChange }) { export default function UserRoleCard({ user, onRoleChange }) {
return ( return (
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}> <div className="user-card">
<div className="user-info"> <div className="user-info">
<strong>{user.name}</strong> <strong>{user.name}</strong>
<span className="user-username">@{user.username}</span> <span className="user-username">@{user.username}</span>
@ -10,8 +10,7 @@ export default function UserRoleCard({ user, onRoleChange }) {
<select <select
onChange={(e) => onRoleChange(user.id, e.target.value)} onChange={(e) => onRoleChange(user.id, e.target.value)}
value={user.role} value={user.role}
className="form-select" className="role-select"
style={{ fontSize: 'var(--font-size-sm)' }}
> >
<option value={ROLES.VIEWER}>Viewer</option> <option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option> <option value={ROLES.EDITOR}>Editor</option>

View File

@ -1,5 +1,4 @@
import { useRef, useState, useContext } from "react"; import { useRef } from "react";
import { ConfigContext } from "../../context/ConfigContext";
import "../../styles/components/ImageUploadSection.css"; import "../../styles/components/ImageUploadSection.css";
/** /**
@ -18,25 +17,10 @@ export default function ImageUploadSection({
}) { }) {
const cameraInputRef = useRef(null); const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null); const galleryInputRef = useRef(null);
const [sizeError, setSizeError] = useState(null);
const { config } = useContext(ConfigContext);
const MAX_FILE_SIZE = config ? config.maxFileSizeMB * 1024 * 1024 : 20 * 1024 * 1024;
const MAX_FILE_SIZE_MB = config ? config.maxFileSizeMB : 20;
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
// Check file size
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
setSizeError(`Image size (${sizeMB}MB) exceeds the ${MAX_FILE_SIZE_MB}MB limit. Please choose a smaller image.`);
// Reset the input
e.target.value = '';
return;
}
// Clear any previous error
setSizeError(null);
onImageChange(file); onImageChange(file);
} }
}; };
@ -52,11 +36,6 @@ export default function ImageUploadSection({
return ( return (
<div className="image-upload-section"> <div className="image-upload-section">
<h3 className="image-upload-title">{title}</h3> <h3 className="image-upload-title">{title}</h3>
{sizeError && (
<div className="image-upload-error">
{sizeError}
</div>
)}
<div className="image-upload-content"> <div className="image-upload-content">
{!imagePreview ? ( {!imagePreview ? (
<div className="image-upload-options"> <div className="image-upload-options">

View File

@ -1,11 +1,12 @@
import { memo, useRef, useState } from "react"; import { useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal"; import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal"; import ConfirmBuyModal from "../modals/ConfirmBuyModal";
import ImageModal from "../modals/ImageModal";
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) { export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
const [showModal, setShowModal] = useState(false);
const [showAddImageModal, setShowAddImageModal] = useState(false); const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false); const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const [currentItem, setCurrentItem] = useState(item);
const longPressTimer = useRef(null); const longPressTimer = useRef(null);
const pressStartPos = useRef({ x: 0, y: 0 }); const pressStartPos = useRef({ x: 0, y: 0 });
@ -56,14 +57,13 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
const handleItemClick = () => { const handleItemClick = () => {
if (onClick) { if (onClick) {
setCurrentItem(item);
setShowConfirmBuyModal(true); setShowConfirmBuyModal(true);
} }
}; };
const handleConfirmBuy = (quantity) => { const handleConfirmBuy = (quantity) => {
if (onClick) { if (onClick) {
onClick(currentItem.id, quantity); onClick(quantity);
} }
setShowConfirmBuyModal(false); setShowConfirmBuyModal(false);
}; };
@ -72,16 +72,10 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
setShowConfirmBuyModal(false); setShowConfirmBuyModal(false);
}; };
const handleNavigate = (newItem) => {
setCurrentItem(newItem);
};
const handleImageClick = (e) => { const handleImageClick = (e) => {
e.stopPropagation(); // Prevent triggering the bought action e.stopPropagation(); // Prevent triggering the bought action
if (item.item_image) { if (item.item_image) {
// Open buy modal which now shows the image setShowModal(true);
setCurrentItem(item);
setShowConfirmBuyModal(true);
} else { } else {
setShowAddImageModal(true); setShowAddImageModal(true);
} }
@ -120,7 +114,7 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
return ( return (
<> <>
<li <li
className={`glist-li ${compact ? 'compact' : ''}`} className="glist-li"
onClick={handleItemClick} onClick={handleItemClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
@ -156,6 +150,14 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
</div> </div>
</li> </li>
{showModal && (
<ImageModal
imageUrl={imageUrl}
itemName={item.item_name}
onClose={() => setShowModal(false)}
/>
)}
{showAddImageModal && ( {showAddImageModal && (
<AddImageModal <AddImageModal
itemName={item.item_name} itemName={item.item_name}
@ -166,32 +168,11 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
{showConfirmBuyModal && ( {showConfirmBuyModal && (
<ConfirmBuyModal <ConfirmBuyModal
item={currentItem} item={item}
onConfirm={handleConfirmBuy} onConfirm={handleConfirmBuy}
onCancel={handleCancelBuy} onCancel={handleCancelBuy}
allItems={allItems}
onNavigate={handleNavigate}
/> />
)} )}
</> </>
); );
} }
// Memoize component to prevent re-renders when props haven't changed
export default memo(GroceryListItem, (prevProps, nextProps) => {
// Only re-render if the item data or handlers have changed
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.item_name === nextProps.item.item_name &&
prevProps.item.quantity === nextProps.item.quantity &&
prevProps.item.item_image === nextProps.item.item_image &&
prevProps.item.bought === nextProps.item.bought &&
prevProps.item.last_added_on === nextProps.item.last_added_on &&
prevProps.item.zone === nextProps.item.zone &&
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
prevProps.onClick === nextProps.onClick &&
prevProps.onImageAdded === nextProps.onImageAdded &&
prevProps.onLongPress === nextProps.onLongPress &&
prevProps.allItems?.length === nextProps.allItems?.length
);
});

View File

@ -1,4 +1,3 @@
import "../../styles/components/SuggestionList.css";
interface Props { interface Props {
suggestions: string[]; suggestions: string[];
@ -9,12 +8,27 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null; if (!suggestions.length) return null;
return ( return (
<ul className="suggestion-list"> <ul
className="suggestion-list"
style={{
background: "#fff",
border: "1px solid #ccc",
maxHeight: "150px",
overflowY: "auto",
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{suggestions.map((s) => ( {suggestions.map((s) => (
<li <li
key={s} key={s}
onClick={() => onSelect(s)} onClick={() => onSelect(s)}
className="suggestion-item" style={{
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
> >
{s} {s}
</li> </li>

View File

@ -11,7 +11,6 @@ export default function Navbar() {
<nav className="navbar"> <nav className="navbar">
<div className="navbar-links"> <div className="navbar-links">
<Link to="/">Home</Link> <Link to="/">Home</Link>
<Link to="/settings">Settings</Link>
{role === "admin" && <Link to="/admin">Admin</Link>} {role === "admin" && <Link to="/admin">Admin</Link>}
</div> </div>

View File

@ -39,11 +39,11 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="add-image-modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="add-image-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Add Image</h2> <h2>Add Image</h2>
<p className="text-center mb-4" style={{ color: 'var(--color-text-secondary)', fontSize: '0.95em' }}> <p className="add-image-subtitle">
There's no image for <strong className="text-primary">"{itemName}"</strong> yet. Add a new image? There's no image for <strong>"{itemName}"</strong> yet. Add a new image?
</p> </p>
{!imagePreview ? ( {!imagePreview ? (
@ -83,12 +83,12 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
style={{ display: "none" }} style={{ display: "none" }}
/> />
<div className="modal-actions"> <div className="add-image-actions">
<button onClick={onClose} className="btn btn-outline flex-1"> <button onClick={onClose} className="add-image-cancel">
Cancel Cancel
</button> </button>
{imagePreview && ( {imagePreview && (
<button onClick={handleConfirm} className="btn btn-success flex-1"> <button onClick={handleConfirm} className="add-image-confirm">
Add Image Add Image
</button> </button>
)} )}

View File

@ -1,47 +0,0 @@
import "../../styles/components/ConfirmAddExistingModal.css";
export default function ConfirmAddExistingModal({
itemName,
currentQuantity,
addingQuantity,
onConfirm,
onCancel
}) {
const newQuantity = currentQuantity + addingQuantity;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2 className="text-center text-xl mb-4">
<strong className="text-primary font-semibold">{itemName}</strong> is already in your list
</h2>
<div className="mb-4">
<div className="confirm-add-existing-qty-info">
<div className="qty-row">
<span className="qty-label">Current quantity:</span>
<span className="qty-value">{currentQuantity}</span>
</div>
<div className="qty-row">
<span className="qty-label">Adding:</span>
<span className="qty-value">+{addingQuantity}</span>
</div>
<div className="qty-row qty-total">
<span className="qty-label">New total:</span>
<span className="qty-value">{newQuantity}</span>
</div>
</div>
</div>
<div className="modal-actions">
<button className="btn btn-outline flex-1" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-primary flex-1" onClick={onConfirm}>
Update Quantity
</button>
</div>
</div>
</div>
);
}

View File

@ -1,26 +1,10 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import "../../styles/ConfirmBuyModal.css"; import "../../styles/ConfirmBuyModal.css";
export default function ConfirmBuyModal({ export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
item,
onConfirm,
onCancel,
allItems = [],
onNavigate
}) {
const [quantity, setQuantity] = useState(item.quantity); const [quantity, setQuantity] = useState(item.quantity);
const maxQuantity = item.quantity; const maxQuantity = item.quantity;
// Update quantity when item changes (navigation)
useEffect(() => {
setQuantity(item.quantity);
}, [item.id, item.quantity]);
// Find current index and check for prev/next
const currentIndex = allItems.findIndex(i => i.id === item.id);
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allItems.length - 1;
const handleIncrement = () => { const handleIncrement = () => {
if (quantity < maxQuantity) { if (quantity < maxQuantity) {
setQuantity(prev => prev + 1); setQuantity(prev => prev + 1);
@ -37,61 +21,14 @@ export default function ConfirmBuyModal({
onConfirm(quantity); onConfirm(quantity);
}; };
const handlePrev = () => {
if (hasPrev && onNavigate) {
const prevItem = allItems[currentIndex - 1];
onNavigate(prevItem);
}
};
const handleNext = () => {
if (hasNext && onNavigate) {
const nextItem = allItems[currentIndex + 1];
onNavigate(nextItem);
}
};
const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}`
: null;
return ( return (
<div className="confirm-buy-modal-overlay" onClick={onCancel}> <div className="confirm-buy-modal-overlay" onClick={onCancel}>
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}> <div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
<div className="confirm-buy-header"> <h2>Mark as Bought</h2>
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>} <p className="confirm-buy-item-name">"{item.item_name}"</p>
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
</div>
<div className="confirm-buy-image-section">
<button
className="confirm-buy-nav-btn confirm-buy-nav-prev"
onClick={handlePrev}
style={{ visibility: hasPrev ? 'visible' : 'hidden' }}
disabled={!hasPrev}
>
</button>
<div className="confirm-buy-image-container">
{imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
) : (
<div className="confirm-buy-image-placeholder">📦</div>
)}
</div>
<button
className="confirm-buy-nav-btn confirm-buy-nav-next"
onClick={handleNext}
style={{ visibility: hasNext ? 'visible' : 'hidden' }}
disabled={!hasNext}
>
</button>
</div>
<div className="confirm-buy-quantity-section"> <div className="confirm-buy-quantity-section">
<p className="confirm-buy-label">Quantity to buy:</p>
<div className="confirm-buy-counter"> <div className="confirm-buy-counter">
<button <button
onClick={handleDecrement} onClick={handleDecrement}

View File

@ -1,16 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
import "../../styles/components/EditItemModal.css"; import "../../styles/components/EditItemModal.css";
import AddImageModal from "./AddImageModal"; import ClassificationSection from "../forms/ClassificationSection";
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { export default function EditItemModal({ item, onSave, onCancel }) {
const [itemName, setItemName] = useState(item.item_name || ""); const [itemName, setItemName] = useState(item.item_name || "");
const [quantity, setQuantity] = useState(item.quantity || 1); const [quantity, setQuantity] = useState(item.quantity || 1);
const [itemType, setItemType] = useState(""); const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState(""); const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState(""); const [zone, setZone] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showImageModal, setShowImageModal] = useState(false);
// Load existing classification // Load existing classification
useEffect(() => { useEffect(() => {
@ -60,131 +58,44 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
} }
}; };
const handleImageUpload = async (imageFile) => {
if (onImageUpdate) {
try {
await onImageUpdate(item.id, itemName, quantity, imageFile);
setShowImageModal(false);
} catch (error) {
console.error("Failed to upload image:", error);
alert("Failed to upload image");
}
}
};
const incrementQuantity = () => {
setQuantity(prev => prev + 1);
};
const decrementQuantity = () => {
setQuantity(prev => Math.max(1, prev - 1));
};
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
return ( return (
<div className="edit-modal-overlay" onClick={onCancel}> <div className="edit-modal-overlay" onClick={onCancel}>
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}> <div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
<h2 className="edit-modal-title">Edit Item</h2> <h2 className="edit-modal-title">Edit Item</h2>
{/* Item Name - no label */} <div className="edit-modal-field">
<label>Item Name</label>
<input <input
type="text" type="text"
value={itemName} value={itemName}
onChange={(e) => setItemName(e.target.value)} onChange={(e) => setItemName(e.target.value)}
className="edit-modal-input" className="edit-modal-input"
placeholder="Item name"
/> />
</div>
{/* Quantity Control - like AddItemForm */} <div className="edit-modal-field">
<div className="edit-modal-quantity-control"> <label>Quantity</label>
<button
type="button"
className="quantity-btn quantity-btn-minus"
onClick={decrementQuantity}
disabled={quantity <= 1}
>
</button>
<input <input
type="number" type="number"
min="1" min="1"
className="edit-modal-quantity-input"
value={quantity} value={quantity}
readOnly onChange={(e) => setQuantity(parseInt(e.target.value))}
className="edit-modal-input"
/> />
<button
type="button"
className="quantity-btn quantity-btn-plus"
onClick={incrementQuantity}
>
+
</button>
</div> </div>
<div className="edit-modal-divider" /> <div className="edit-modal-divider" />
{/* Inline Classification Fields */} <ClassificationSection
<div className="edit-modal-inline-field"> itemType={itemType}
<label>Type</label> itemGroup={itemGroup}
<select zone={zone}
value={itemType} onItemTypeChange={handleItemTypeChange}
onChange={(e) => handleItemTypeChange(e.target.value)} onItemGroupChange={setItemGroup}
className="edit-modal-select" onZoneChange={setZone}
> fieldClass="edit-modal-field"
<option value="">-- Select Type --</option> selectClass="edit-modal-select"
{Object.values(ITEM_TYPES).map((type) => ( />
<option key={type} value={type}>
{getItemTypeLabel(type)}
</option>
))}
</select>
</div>
{itemType && (
<div className="edit-modal-inline-field">
<label>Group</label>
<select
value={itemGroup}
onChange={(e) => setItemGroup(e.target.value)}
className="edit-modal-select"
>
<option value="">-- Select Group --</option>
{availableGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</div>
)}
<div className="edit-modal-inline-field">
<label>Zone</label>
<select
value={zone}
onChange={(e) => setZone(e.target.value)}
className="edit-modal-select"
>
<option value="">-- Select Zone --</option>
{getZoneValues().map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
</div>
<div className="edit-modal-divider" />
<button
className="edit-modal-btn edit-modal-btn-image"
onClick={() => setShowImageModal(true)}
disabled={loading}
type="button"
>
{item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
</button>
<div className="edit-modal-actions"> <div className="edit-modal-actions">
<button <button
@ -203,14 +114,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
</button> </button>
</div> </div>
</div> </div>
{showImageModal && (
<AddImageModal
itemName={itemName}
onClose={() => setShowImageModal(false)}
onAddImage={handleImageUpload}
/>
)}
</div> </div>
); );
} }

View File

@ -17,7 +17,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
<div className="image-modal-overlay" onClick={onClose}> <div className="image-modal-overlay" onClick={onClose}>
<div className="image-modal-content" onClick={onClose}> <div className="image-modal-content" onClick={onClose}>
<img src={imageUrl} alt={itemName} className="image-modal-img" /> <img src={imageUrl} alt={itemName} className="image-modal-img" />
<p className="text-center mt-3 text-lg font-semibold">{itemName}</p> <p className="image-modal-caption">{itemName}</p>
</div> </div>
</div> </div>
); );

View File

@ -2,22 +2,25 @@ import "../../styles/SimilarItemModal.css";
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) { export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
return ( return (
<div className="modal-overlay" onClick={onCancel}> <div className="similar-item-modal-overlay" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="similar-item-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Similar Item Found</h2> <h2>Similar Item Found</h2>
<p className="text-center text-lg mb-4"> <p className="similar-item-question">
Instead of <strong className="similar-item-original">"{originalName.toLowerCase()}"</strong>, use <strong className="similar-item-suggested">"{suggestedName}"</strong>? Do you mean <strong>"{suggestedName}"</strong>?
</p>
<p className="similar-item-clarification">
You entered: "{originalName}"
</p> </p>
<div className="similar-modal-actions"> <div className="similar-item-actions">
<button onClick={onYes} className="btn btn-success"> <button onClick={onCancel} className="similar-item-cancel">
Yes, Use Suggestion Cancel
</button> </button>
<button onClick={onNo} className="btn btn-primary"> <button onClick={onNo} className="similar-item-no">
No, Create New No, Create New
</button> </button>
<button onClick={onCancel} className="btn btn-danger"> <button onClick={onYes} className="similar-item-yes">
Cancel Yes, Use Suggestion
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,44 +0,0 @@
import { createContext, useState, useEffect } from 'react';
import { getConfig } from '../api/config';
export const ConfigContext = createContext({
config: null,
loading: true,
});
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchConfig = async () => {
try {
const data = await getConfig();
setConfig(data);
} catch (error) {
console.error('Failed to fetch config:', error);
// Set default fallback values
setConfig({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85
});
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
const value = {
config,
loading
};
return (
<ConfigContext.Provider value={value}>
{children}
</ConfigContext.Provider>
);
};

View File

@ -1,122 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react";
import { AuthContext } from "./AuthContext";
const DEFAULT_SETTINGS = {
// Appearance
theme: "auto", // "light" | "dark" | "auto"
compactView: false,
// List Display
defaultSortMode: "zone",
showRecentlyBought: true,
recentlyBoughtCount: 10,
recentlyBoughtCollapsed: false,
// Behavior
confirmBeforeBuy: true,
autoReloadInterval: 0, // 0 = disabled, else minutes
hapticFeedback: true,
// Advanced
debugMode: false,
};
export const SettingsContext = createContext({
settings: DEFAULT_SETTINGS,
updateSettings: () => { },
resetSettings: () => { },
});
export const SettingsProvider = ({ children }) => {
const { username } = useContext(AuthContext);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
// Load settings from localStorage when user changes
useEffect(() => {
if (!username) {
setSettings(DEFAULT_SETTINGS);
return;
}
const storageKey = `user_preferences_${username}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
} catch (error) {
console.error("Failed to parse settings:", error);
setSettings(DEFAULT_SETTINGS);
}
} else {
setSettings(DEFAULT_SETTINGS);
}
}, [username]);
// Apply theme to document
useEffect(() => {
const applyTheme = () => {
let theme = settings.theme;
if (theme === "auto") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
theme = prefersDark ? "dark" : "light";
}
document.documentElement.setAttribute("data-theme", theme);
};
applyTheme();
// Listen for system theme changes if in auto mode
if (settings.theme === "auto") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => applyTheme();
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}
}, [settings.theme]);
// Save settings to localStorage
const updateSettings = (newSettings) => {
if (!username) return;
const updated = { ...settings, ...newSettings };
setSettings(updated);
const storageKey = `user_preferences_${username}`;
localStorage.setItem(storageKey, JSON.stringify(updated));
};
// Reset to defaults
const resetSettings = () => {
if (!username) return;
setSettings(DEFAULT_SETTINGS);
const storageKey = `user_preferences_${username}`;
localStorage.setItem(storageKey, JSON.stringify(DEFAULT_SETTINGS));
};
const value = {
settings,
updateSettings,
resetSettings,
};
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};

View File

@ -84,14 +84,9 @@ body {
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-body); background: var(--color-bg-body);
margin: 0; margin: 0;
padding: 0; padding: var(--spacing-md);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease, color 0.3s ease;
}
#root {
min-height: 100vh;
} }
.container { .container {
@ -99,6 +94,11 @@ body {
margin: auto; margin: auto;
padding: var(--container-padding); padding: var(--container-padding);
} }
background: white;
padding: 1em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 { h1 {
text-align: center; text-align: center;

View File

@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
import './index.css' import './index.css'
import './styles/theme.css' import './styles/theme.css'
import './styles/utilities.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View 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!"

View 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!"

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users"; import { getAllUsers, updateRole } from "../api/users";
import UserRoleCard from "../components/common/UserRoleCard"; import UserRoleCard from "../components/common/UserRoleCard";
import "../styles/UserRoleCard.css"; import "../styles/UserRoleCard.css";
import "../styles/pages/AdminPanel.css";
export default function AdminPanel() { export default function AdminPanel() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@ -23,10 +22,9 @@ export default function AdminPanel() {
} }
return ( return (
<div className="p-4" style={{ minHeight: '100vh' }}> <div style={{ padding: "2rem" }}>
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}> <h1>Admin Panel</h1>
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1> <div style={{ marginTop: "2rem" }}>
<div className="mt-4">
{users.map((user) => ( {users.map((user) => (
<UserRoleCard <UserRoleCard
key={user.id} key={user.id}
@ -36,6 +34,5 @@ export default function AdminPanel() {
))} ))}
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -1,40 +1,25 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
addItem,
getClassification,
getItemByName,
getList,
getRecentlyBought,
getSuggestions,
markBought,
updateItemImage,
updateItemWithClassification
} from "../api/list";
import FloatingActionButton from "../components/common/FloatingActionButton"; import FloatingActionButton from "../components/common/FloatingActionButton";
import SortDropdown from "../components/common/SortDropdown"; import SortDropdown from "../components/common/SortDropdown";
import AddItemForm from "../components/forms/AddItemForm"; import AddItemForm from "../components/forms/AddItemForm";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
import EditItemModal from "../components/modals/EditItemModal"; import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal";
import { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles"; import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import { SettingsContext } from "../context/SettingsContext";
import "../styles/pages/GroceryList.css"; import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity"; import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() { export default function GroceryList() {
const { role } = useContext(AuthContext); const { role } = useContext(AuthContext);
const { settings } = useContext(SettingsContext);
// === State === //
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
const [sortMode, setSortMode] = useState(settings.defaultSortMode); const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("zone");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(true); const [showAddForm, setShowAddForm] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -45,13 +30,7 @@ export default function GroceryList() {
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
const [collapsedZones, setCollapsedZones] = useState({});
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
// === Data Loading ===
const loadItems = async () => { const loadItems = async () => {
setLoading(true); setLoading(true);
const res = await getList(); const res = await getList();
@ -60,7 +39,6 @@ export default function GroceryList() {
setLoading(false); setLoading(false);
}; };
const loadRecentlyBought = async () => { const loadRecentlyBought = async () => {
try { try {
const res = await getRecentlyBought(); const res = await getRecentlyBought();
@ -71,24 +49,13 @@ export default function GroceryList() {
} }
}; };
useEffect(() => { useEffect(() => {
loadItems(); loadItems();
loadRecentlyBought(); loadRecentlyBought();
}, []); }, []);
useEffect(() => {
// === Zone Collapse Handler === let sorted = [...items];
const toggleZoneCollapse = (zone) => {
setCollapsedZones(prev => ({
...prev,
[zone]: !prev[zone]
}));
};
// === Sorted Items Computation ===
const sortedItems = useMemo(() => {
const 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 === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
@ -97,22 +64,11 @@ export default function GroceryList() {
if (sortMode === "zone") { if (sortMode === "zone") {
sorted.sort((a, b) => { sorted.sort((a, b) => {
// Items without classification go to the end // Items without classification go to the end
if (!a.zone && b.zone) return 1; if (!a.item_type && b.item_type) return 1;
if (a.zone && !b.zone) return -1; if (a.item_type && !b.item_type) return -1;
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name); if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
// Sort by ZONE_FLOW order // Sort by item_type
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
// If zone not in ZONE_FLOW, put at end
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
const zoneCompare = aIndex - bIndex;
if (zoneCompare !== 0) return zoneCompare;
// Then by item_type
const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare; if (typeCompare !== 0) return typeCompare;
@ -120,16 +76,18 @@ export default function GroceryList() {
const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare; if (groupCompare !== 0) return groupCompare;
// Then by zone
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
if (zoneCompare !== 0) return zoneCompare;
// Finally by name // Finally by name
return a.item_name.localeCompare(b.item_name); return a.item_name.localeCompare(b.item_name);
}); });
} }
return sorted; setSortedItems(sorted);
}, [items, sortMode]); }, [items, sortMode]);
// === Suggestion Handler ===
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
if (!text.trim()) { if (!text.trim()) {
setSuggestions([]); setSuggestions([]);
@ -137,27 +95,34 @@ export default function GroceryList() {
return; 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 lowerText = text.toLowerCase().trim();
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
if (exactMatch) {
setButtonText("Add");
} else {
setButtonText("Create + Add");
}
try { try {
const response = await getSuggestions(text); let suggestions = await getSuggestions(text);
const suggestionList = response.data.map(s => s.item_name); suggestions = suggestions.data.map(s => s.item_name);
setSuggestions(suggestionList); setSuggestions(suggestions);
// All suggestions are now lowercase from DB, direct comparison
const exactMatch = suggestionList.includes(lowerText);
setButtonText(exactMatch ? "Add" : "Create + Add");
} catch { } catch {
setSuggestions([]); setSuggestions([]);
setButtonText("Create + Add");
} }
}; };
const handleAdd = async (itemName, quantity) => {
// === Item Addition Handlers ===
const handleAdd = useCallback(async (itemName, quantity) => {
if (!itemName.trim()) return; if (!itemName.trim()) return;
const lowerItemName = itemName.toLowerCase().trim();
// First check if exact item exists in database (case-insensitive)
let existingItem = null; let existingItem = null;
try { try {
const response = await getItemByName(itemName); const response = await getItemByName(itemName);
@ -166,27 +131,29 @@ export default function GroceryList() {
existingItem = null; existingItem = null;
} }
// If exact item exists, skip similarity check and process directly
if (existingItem) { if (existingItem) {
await processItemAddition(itemName, quantity); await processItemAddition(itemName, quantity);
return; return;
} }
setItems(prevItems => { // Only check for similar items if exact item doesn't exist
const allItems = [...prevItems, ...recentlyBoughtItems]; const allItems = [...items, ...recentlyBoughtItems];
const similar = findSimilarItems(itemName, allItems, 70); const similar = findSimilarItems(itemName, allItems, 80);
if (similar.length > 0) { if (similar.length > 0) {
// Show modal and wait for user decision
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
setShowSimilarModal(true); setShowSimilarModal(true);
return prevItems; return;
} }
processItemAddition(itemName, quantity); // Continue with normal flow for new items
return prevItems; await processItemAddition(itemName, quantity);
}); };
}, [recentlyBoughtItems]);
const processItemAddition = async (itemName, quantity) => {
const processItemAddition = useCallback(async (itemName, quantity) => { // Check if item exists in database (case-insensitive)
let existingItem = null; let existingItem = null;
try { try {
const response = await getItemByName(itemName); const response = await getItemByName(itemName);
@ -195,205 +162,126 @@ export default function GroceryList() {
existingItem = null; existingItem = null;
} }
if (existingItem?.bought === false) { if (existingItem && existingItem.bought === false) {
// Item exists and is unbought - update quantity
const currentQuantity = existingItem.quantity; const currentQuantity = existingItem.quantity;
const newQuantity = currentQuantity + 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 ${newQuantity}?`
);
if (!yes) return;
// Show modal instead of window.confirm await addItem(itemName, newQuantity, null);
setConfirmAddExistingData({ setSuggestions([]);
itemName, setButtonText("Add Item");
currentQuantity, loadItems();
addingQuantity: quantity,
newQuantity,
existingItem
});
setShowConfirmAddExisting(true);
} else if (existingItem) { } else if (existingItem) {
// Item exists in database (was previously bought) - just add quantity
await addItem(itemName, quantity, null); await addItem(itemName, quantity, null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
// Reload lists to reflect the changes
await loadItems();
await loadRecentlyBought();
} else { } else {
// NEW ITEM - show combined add details modal
setPendingItem({ itemName, quantity }); setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true); setShowAddDetailsModal(true);
} }
}, []); };
const handleSimilarCancel = () => {
// === Similar Item Modal Handlers ===
const handleSimilarCancel = useCallback(() => {
setShowSimilarModal(false); setShowSimilarModal(false);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}, []); };
const handleSimilarNo = async () => {
const handleSimilarNo = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
// Create new item with original name
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]); };
const handleSimilarYes = async () => {
const handleSimilarYes = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
// Use suggested item name
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]); };
const handleAddDetailsConfirm = async (imageFile, classification) => {
// === Confirm Add Existing Modal Handlers ===
const handleConfirmAddExisting = useCallback(async () => {
if (!confirmAddExistingData) return;
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
try {
// Update the item
await addItem(itemName, newQuantity, null);
// Fetch the updated item with properly formatted data
const response = await getItemByName(itemName);
const updatedItem = response.data;
// Update state with the full item data
setItems(prevItems =>
prevItems.map(item =>
item.id === existingItem.id ? updatedItem : item
)
);
setSuggestions([]);
setButtonText("Add Item");
} catch (error) {
console.error("Failed to update item:", error);
// Fallback to full reload on error
await loadItems();
}
}, [confirmAddExistingData, loadItems]);
const handleCancelAddExisting = useCallback(() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}, []);
// === Add Details Modal Handlers ===
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
if (!pendingItem) return; if (!pendingItem) return;
try { try {
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); // Add item to grocery_list with image
let newItem = addResponse.data; await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
// If classification provided, add it
if (classification) { if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName); const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id; const itemId = itemResponse.data.id;
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification); await updateItemWithClassification(itemId, undefined, undefined, classification);
newItem = { ...newItem, ...updateResponse.data };
} }
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); alert("Failed to add item. Please try again.");
} }
}, [pendingItem]); };
const handleAddDetailsSkip = async () => {
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return; if (!pendingItem) return;
try { try {
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null); // Add item without image or classification
await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
}
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); alert("Failed to add item. Please try again.");
} }
}, [pendingItem]); };
const handleAddDetailsCancel = () => {
const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
}, []); };
// === Item Action Handlers ===
const handleBought = useCallback(async (id, quantity) => {
const item = items.find(i => i.id === id);
if (!item) return;
await markBought(id, quantity);
// If buying full quantity, remove from list
if (quantity >= item.quantity) {
setItems(prevItems => prevItems.filter(item => item.id !== id));
} else {
// If partial, update quantity
const response = await getItemByName(item.item_name);
if (response.data) {
setItems(prevItems =>
prevItems.map(item => item.id === id ? response.data : item)
);
}
}
const handleBought = async (id, quantity) => {
await markBought(id);
loadItems();
loadRecentlyBought(); loadRecentlyBought();
}, [items]); };
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
try { try {
const response = await updateItemImage(id, itemName, quantity, imageFile); await updateItemImage(id, itemName, quantity, imageFile);
loadItems(); // Reload to show new image
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
setRecentlyBoughtItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
} catch (error) { } catch (error) {
console.error("Failed to add image:", error); console.error("Failed to add image:", error);
alert("Failed to add image. Please try again."); alert("Failed to add image. Please try again.");
} }
}, []); };
const handleLongPress = async (item) => {
const handleLongPress = useCallback(async (item) => {
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return; if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
try { try {
// Fetch existing classification
const classificationResponse = await getClassification(item.id); const classificationResponse = await getClassification(item.id);
setEditingItem({ setEditingItem({
...item, ...item,
@ -405,42 +293,27 @@ export default function GroceryList() {
setEditingItem({ ...item, classification: null }); setEditingItem({ ...item, classification: null });
setShowEditModal(true); setShowEditModal(true);
} }
}, [role]); };
const handleEditSave = async (id, itemName, quantity, classification) => {
// === Edit Modal Handlers ===
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
try { try {
const response = await updateItemWithClassification(id, itemName, quantity, classification); await updateItemWithClassification(id, itemName, quantity, classification);
setShowEditModal(false); setShowEditModal(false);
setEditingItem(null); setEditingItem(null);
loadItems();
const updatedItem = response.data; loadRecentlyBought();
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
)
);
setRecentlyBoughtItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
)
);
} catch (error) { } catch (error) {
console.error("Failed to update item:", error); console.error("Failed to update item:", error);
throw error; throw error; // Re-throw to let modal handle it
} }
}, []); };
const handleEditCancel = () => {
const handleEditCancel = useCallback(() => {
setShowEditModal(false); setShowEditModal(false);
setEditingItem(null); setEditingItem(null);
}, []); };
// Group items by zone for classification view
// === Helper Functions ===
const groupItemsByZone = (items) => { const groupItemsByZone = (items) => {
const groups = {}; const groups = {};
items.forEach(item => { items.forEach(item => {
@ -453,10 +326,8 @@ export default function GroceryList() {
return groups; return groups;
}; };
if (loading) return <p>Loading...</p>; if (loading) return <p>Loading...</p>;
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
@ -475,35 +346,21 @@ export default function GroceryList() {
<SortDropdown value={sortMode} onChange={setSortMode} /> <SortDropdown value={sortMode} onChange={setSortMode} />
{sortMode === "zone" ? ( {sortMode === "zone" ? (
// Grouped view by zone
(() => { (() => {
const grouped = groupItemsByZone(sortedItems); const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => { return Object.keys(grouped).map(zone => (
const isCollapsed = collapsedZones[zone];
const itemCount = grouped[zone].length;
return (
<div key={zone} className="glist-classification-group"> <div key={zone} className="glist-classification-group">
<h3 <h3 className="glist-classification-header">
className="glist-classification-header clickable"
onClick={() => toggleZoneCollapse(zone)}
>
<span>
{zone === 'unclassified' ? 'Unclassified' : zone} {zone === 'unclassified' ? 'Unclassified' : zone}
<span className="glist-zone-count"> ({itemCount})</span>
</span>
<span className="glist-zone-indicator">
{isCollapsed ? "▼" : "▲"}
</span>
</h3> </h3>
{!isCollapsed && ( <ul className="glist-ul">
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
{grouped[zone].map((item) => ( {grouped[zone].map((item) => (
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={sortedItems} onClick={(quantity) =>
compact={settings.compactView} [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
onClick={(id, quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
} }
onImageAdded={ onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
@ -514,21 +371,18 @@ export default function GroceryList() {
/> />
))} ))}
</ul> </ul>
)}
</div> </div>
); ));
});
})() })()
) : ( ) : (
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}> // Regular flat list view
<ul className="glist-ul">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={sortedItems} onClick={(quantity) =>
compact={settings.compactView} [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
onClick={(id, quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
} }
onImageAdded={ onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
@ -541,27 +395,14 @@ export default function GroceryList() {
</ul> </ul>
)} )}
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( {recentlyBoughtItems.length > 0 && (
<> <>
<h2 <h2 className="glist-section-title">Recently Bought (24HR)</h2>
className="glist-section-title clickable" <ul className="glist-ul">
onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
>
<span>Recently Bought (24HR)</span>
<span className="glist-section-indicator">
{recentlyBoughtCollapsed ? "▼" : "▲"}
</span>
</h2>
{!recentlyBoughtCollapsed && (
<>
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => ( {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={recentlyBoughtItems}
compact={settings.compactView}
onClick={null} onClick={null}
onImageAdded={ onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
@ -584,8 +425,6 @@ export default function GroceryList() {
)} )}
</> </>
)} )}
</>
)}
</div> </div>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
@ -619,17 +458,6 @@ export default function GroceryList() {
item={editingItem} item={editingItem}
onSave={handleEditSave} onSave={handleEditSave}
onCancel={handleEditCancel} onCancel={handleEditCancel}
onImageUpdate={handleImageAdded}
/>
)}
{showConfirmAddExisting && confirmAddExistingData && (
<ConfirmAddExistingModal
itemName={confirmAddExistingData.itemName}
currentQuantity={confirmAddExistingData.currentQuantity}
addingQuantity={confirmAddExistingData.addingQuantity}
onConfirm={handleConfirmAddExisting}
onCancel={handleCancelAddExisting}
/> />
)} )}
</div> </div>

View File

@ -27,16 +27,16 @@ export default function Login() {
}; };
return ( return (
<div className="flex-center" style={{ minHeight: '100vh', padding: '1em', background: '#f8f9fa' }}> <div className="login-wrapper">
<div className="card card-elevated" style={{ width: '100%', maxWidth: '360px' }}> <div className="login-box">
<h1 className="text-center text-2xl mb-3">Login</h1> <h1 className="login-title">Login</h1>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<form onSubmit={submit}> <form onSubmit={submit}>
<FormInput <FormInput
type="text" type="text"
className="form-input my-2" className="login-input"
placeholder="Username" placeholder="Username"
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
@ -44,7 +44,7 @@ export default function Login() {
<div className="login-password-wrapper"> <div className="login-password-wrapper">
<FormInput <FormInput
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="form-input" className="login-input"
placeholder="Password" placeholder="Password"
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
@ -58,11 +58,11 @@ export default function Login() {
</button> </button>
</div> </div>
<button type="submit" className="btn btn-primary btn-block mt-2">Login</button> <button type="submit" className="login-button">Login</button>
</form> </form>
<p className="text-center mt-3"> <p className="login-register">
Need an account? <Link to="/register" className="text-primary">Register here</Link> Need an account? <Link to="/register">Register here</Link>
</p> </p>
</div> </div>
</div> </div>

View File

@ -59,7 +59,7 @@ export default function Register() {
return ( return (
<div className="register-container"> <div className="register-container">
<h1 className="text-center mb-4 text-2xl font-bold">Register</h1> <h1>Register</h1>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<ErrorMessage message={success} type="success" /> <ErrorMessage message={success} type="success" />
@ -67,7 +67,6 @@ export default function Register() {
<form className="register-form" onSubmit={submit}> <form className="register-form" onSubmit={submit}>
<FormInput <FormInput
type="text" type="text"
className="form-input"
placeholder="Name" placeholder="Name"
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
@ -75,7 +74,6 @@ export default function Register() {
<FormInput <FormInput
type="text" type="text"
className="form-input"
placeholder="Username" placeholder="Username"
onKeyUp={(e) => setUsername(e.target.value)} onKeyUp={(e) => setUsername(e.target.value)}
required required
@ -83,7 +81,6 @@ export default function Register() {
<FormInput <FormInput
type="password" type="password"
className="form-input"
placeholder="Password" placeholder="Password"
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@ -91,19 +88,18 @@ export default function Register() {
<FormInput <FormInput
type="password" type="password"
className="form-input"
placeholder="Confirm Password" placeholder="Confirm Password"
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
required required
/> />
<button disabled={error !== ""} type="submit" className="btn btn-primary btn-block mt-2"> <button disabled={error !== ""} type="submit">
Create Account Create Account
</button> </button>
</form> </form>
<p className="text-center mt-3"> <p className="register-link">
Already have an account? <Link to="/login" className="text-primary font-semibold">Login here</Link> Already have an account? <Link to="/login">Login here</Link>
</p> </p>
</div> </div>
); );

View File

@ -1,426 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
import { SettingsContext } from "../context/SettingsContext";
import "../styles/pages/Settings.css";
export default function Settings() {
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
const [activeTab, setActiveTab] = useState("appearance");
// Account management state
const [displayName, setDisplayName] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [accountMessage, setAccountMessage] = useState({ type: "", text: "" });
const [loadingProfile, setLoadingProfile] = useState(false);
const [loadingPassword, setLoadingPassword] = useState(false);
// Load user profile
useEffect(() => {
const loadProfile = async () => {
try {
const response = await getCurrentUser();
setDisplayName(response.data.display_name || response.data.name || "");
} catch (error) {
console.error("Failed to load profile:", error);
}
};
loadProfile();
}, []);
const handleThemeChange = (theme) => {
updateSettings({ theme });
};
const handleUpdateDisplayName = async (e) => {
e.preventDefault();
setLoadingProfile(true);
setAccountMessage({ type: "", text: "" });
try {
await updateCurrentUser(displayName);
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
} catch (error) {
setAccountMessage({
type: "error",
text: error.response?.data?.error || "Failed to update display name"
});
} finally {
setLoadingProfile(false);
}
};
const handleChangePassword = async (e) => {
e.preventDefault();
setLoadingPassword(true);
setAccountMessage({ type: "", text: "" });
if (newPassword !== confirmPassword) {
setAccountMessage({ type: "error", text: "New passwords don't match" });
setLoadingPassword(false);
return;
}
if (newPassword.length < 6) {
setAccountMessage({ type: "error", text: "Password must be at least 6 characters" });
setLoadingPassword(false);
return;
}
try {
await changePassword(currentPassword, newPassword);
setAccountMessage({ type: "success", text: "Password changed successfully!" });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (error) {
setAccountMessage({
type: "error",
text: error.response?.data?.error || "Failed to change password"
});
} finally {
setLoadingPassword(false);
}
};
const handleToggle = (key) => {
updateSettings({ [key]: !settings[key] });
};
const handleNumberChange = (key, value) => {
updateSettings({ [key]: parseInt(value, 10) });
};
const handleSelectChange = (key, value) => {
updateSettings({ [key]: value });
};
const handleReset = () => {
if (window.confirm("Reset all settings to defaults?")) {
resetSettings();
}
};
return (
<div className="settings-page">
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1 className="text-2xl font-semibold mb-4">Settings</h1>
<div className="settings-tabs">
<button
className={`settings-tab ${activeTab === "appearance" ? "active" : ""}`}
onClick={() => setActiveTab("appearance")}
>
Appearance
</button>
<button
className={`settings-tab ${activeTab === "list" ? "active" : ""}`}
onClick={() => setActiveTab("list")}
>
List Display
</button>
<button
className={`settings-tab ${activeTab === "behavior" ? "active" : ""}`}
onClick={() => setActiveTab("behavior")}
>
Behavior
</button>
<button
className={`settings-tab ${activeTab === "account" ? "active" : ""}`}
onClick={() => setActiveTab("account")}
>
Account
</button>
</div>
<div className="settings-content">
{/* Appearance Tab */}
{activeTab === "appearance" && (
<div className="settings-section">
<h2 className="text-xl font-semibold mb-4">Appearance</h2>
<div className="settings-group">
<label className="settings-label">Theme</label>
<div className="settings-theme-options">
<button
className={`settings-theme-btn ${settings.theme === "light" ? "active" : ""}`}
onClick={() => handleThemeChange("light")}
>
Light
</button>
<button
className={`settings-theme-btn ${settings.theme === "dark" ? "active" : ""}`}
onClick={() => handleThemeChange("dark")}
>
🌙 Dark
</button>
<button
className={`settings-theme-btn ${settings.theme === "auto" ? "active" : ""}`}
onClick={() => handleThemeChange("auto")}
>
🔄 Auto
</button>
</div>
<p className="settings-description">
Auto mode follows your system preferences
</p>
</div>
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.compactView}
onChange={() => handleToggle("compactView")}
/>
<span>Compact View</span>
</label>
<p className="settings-description">
Show more items on screen with reduced spacing
</p>
</div>
</div>
)}
{/* List Display Tab */}
{activeTab === "list" && (
<div className="settings-section">
<h2 className="text-xl font-semibold mb-4">List Display</h2>
<div className="settings-group">
<label className="settings-label">Default Sort Mode</label>
<select
value={settings.defaultSortMode}
onChange={(e) => handleSelectChange("defaultSortMode", e.target.value)}
className="form-select mt-2"
>
<option value="zone">By Zone</option>
<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>
<p className="settings-description">
Your preferred sorting method when opening the list
</p>
</div>
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.showRecentlyBought}
onChange={() => handleToggle("showRecentlyBought")}
/>
<span>Show Recently Bought Section</span>
</label>
<p className="settings-description">
Display items bought in the last 24 hours
</p>
</div>
{settings.showRecentlyBought && (
<>
<div className="settings-group">
<label className="settings-label">
Recently Bought Item Count: {settings.recentlyBoughtCount}
</label>
<input
type="range"
min="5"
max="50"
step="5"
value={settings.recentlyBoughtCount}
onChange={(e) => handleNumberChange("recentlyBoughtCount", e.target.value)}
className="settings-range"
/>
<p className="settings-description">
Number of items to show initially (5-50)
</p>
</div>
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.recentlyBoughtCollapsed}
onChange={() => handleToggle("recentlyBoughtCollapsed")}
/>
<span>Collapse Recently Bought by Default</span>
</label>
<p className="settings-description">
Start with the section collapsed
</p>
</div>
</>
)}
</div>
)}
{/* Behavior Tab */}
{activeTab === "behavior" && (
<div className="settings-section">
<h2 className="text-xl font-semibold mb-4">Behavior</h2>
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.confirmBeforeBuy}
onChange={() => handleToggle("confirmBeforeBuy")}
/>
<span>Confirm Before Buying</span>
</label>
<p className="settings-description">
Show confirmation modal when marking items as bought
</p>
</div>
<div className="settings-group">
<label className="settings-label">
Auto-reload Interval (minutes): {settings.autoReloadInterval || "Disabled"}
</label>
<input
type="range"
min="0"
max="30"
step="5"
value={settings.autoReloadInterval}
onChange={(e) => handleNumberChange("autoReloadInterval", e.target.value)}
className="settings-range"
/>
<p className="settings-description">
Automatically refresh the list every X minutes (0 = disabled)
</p>
</div>
<div className="settings-group">
<label className="settings-label">
<input
type="checkbox"
checked={settings.hapticFeedback}
onChange={() => handleToggle("hapticFeedback")}
/>
<span>Haptic Feedback (Mobile)</span>
</label>
<p className="settings-description">
Vibrate on long-press and other interactions
</p>
</div>
</div>
)}
{/* Account Tab */}
{activeTab === "account" && (
<div className="settings-section">
<h2 className="text-xl font-semibold mb-4">Account Management</h2>
{accountMessage.text && (
<div className={`account-message ${accountMessage.type}`}>
{accountMessage.text}
</div>
)}
{/* Display Name Section */}
<form onSubmit={handleUpdateDisplayName} className="account-form">
<h3 className="text-lg font-semibold mb-3">Display Name</h3>
<div className="settings-group">
<label className="settings-label">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={100}
className="form-input"
placeholder="Your display name"
/>
<p className="settings-description">
{displayName.length}/100 characters
</p>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loadingProfile}
>
{loadingProfile ? "Saving..." : "Save Display Name"}
</button>
</form>
<hr className="my-4" style={{ borderColor: 'var(--border-color)' }} />
{/* Password Change Section */}
<form onSubmit={handleChangePassword} className="account-form">
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
<div className="settings-group">
<label className="settings-label">
Current Password
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="form-input"
required
/>
</div>
<div className="settings-group">
<label className="settings-label">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="form-input"
minLength={6}
required
/>
<p className="settings-description">
Minimum 6 characters
</p>
</div>
<div className="settings-group">
<label className="settings-label">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="form-input"
minLength={6}
required
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loadingPassword}
>
{loadingPassword ? "Changing..." : "Change Password"}
</button>
</form>
</div>
)}
</div>
<div className="mt-4">
<button onClick={handleReset} className="btn btn-outline">
Reset to Defaults
</button>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,44 @@
/* AddImageModal - custom styles for unique components */ .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 { .add-image-options {
display: flex; display: flex;
@ -8,33 +48,32 @@
} }
.add-image-option-btn { .add-image-option-btn {
padding: var(--spacing-lg); padding: 1.2em;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #ddd;
border-radius: var(--border-radius-lg); border-radius: 8px;
background: var(--color-bg-surface); background: white;
font-size: var(--font-size-lg); font-size: 1.1em;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: all 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-sm); gap: 0.5em;
color: var(--color-text-primary);
} }
.add-image-option-btn:hover { .add-image-option-btn:hover {
border-color: var(--color-primary); border-color: #007bff;
background: var(--color-bg-hover); background: #f8f9fa;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--shadow-md); box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
} }
.add-image-option-btn.camera { .add-image-option-btn.camera {
color: var(--color-primary); color: #007bff;
} }
.add-image-option-btn.gallery { .add-image-option-btn.gallery {
color: var(--color-success); color: #28a745;
} }
.add-image-preview-container { .add-image-preview-container {
@ -47,10 +86,9 @@
position: relative; position: relative;
width: 250px; width: 250px;
height: 250px; height: 250px;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #ddd;
border-radius: var(--border-radius-lg); border-radius: 8px;
overflow: hidden; overflow: hidden;
background: var(--color-gray-100);
} }
.add-image-preview img { .add-image-preview img {
@ -81,3 +119,58 @@
.add-image-remove:hover { .add-image-remove:hover {
background: rgba(255, 0, 0, 1); background: rgba(255, 0, 0, 1);
} }
.add-image-actions {
display: flex;
gap: 1em;
margin-top: 1.5em;
}
.add-image-cancel,
.add-image-confirm {
flex: 1;
padding: 0.8em;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
}
.add-image-cancel {
background: #f0f0f0;
color: #333;
}
.add-image-cancel:hover {
background: #e0e0e0;
}
.add-image-confirm {
background: #28a745;
color: white;
}
.add-image-confirm:hover {
background: #218838;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -4,128 +4,68 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: var(--modal-backdrop-bg); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: var(--z-modal); z-index: 1000;
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
} }
.confirm-buy-modal { .confirm-buy-modal {
background: var(--modal-bg); background: white;
padding: var(--spacing-md); padding: 2em;
border-radius: var(--border-radius-xl); border-radius: 12px;
max-width: 450px; max-width: 450px;
width: 90%; width: 90%;
box-shadow: var(--shadow-xl); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out; animation: slideUp 0.3s ease-out;
} }
.confirm-buy-header { .confirm-buy-modal h2 {
margin: 0 0 0.5em 0;
font-size: 1.5em;
color: #333;
text-align: center; text-align: center;
margin-bottom: 0.5em;
}
.confirm-buy-zone {
font-size: 0.85em;
color: var(--color-text-secondary);
font-weight: 500;
margin-bottom: 0.2em;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.confirm-buy-item-name { .confirm-buy-item-name {
margin: 0; margin: 0 0 1.5em 0;
font-size: 1.2em; font-size: 1.1em;
color: var(--color-primary); color: #007bff;
font-weight: 600; font-weight: 600;
} text-align: center;
.confirm-buy-image-section {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6em;
margin: 0.8em 0;
}
.confirm-buy-nav-btn {
width: 35px;
height: 35px;
border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-full);
background: var(--color-bg-surface);
color: var(--color-primary);
font-size: 1.8em;
font-weight: bold;
cursor: pointer;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
flex-shrink: 0;
}
.confirm-buy-nav-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.confirm-buy-nav-btn:disabled {
border-color: var(--color-border-medium);
color: var(--color-text-disabled);
cursor: not-allowed;
}
.confirm-buy-image-container {
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width-medium) solid var(--color-border-light);
border-radius: var(--border-radius-lg);
overflow: hidden;
background: var(--color-gray-100);
}
.confirm-buy-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.confirm-buy-image-placeholder {
font-size: 4em;
color: var(--color-border-medium);
} }
.confirm-buy-quantity-section { .confirm-buy-quantity-section {
margin: 0.8em 0; margin: 2em 0;
}
.confirm-buy-label {
margin: 0 0 1em 0;
font-size: 1em;
color: #555;
text-align: center;
} }
.confirm-buy-counter { .confirm-buy-counter {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.8em; gap: 1em;
} }
.confirm-buy-counter-btn { .confirm-buy-counter-btn {
width: 45px; width: 50px;
height: 45px; height: 50px;
border: var(--border-width-medium) solid var(--color-primary); border: 2px solid #007bff;
border-radius: var(--border-radius-lg); border-radius: 8px;
background: var(--color-bg-surface); background: white;
color: var(--color-primary); color: #007bff;
font-size: 1.6em; font-size: 1.8em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: all 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -134,68 +74,67 @@
} }
.confirm-buy-counter-btn:hover:not(:disabled) { .confirm-buy-counter-btn:hover:not(:disabled) {
background: var(--color-primary); background: #007bff;
color: var(--color-text-inverse); color: white;
} }
.confirm-buy-counter-btn:disabled { .confirm-buy-counter-btn:disabled {
border-color: var(--color-border-medium); border-color: #ccc;
color: var(--color-text-disabled); color: #ccc;
cursor: not-allowed; cursor: not-allowed;
} }
.confirm-buy-counter-display { .confirm-buy-counter-display {
width: 70px; width: 80px;
height: 45px; height: 50px;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #ddd;
border-radius: var(--border-radius-lg); border-radius: 8px;
text-align: center; text-align: center;
font-size: 1.4em; font-size: 1.5em;
font-weight: bold; font-weight: bold;
color: var(--color-text-primary); color: #333;
background: var(--color-gray-100); background: #f8f9fa;
} }
.confirm-buy-counter-display:focus { .confirm-buy-counter-display:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: #007bff;
} }
.confirm-buy-actions { .confirm-buy-actions {
display: flex; display: flex;
gap: 0.6em; gap: 1em;
margin-top: 1em; margin-top: 2em;
} }
.confirm-buy-cancel, .confirm-buy-cancel,
.confirm-buy-confirm { .confirm-buy-confirm {
flex: 1; flex: 1;
padding: 0.75em 0.5em; padding: 0.9em;
border: none; border: none;
border-radius: var(--border-radius-lg); border-radius: 8px;
font-size: 0.95em; font-size: 1em;
font-weight: 600; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: all 0.2s;
white-space: nowrap;
} }
.confirm-buy-cancel { .confirm-buy-cancel {
background: var(--color-gray-200); background: #f0f0f0;
color: var(--color-text-primary); color: #333;
} }
.confirm-buy-cancel:hover { .confirm-buy-cancel:hover {
background: var(--color-gray-300); background: #e0e0e0;
} }
.confirm-buy-confirm { .confirm-buy-confirm {
background: var(--color-success); background: #28a745;
color: var(--color-text-inverse); color: white;
} }
.confirm-buy-confirm:hover { .confirm-buy-confirm:hover {
background: var(--color-success-hover); background: #218838;
} }
@keyframes fadeIn { @keyframes fadeIn {
@ -217,88 +156,3 @@
opacity: 1; opacity: 1;
} }
} }
/* Mobile optimizations */
@media (max-width: 480px) {
.confirm-buy-modal {
padding: 0.8em;
}
.confirm-buy-header {
margin-bottom: 0.4em;
}
.confirm-buy-zone {
font-size: 0.8em;
}
.confirm-buy-item-name {
font-size: 1.1em;
}
.confirm-buy-image-section {
gap: 0.5em;
margin: 0.6em 0;
}
.confirm-buy-actions {
gap: 0.5em;
margin-top: 0.8em;
}
.confirm-buy-cancel,
.confirm-buy-confirm {
padding: 0.7em 0.4em;
font-size: 0.9em;
}
.confirm-buy-image-container {
width: 220px;
height: 220px;
}
.confirm-buy-nav-btn {
width: 30px;
height: 30px;
font-size: 1.6em;
}
.confirm-buy-counter-btn {
width: 40px;
height: 40px;
font-size: 1.4em;
}
.confirm-buy-counter-display {
width: 60px;
height: 40px;
font-size: 1.2em;
}
.confirm-buy-quantity-section {
margin: 0.6em 0;
}
}
@media (max-width: 360px) {
.confirm-buy-modal {
padding: 0.7em;
}
.confirm-buy-cancel,
.confirm-buy-confirm {
padding: 0.65em 0.3em;
font-size: 0.85em;
}
.confirm-buy-image-container {
width: 180px;
height: 180px;
}
.confirm-buy-nav-btn {
width: 28px;
height: 28px;
font-size: 1.4em;
}
}

View File

@ -1,5 +1,3 @@
/* ImageModal - specialized full-screen image viewer */
.image-modal-overlay { .image-modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -46,6 +44,30 @@
} }
} }
.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 { .image-modal-img {
max-width: 100%; max-width: 100%;
max-height: 70vh; max-height: 70vh;
@ -54,6 +76,14 @@
border-radius: 8px; 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) { @media (max-width: 768px) {
.image-modal-overlay { .image-modal-overlay {
padding: 1rem; padding: 1rem;
@ -62,5 +92,8 @@
.image-modal-img { .image-modal-img {
max-height: 60vh; max-height: 60vh;
} }
}
.image-modal-caption {
font-size: 1rem;
}
}

View File

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

View File

@ -1,13 +1,40 @@
/* UserRoleCard - custom styles only */ .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 { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: 0.25rem;
} }
.user-username { .user-username {
color: var(--color-text-secondary); color: #666;
font-size: var(--font-size-sm); font-size: 0.9rem;
} }
.role-select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
font-size: 0.9rem;
}
.role-select:hover {
border-color: #007bff;
}
.role-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}

View File

@ -29,12 +29,6 @@
font-family: var(--font-family-base); font-family: var(--font-family-base);
transition: var(--transition-base); transition: var(--transition-base);
width: 100%; width: 100%;
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.add-item-form-input::placeholder {
color: var(--color-text-muted);
} }
.add-item-form-input:focus { .add-item-form-input:focus {
@ -113,8 +107,6 @@
font-family: var(--font-family-base); font-family: var(--font-family-base);
text-align: center; text-align: center;
transition: var(--transition-base); transition: var(--transition-base);
background: var(--color-bg-surface);
color: var(--color-text-primary);
-moz-appearance: textfield; /* Remove spinner in Firefox */ -moz-appearance: textfield; /* Remove spinner in Firefox */
} }
@ -141,8 +133,7 @@
border-radius: var(--button-border-radius); border-radius: var(--button-border-radius);
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-weight: var(--button-font-weight); font-weight: var(--button-font-weight);
flex: 1; cursor: pointer;
min-width: 120px
transition: var(--transition-base); transition: var(--transition-base);
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
} }
@ -159,13 +150,12 @@
.add-item-form-submit.disabled, .add-item-form-submit.disabled,
.add-item-form-submit:disabled { .add-item-form-submit:disabled {
background: var(--color-gray-400); background: var(--color-bg-disabled);
color: var(--color-gray-600); color: var(--color-text-disabled);
cursor: not-allowed; cursor: not-allowed;
opacity: 1; opacity: 0.6;
box-shadow: none; box-shadow: none;
transform: none; transform: none;
border: var(--border-width-thin) solid var(--color-gray-500);
} }
/* Responsive */ /* Responsive */

View File

@ -4,43 +4,43 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: var(--modal-backdrop-bg); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: var(--z-modal); z-index: 1000;
padding: var(--spacing-md); padding: 1em;
} }
.add-item-details-modal { .add-item-details-modal {
background: var(--modal-bg); background: white;
border-radius: var(--border-radius-xl); border-radius: 12px;
padding: var(--spacing-xl); padding: 1.5em;
max-width: 500px; max-width: 500px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-xl); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
} }
.add-item-details-title { .add-item-details-title {
font-size: var(--font-size-xl); font-size: 1.4em;
margin: 0 0 var(--spacing-xs) 0; margin: 0 0 0.3em 0;
text-align: center; text-align: center;
color: var(--color-text-primary); color: #333;
} }
.add-item-details-subtitle { .add-item-details-subtitle {
text-align: center; text-align: center;
color: var(--color-text-secondary); color: #666;
margin: 0 0 var(--spacing-xl) 0; margin: 0 0 1.5em 0;
font-size: var(--font-size-sm); font-size: 0.9em;
} }
.add-item-details-section { .add-item-details-section {
margin-bottom: var(--spacing-xl); margin-bottom: 1.5em;
padding-bottom: var(--spacing-xl); padding-bottom: 1.5em;
border-bottom: var(--border-width-thin) solid var(--color-border-light); border-bottom: 1px solid #e0e0e0;
} }
.add-item-details-section:last-of-type { .add-item-details-section:last-of-type {
@ -48,9 +48,9 @@
} }
.add-item-details-section-title { .add-item-details-section-title {
font-size: var(--font-size-lg); font-size: 1.1em;
margin: 0 0 var(--spacing-md) 0; margin: 0 0 1em 0;
color: var(--color-text-secondary); color: #555;
font-weight: 600; font-weight: 600;
} }
@ -68,27 +68,27 @@
.add-item-details-image-btn { .add-item-details-image-btn {
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
padding: var(--button-padding-y) var(--button-padding-x); padding: 0.8em;
font-size: 0.95em; font-size: 0.95em;
border: var(--border-width-medium) solid var(--color-primary); border: 2px solid #007bff;
background: var(--color-bg-surface); background: white;
color: var(--color-primary); color: #007bff;
border-radius: var(--border-radius-lg); border-radius: 8px;
cursor: pointer; cursor: pointer;
font-weight: var(--button-font-weight); font-weight: 600;
transition: var(--transition-base); transition: all 0.2s;
} }
.add-item-details-image-btn:hover { .add-item-details-image-btn:hover {
background: var(--color-primary); background: #007bff;
color: var(--color-text-inverse); color: white;
} }
.add-item-details-image-preview { .add-item-details-image-preview {
position: relative; position: relative;
border-radius: var(--border-radius-lg); border-radius: 8px;
overflow: hidden; overflow: hidden;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #e0e0e0;
} }
.add-item-details-image-preview img { .add-item-details-image-preview img {

View File

@ -1,45 +1,44 @@
/* Classification Section */ /* Classification Section */
.classification-section { .classification-section {
margin-bottom: var(--spacing-xl); margin-bottom: 1.5rem;
} }
.classification-title { .classification-title {
font-size: var(--font-size-base); font-size: 1em;
font-weight: 600; font-weight: 600;
margin-bottom: var(--spacing-md); margin-bottom: 0.8rem;
color: var(--color-text-primary); color: #333;
} }
.classification-field { .classification-field {
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
} }
.classification-field label { .classification-field label {
display: block; display: block;
font-size: var(--font-size-sm); font-size: 0.9em;
font-weight: 500; font-weight: 500;
margin-bottom: var(--spacing-xs); margin-bottom: 0.4rem;
color: var(--color-text-secondary); color: #555;
} }
.classification-select { .classification-select {
width: 100%; width: 100%;
padding: var(--input-padding-y) var(--input-padding-x); padding: 0.6rem;
font-size: var(--font-size-base); font-size: 1em;
border: var(--border-width-thin) solid var(--input-border-color); border: 1px solid #ccc;
border-radius: var(--input-border-radius); border-radius: 4px;
background: var(--color-bg-surface); background: white;
color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: border-color 0.2s;
} }
.classification-select:focus { .classification-select:focus {
outline: none; outline: none;
border-color: var(--input-focus-border-color); border-color: #007bff;
box-shadow: var(--input-focus-shadow); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
} }
.classification-select:hover { .classification-select:hover {
border-color: var(--color-border-dark); border-color: #999;
} }

View File

@ -1,41 +0,0 @@
/* ConfirmAddExistingModal - quantity breakdown box */
.confirm-add-existing-qty-info {
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
margin: var(--spacing-md) 0;
}
.qty-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-xs) 0;
font-size: var(--font-size-base);
}
.qty-row.qty-total {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: var(--border-width-medium) solid var(--color-border-medium);
font-weight: var(--font-weight-semibold);
}
.qty-label {
color: var(--color-text-secondary);
}
.qty-value {
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-lg);
}
.qty-total .qty-label,
.qty-total .qty-value {
color: var(--color-primary);
font-size: var(--font-size-lg);
}

View File

@ -4,141 +4,88 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: var(--modal-backdrop-bg); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: var(--z-modal); z-index: 1000;
padding: var(--spacing-md); padding: 1em;
} }
.edit-modal-content { .edit-modal-content {
background: var(--modal-bg); background: white;
border-radius: var(--border-radius-xl); border-radius: 12px;
padding: var(--spacing-lg); padding: 1.5em;
max-width: 420px; max-width: 480px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-xl); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
} }
.edit-modal-title { .edit-modal-title {
font-size: var(--font-size-xl); font-size: 1.5em;
margin: 0 0 var(--spacing-md) 0; margin: 0 0 1em 0;
text-align: center; text-align: center;
color: var(--color-text-primary); 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-input,
.edit-modal-select { .edit-modal-select {
width: 100%; width: 100%;
padding: var(--input-padding-y) var(--input-padding-x); padding: 0.6em;
font-size: var(--font-size-base); font-size: 1em;
border: var(--border-width-thin) solid var(--input-border-color); border: 1px solid #ccc;
border-radius: var(--input-border-radius); border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
transition: var(--transition-base); transition: border-color 0.2s;
background: var(--color-bg-surface);
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
} }
.edit-modal-input:focus, .edit-modal-input:focus,
.edit-modal-select:focus { .edit-modal-select:focus {
outline: none; outline: none;
border-color: var(--input-focus-border-color); border-color: #007bff;
box-shadow: var(--input-focus-shadow);
}
/* Quantity Control - matching AddItemForm */
.edit-modal-quantity-control {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.edit-modal-quantity-input {
width: 60px;
text-align: center;
font-weight: 600;
font-size: var(--font-size-lg);
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);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.quantity-btn {
width: 48px;
height: 48px;
border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
color: var(--color-primary);
font-size: 1.5em;
font-weight: bold;
cursor: pointer;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
}
.quantity-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.quantity-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Inline Classification Fields */
.edit-modal-inline-field {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-sm);
}
.edit-modal-inline-field label {
min-width: 60px;
font-weight: 600;
color: var(--color-text-primary);
font-size: 0.95em;
}
.edit-modal-inline-field .edit-modal-select {
flex: 1;
margin-bottom: 0;
} }
.edit-modal-divider { .edit-modal-divider {
height: 1px; height: 1px;
background: var(--color-border-light); background: #e0e0e0;
margin: var(--spacing-md) 0; margin: 1.5em 0;
} }
.edit-modal-actions { .edit-modal-actions {
display: flex; display: flex;
gap: var(--spacing-sm); gap: 0.8em;
margin-top: var(--spacing-md); margin-top: 1.5em;
} }
.edit-modal-btn { .edit-modal-btn {
flex: 1; flex: 1;
padding: var(--button-padding-y) var(--button-padding-x); padding: 0.7em;
font-size: var(--font-size-base); font-size: 1em;
border: none; border: none;
border-radius: var(--button-border-radius); border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: var(--button-font-weight); font-weight: 600;
transition: var(--transition-base); transition: all 0.2s;
} }
.edit-modal-btn:disabled { .edit-modal-btn:disabled {
@ -147,43 +94,19 @@
} }
.edit-modal-btn-cancel { .edit-modal-btn-cancel {
background: var(--color-secondary); background: #6c757d;
color: var(--color-text-inverse); color: white;
} }
.edit-modal-btn-cancel:hover:not(:disabled) { .edit-modal-btn-cancel:hover:not(:disabled) {
background: var(--color-secondary-hover); background: #5a6268;
} }
.edit-modal-btn-save { .edit-modal-btn-save {
background: var(--color-primary); background: #007bff;
color: var(--color-text-inverse); color: white;
} }
.edit-modal-btn-save:hover:not(:disabled) { .edit-modal-btn-save:hover:not(:disabled) {
background: var(--color-primary-hover); background: #0056b3;
}
.edit-modal-btn-image {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-base);
border: var(--border-width-medium) solid var(--color-success);
border-radius: var(--button-border-radius);
cursor: pointer;
font-weight: var(--button-font-weight);
transition: var(--transition-base);
background: var(--color-bg-surface);
color: var(--color-success);
margin-bottom: var(--spacing-sm);
}
.edit-modal-btn-image:hover:not(:disabled) {
background: var(--color-success);
color: var(--color-text-inverse);
}
.edit-modal-btn-image:disabled {
opacity: 0.6;
cursor: not-allowed;
} }

View File

@ -10,17 +10,6 @@
color: #333; color: #333;
} }
.image-upload-error {
background: #f8d7da;
border: 1px solid #f5c2c7;
color: #842029;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 0.8rem;
font-size: 0.9em;
line-height: 1.4;
}
.image-upload-content { .image-upload-content {
border: 2px dashed #ccc; border: 2px dashed #ccc;
border-radius: 8px; border-radius: 8px;

View File

@ -1,42 +0,0 @@
/* Suggestion List Component */
.suggestion-list {
background: var(--color-bg-surface);
border: 2px solid var(--color-border-medium);
border-radius: var(--border-radius-md);
max-height: 200px;
overflow-y: auto;
list-style: none;
padding: var(--spacing-xs);
margin: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
position: relative;
z-index: 100;
}
.suggestion-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-radius: var(--border-radius-sm);
background: var(--color-bg-hover);
color: var(--color-text-primary);
transition: var(--transition-fast);
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
border: 1px solid var(--color-border-light);
}
.suggestion-item:last-child {
margin-bottom: 0;
}
.suggestion-item:hover {
background: var(--color-primary-light);
color: var(--color-primary);
font-weight: 500;
border-color: var(--color-primary);
}
.suggestion-item:active {
background: var(--color-primary);
color: var(--color-text-inverse);
}

View File

@ -1,9 +0,0 @@
/* Admin Panel - uses utility classes */
/* Responsive adjustments only */
@media (max-width: 768px) {
.admin-panel-page {
padding: var(--spacing-md) !important;
}
}

View File

@ -29,64 +29,6 @@
color: var(--color-gray-700); color: var(--color-gray-700);
border-top: var(--border-width-medium) solid var(--color-border-light); border-top: var(--border-width-medium) solid var(--color-border-light);
padding-top: var(--spacing-md); padding-top: var(--spacing-md);
display: flex;
justify-content: space-between;
align-items: center;
}
.glist-section-title.clickable {
cursor: pointer;
transition: var(--transition-base);
user-select: none;
padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
background: var(--color-bg-surface);
}
.glist-section-title.clickable:hover {
background: var(--color-bg-hover);
color: var(--color-primary);
border-top-color: var(--color-primary);
}
.glist-section-indicator {
font-size: var(--font-size-base);
opacity: 0.7;
margin-left: var(--spacing-sm);
}
.glist-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-xl);
border-top: var(--border-width-medium) solid var(--color-border-light);
padding-top: var(--spacing-md);
}
.glist-section-header .glist-section-title {
margin: 0;
border: none;
padding: 0;
text-align: left;
}
.glist-collapse-btn {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
cursor: pointer;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
border-radius: var(--button-border-radius);
transition: var(--transition-base);
font-weight: var(--button-font-weight);
}
.glist-collapse-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
border-color: var(--color-primary);
} }
/* Classification Groups */ /* Classification Groups */
@ -103,34 +45,6 @@
background: var(--color-primary-light); background: var(--color-primary-light);
border-left: var(--border-width-thick) solid var(--color-primary); border-left: var(--border-width-thick) solid var(--color-primary);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
display: flex;
justify-content: space-between;
align-items: center;
}
.glist-classification-header.clickable {
cursor: pointer;
transition: var(--transition-base);
user-select: none;
}
.glist-classification-header.clickable:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
transform: translateY(-1px);
}
.glist-zone-count {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
opacity: 0.8;
margin-left: var(--spacing-xs);
}
.glist-zone-indicator {
font-size: var(--font-size-base);
opacity: 0.7;
margin-left: var(--spacing-sm);
} }
/* Inputs */ /* Inputs */
@ -180,52 +94,49 @@
/* Suggestion dropdown */ /* Suggestion dropdown */
.glist-suggest-box { .glist-suggest-box {
background: var(--color-bg-surface); background: #fff;
border: var(--border-width-thin) solid var(--color-border-medium); border: 1px solid #ccc;
max-height: 150px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
position: absolute; position: absolute;
z-index: var(--z-dropdown); z-index: 999;
border-radius: var(--border-radius-lg); border-radius: 8px;
box-shadow: var(--shadow-card); box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: var(--spacing-md); padding: 1em;
width: calc(100% - 8em); width: calc(100% - 8em);
max-width: 440px; max-width: 440px;
margin: 0 auto; margin: 0 auto;
} }
.glist-suggest-item { .glist-suggest-item {
padding: var(--spacing-sm); padding: 0.5em;
padding-inline: var(--spacing-xl); padding-inline: 2em;
cursor: pointer; cursor: pointer;
color: var(--color-text-primary);
border-radius: var(--border-radius-sm);
transition: var(--transition-fast);
} }
.glist-suggest-item:hover { .glist-suggest-item:hover {
background: var(--color-bg-hover); background: #eee;
} }
/* Grocery list items */ /* Grocery list items */
.glist-ul { .glist-ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin-top: var(--spacing-md); margin-top: 1em;
} }
.glist-li { .glist-li {
background: var(--color-bg-surface); background: #fff;
border: var(--border-width-thin) solid var(--color-border-light); border: 1px solid #e0e0e0;
border-radius: var(--border-radius-lg); border-radius: 8px;
margin-bottom: var(--spacing-sm); margin-bottom: 0.8em;
cursor: pointer; cursor: pointer;
transition: box-shadow var(--transition-base), transform var(--transition-base); transition: box-shadow 0.2s, transform 0.2s;
overflow: hidden; overflow: hidden;
} }
.glist-li:hover { .glist-li:hover {
box-shadow: var(--shadow-md); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px); transform: translateY(-2px);
} }
@ -240,21 +151,21 @@
width: 50px; width: 50px;
height: 50px; height: 50px;
min-width: 50px; min-width: 50px;
background: var(--color-gray-100); background: #f5f5f5;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #e0e0e0;
border-radius: var(--border-radius-lg); border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 2em; font-size: 2em;
color: var(--color-border-medium); color: #ccc;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.glist-item-image.has-image { .glist-item-image.has-image {
border-color: var(--color-primary); border-color: #007bff;
background: var(--color-bg-surface); background: #fff;
} }
.glist-item-image img { .glist-item-image img {
@ -263,6 +174,11 @@
object-fit: cover; 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 { .glist-item-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -281,69 +197,37 @@
.glist-item-name { .glist-item-name {
font-weight: 800; font-weight: 800;
font-size: 0.8em; font-size: 0.8em;
color: var(--color-text-primary); color: #333;
} }
.glist-item-quantity { .glist-item-quantity {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
background: var(--color-primary); background: rgba(0, 123, 255, 0.9);
color: var(--color-text-inverse); color: white;
font-weight: 700; font-weight: 700;
font-size: 0.3em; font-size: 0.3em;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 0 var(--border-radius-md) 0 var(--border-radius-sm); border-radius: 0 6px 0 4px;
min-width: 20%; min-width: 20%;
text-align: center; text-align: center;
box-shadow: var(--shadow-sm); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
} }
.glist-item-users { .glist-item-users {
font-size: 0.7em; font-size: 0.7em;
color: var(--color-text-secondary); color: #888;
font-style: italic; font-style: italic;
} }
/* Compact View */
.glist-ul.compact .glist-li {
padding: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.glist-ul.compact .glist-item-layout {
gap: var(--spacing-xs);
}
.glist-ul.compact .glist-item-image {
width: 40px;
height: 40px;
font-size: var(--font-size-lg);
}
.glist-ul.compact .glist-item-quantity {
font-size: var(--font-size-xs);
padding: 1px 4px;
}
.glist-ul.compact .glist-item-name {
font-size: var(--font-size-sm);
}
.glist-ul.compact .glist-item-users {
font-size: 0.65em;
}
/* Sorting dropdown */ /* Sorting dropdown */
.glist-sort { .glist-sort {
width: 100%; width: 100%;
margin: var(--spacing-xs) 0; margin: 0.3em 0;
padding: var(--spacing-sm); padding: 0.5em;
font-size: var(--font-size-base); font-size: 1em;
border-radius: var(--border-radius-sm); border-radius: 4px;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface);
color: var(--color-text-primary);
} }
/* Image upload */ /* Image upload */
@ -353,19 +237,18 @@
.glist-image-label { .glist-image-label {
display: block; display: block;
padding: var(--spacing-sm); padding: 0.6em;
background: var(--color-gray-100); background: #f0f0f0;
border: var(--border-width-medium) dashed var(--color-border-medium); border: 2px dashed #ccc;
border-radius: var(--border-radius-sm); border-radius: 4px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: all 0.2s;
color: var(--color-text-primary);
} }
.glist-image-label:hover { .glist-image-label:hover {
background: var(--color-bg-hover); background: #e8e8e8;
border-color: var(--color-primary); border-color: #007bff;
} }
.glist-image-preview { .glist-image-preview {
@ -377,8 +260,8 @@
.glist-image-preview img { .glist-image-preview img {
max-width: 150px; max-width: 150px;
max-height: 150px; max-height: 150px;
border-radius: var(--border-radius-lg); border-radius: 8px;
border: var(--border-width-medium) solid var(--color-border-light); border: 2px solid #ddd;
} }
.glist-remove-image { .glist-remove-image {
@ -387,10 +270,10 @@
right: -8px; right: -8px;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: var(--border-radius-full); border-radius: 50%;
background: var(--color-danger); background: #ff4444;
color: var(--color-text-inverse); color: white;
border: var(--border-width-medium) solid var(--color-bg-surface); border: 2px solid white;
font-size: 1.2rem; font-size: 1.2rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
@ -400,7 +283,7 @@
} }
.glist-remove-image:hover { .glist-remove-image:hover {
background: var(--color-danger-hover); background: #cc0000;
} }
/* Floating Action Button (FAB) */ /* Floating Action Button (FAB) */
@ -408,10 +291,10 @@
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
background: var(--color-success); background: #28a745;
color: var(--color-text-inverse); color: white;
border: none; border: none;
border-radius: var(--border-radius-full); border-radius: 50%;
width: 62px; width: 62px;
height: 62px; height: 62px;
font-size: 2em; font-size: 2em;
@ -419,14 +302,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: var(--shadow-lg); box-shadow: 0 3px 10px rgba(0,0,0,0.2);
cursor: pointer; cursor: pointer;
transition: var(--transition-base);
} }
.glist-fab:hover { .glist-fab:hover {
background: var(--color-success-hover); background: #218838;
transform: scale(1.05);
} }
/* Mobile tweaks */ /* Mobile tweaks */

View File

@ -1,4 +1,37 @@
/* Login page - custom password toggle only */ .login-wrapper {
font-family: Arial, sans-serif;
padding: 1em;
background: #f8f9fa;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.login-box {
width: 100%;
max-width: 360px;
background: white;
padding: 1.5em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.12);
}
.login-title {
text-align: center;
font-size: 1.6em;
margin-bottom: 1em;
}
.login-input {
width: 100%;
padding: 0.6em;
margin: 0.4em 0;
font-size: 1em;
border-radius: 4px;
border: 1px solid #ccc;
}
.login-password-wrapper { .login-password-wrapper {
display: flex; display: flex;
@ -7,7 +40,7 @@
margin: 0.4em 0; margin: 0.4em 0;
} }
.login-password-wrapper .form-input { .login-password-wrapper .login-input {
flex: 1; flex: 1;
width: auto; width: auto;
margin: 0; margin: 0;
@ -34,3 +67,38 @@
background: #e8e8e8; background: #e8e8e8;
} }
.login-button {
width: 100%;
padding: 0.7em;
margin-top: 0.6em;
background: #007bff;
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.login-button:hover {
background: #0068d1;
}
.login-error {
color: red;
text-align: center;
margin-bottom: 0.6em;
}
.login-register {
text-align: center;
margin-top: 1em;
}
.login-register a {
color: #007bff;
text-decoration: none;
}
.login-register a:hover {
text-decoration: underline;
}

View File

@ -1,12 +1,18 @@
/* Register page - container only */
.register-container { .register-container {
max-width: 400px; max-width: 400px;
margin: 50px auto; margin: 50px auto;
padding: 2rem; padding: 2rem;
border-radius: 12px; border-radius: 12px;
background: var(--color-bg-primary); background: #ffffff;
box-shadow: var(--shadow-lg); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
font-family: Arial, sans-serif;
}
.register-container h1 {
text-align: center;
margin-bottom: 1.5rem;
font-size: 1.8rem;
font-weight: bold;
} }
.register-form { .register-form {
@ -15,3 +21,64 @@
gap: 12px; gap: 12px;
} }
.register-form input {
padding: 12px 14px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s ease;
}
.register-form input:focus {
border-color: #0077ff;
}
.register-form button {
padding: 12px;
border: none;
background: #0077ff;
color: white;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
margin-top: 10px;
transition: background 0.2s ease;
}
.register-form button:hover:not(:disabled) {
background: #005fcc;
}
.register-form button:disabled {
background: #a8a8a8;
cursor: not-allowed;
}
.error-message {
height: 15px;
color: red;
text-align: center;
margin-bottom: 10px;
}
.success-message {
color: green;
text-align: center;
margin-bottom: 10px;
}
.register-link {
text-align: center;
margin-top: 1rem;
}
.register-link a {
color: #0077ff;
text-decoration: none;
font-weight: bold;
}
.register-link a:hover {
text-decoration: underline;
}

View File

@ -1,213 +0,0 @@
/* Settings Page - custom components only */
.settings-page {
padding: var(--spacing-lg);
max-width: 800px;
margin: 0 auto;
}
/* Tabs */
.settings-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
border-bottom: 2px solid var(--color-border-light);
}
.settings-tab {
padding: var(--spacing-md) var(--spacing-lg);
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: -2px;
}
.settings-tab:hover {
color: var(--color-primary);
background: var(--color-bg-hover);
}
.settings-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* Content */
.settings-content {
min-height: 400px;
}
.settings-section {
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-group {
margin-bottom: var(--spacing-xl);
padding-bottom: var(--spacing-xl);
border-bottom: 1px solid var(--color-border-light);
}
.settings-group:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
cursor: pointer;
}
.settings-label input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.settings-description {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: var(--spacing-sm) 0 0;
line-height: 1.5;
}
/* Theme Buttons */
.settings-theme-options {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-sm);
}
.settings-theme-btn {
flex: 1;
padding: var(--spacing-md);
border: 2px solid var(--color-border-light);
background: var(--color-bg-surface);
color: var(--color-text-primary);
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.settings-theme-btn:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.settings-theme-btn.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-white);
}
/* Range Slider */
.settings-range {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--color-gray-300);
outline: none;
margin-top: var(--spacing-sm);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.settings-range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
transition: all 0.2s;
}
.settings-range::-webkit-slider-thumb:hover {
background: var(--color-primary-hover);
transform: scale(1.1);
}
.settings-range::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: none;
transition: all 0.2s;
}
.settings-range::-moz-range-thumb:hover {
background: var(--color-primary-hover);
transform: scale(1.1);
}
/* Responsive */
@media (max-width: 768px) {
.settings-page {
padding: var(--spacing-md);
}
.settings-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.settings-tab {
padding: var(--spacing-sm) var(--spacing-md);
white-space: nowrap;
}
.settings-theme-options {
flex-direction: column;
}
}
/* Account Management */
.account-form {
margin-bottom: var(--spacing-xl);
}
.account-message {
padding: var(--spacing-md);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-lg);
font-weight: 500;
}
.account-message.success {
background-color: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.account-message.error {
background-color: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
}

View File

@ -189,94 +189,23 @@
--modal-max-width: 500px; --modal-max-width: 500px;
} }
/* ============================================
DARK MODE
============================================ */
[data-theme="dark"] {
/* Primary Colors */
--color-primary: #4da3ff;
--color-primary-hover: #66b3ff;
--color-primary-light: #1a3a52;
--color-primary-dark: #3d8fdb;
/* Semantic Colors */
--color-success: #4ade80;
--color-success-hover: #5fe88d;
--color-success-light: #1a3a28;
--color-danger: #f87171;
--color-danger-hover: #fa8585;
--color-danger-light: #4a2020;
--color-warning: #fbbf24;
--color-warning-hover: #fcd34d;
--color-warning-light: #3a2f0f;
--color-info: #38bdf8;
--color-info-hover: #5dc9fc;
--color-info-light: #1a2f3a;
/* Text Colors */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-inverse: #1e293b;
--color-text-disabled: #475569;
/* Background Colors */
--color-bg-body: #0f172a;
--color-bg-surface: #1e293b;
--color-bg-hover: #334155;
--color-bg-disabled: #1e293b;
/* Border Colors */
--color-border-light: #334155;
--color-border-medium: #475569;
--color-border-dark: #64748b;
--color-border-disabled: #334155;
/* Neutral Colors - Dark adjusted */
--color-gray-50: #1e293b;
--color-gray-100: #1e293b;
--color-gray-200: #334155;
--color-gray-300: #475569;
--color-gray-400: #64748b;
--color-gray-500: #94a3b8;
--color-gray-600: #cbd5e1;
--color-gray-700: #e2e8f0;
--color-gray-800: #f1f5f9;
--color-gray-900: #f8fafc;
/* Shadows - Lighter for dark mode */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.5);
/* Modals */
--modal-backdrop-bg: rgba(0, 0, 0, 0.8);
--modal-bg: var(--color-bg-surface);
/* Inputs */
--input-border-color: var(--color-border-medium);
--input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3);
/* Cards */
--card-bg: var(--color-bg-surface);
}
/* ============================================ /* ============================================
DARK MODE SUPPORT (Future Implementation) DARK MODE SUPPORT (Future Implementation)
============================================ */ ============================================ */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
/* Auto mode will use data-theme attribute set by JS */ /* 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 */
/* Manual dark mode class override (deprecated - use data-theme) */
.dark-mode { .dark-mode {
--color-text-primary: #f8f9fa; --color-text-primary: #f8f9fa;
--color-text-secondary: #adb5bd; --color-text-secondary: #adb5bd;

View File

@ -1,570 +0,0 @@
/**
* Reusable Utility Classes
*
* Common patterns extracted from component styles.
* Import this file after theme.css in main.tsx
*/
/* ============================================
LAYOUT UTILITIES
============================================ */
/* Containers */
.container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: var(--container-padding);
}
.container-full {
width: 100%;
padding: var(--spacing-md);
}
/* Centering */
.center-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-start {
display: flex;
justify-content: flex-start;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
/* ============================================
CARD COMPONENTS
============================================ */
.card {
background: var(--color-bg-surface);
border-radius: var(--card-border-radius);
padding: var(--card-padding);
box-shadow: var(--shadow-card);
}
.card-elevated {
background: var(--color-bg-surface);
border-radius: var(--card-border-radius);
padding: var(--spacing-lg);
box-shadow: var(--shadow-lg);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
/* ============================================
BUTTON COMPONENTS
============================================ */
.btn {
padding: var(--button-padding-y) var(--button-padding-x);
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);
text-align: center;
display: inline-block;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--color-secondary);
color: var(--color-text-inverse);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-secondary-hover);
}
.btn-danger {
background: var(--color-danger);
color: var(--color-text-inverse);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-hover);
}
.btn-success {
background: var(--color-success);
color: var(--color-text-inverse);
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-hover);
}
.btn-outline {
background: transparent;
color: var(--color-primary);
border: var(--border-width-thin) solid var(--color-primary);
}
.btn-outline:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-ghost {
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: var(--border-width-thin) solid var(--color-border-medium);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-dark);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.btn-lg {
padding: var(--spacing-md) var(--spacing-xl);
font-size: var(--font-size-lg);
}
.btn-block {
width: 100%;
display: block;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ============================================
FORM COMPONENTS
============================================ */
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.form-input {
width: 100%;
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);
color: var(--color-text-primary);
background: var(--color-bg-surface);
transition: var(--transition-base);
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
.form-input::placeholder {
color: var(--color-text-muted);
}
.form-select {
width: 100%;
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);
color: var(--color-text-primary);
background: var(--color-bg-surface);
cursor: pointer;
transition: var(--transition-base);
}
.form-select:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
/* ============================================
MODAL COMPONENTS
============================================ */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-backdrop-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--spacing-md);
}
.modal {
background: var(--modal-bg);
border-radius: var(--modal-border-radius);
padding: var(--modal-padding);
max-width: var(--modal-max-width);
width: 100%;
box-shadow: var(--shadow-xl);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
margin-bottom: var(--spacing-lg);
}
.modal-title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: center;
}
.modal-content {
margin-bottom: var(--spacing-lg);
}
.modal-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.modal-actions .btn {
flex: 1;
}
/* ============================================
LIST COMPONENTS
============================================ */
.list-unstyled {
list-style: none;
padding: 0;
margin: 0;
}
.list-item {
padding: var(--spacing-md);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
margin-bottom: var(--spacing-sm);
transition: var(--transition-base);
}
.list-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-medium);
transform: translateY(-1px);
}
.list-item:last-child {
margin-bottom: 0;
}
/* ============================================
IMAGE COMPONENTS
============================================ */
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-surface);
border: var(--border-width-medium) dashed var(--color-border-medium);
border-radius: var(--border-radius-md);
color: var(--color-text-muted);
font-size: 2rem;
}
.image-thumbnail {
width: 50px;
height: 50px;
border-radius: var(--border-radius-md);
object-fit: cover;
border: var(--border-width-thin) solid var(--color-border-light);
}
/* ============================================
BADGE COMPONENTS
============================================ */
.badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.badge-primary {
background: var(--color-primary-light);
color: var(--color-primary);
}
.badge-success {
background: var(--color-success-light);
color: var(--color-success);
}
.badge-danger {
background: var(--color-danger-light);
color: var(--color-danger);
}
.badge-warning {
background: var(--color-warning-light);
color: var(--color-warning);
}
.badge-secondary {
background: var(--color-secondary-light);
color: var(--color-secondary);
}
/* ============================================
DIVIDER
============================================ */
.divider {
border: none;
border-top: var(--border-width-thin) solid var(--color-border-light);
margin: var(--spacing-lg) 0;
}
.divider-thick {
border-top-width: var(--border-width-medium);
}
/* ============================================
SPACING HELPERS
============================================ */
.mt-0 { margin-top: 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-0 { margin-bottom: 0 !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; }
.ml-auto { margin-left: auto !important; }
.mr-auto { margin-right: auto !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; }
.px-0 { padding-left: 0 !important; padding-right: 0 !important; }
.px-1 { padding-left: var(--spacing-xs) !important; padding-right: var(--spacing-xs) !important; }
.px-2 { padding-left: var(--spacing-sm) !important; padding-right: var(--spacing-sm) !important; }
.px-3 { padding-left: var(--spacing-md) !important; padding-right: var(--spacing-md) !important; }
.px-4 { padding-left: var(--spacing-lg) !important; padding-right: var(--spacing-lg) !important; }
.py-0 { padding-top: 0 !important; padding-bottom: 0 !important; }
.py-1 { padding-top: var(--spacing-xs) !important; padding-bottom: var(--spacing-xs) !important; }
.py-2 { padding-top: var(--spacing-sm) !important; padding-bottom: var(--spacing-sm) !important; }
.py-3 { padding-top: var(--spacing-md) !important; padding-bottom: var(--spacing-md) !important; }
.py-4 { padding-top: var(--spacing-lg) !important; padding-bottom: var(--spacing-lg) !important; }
/* ============================================
TEXT UTILITIES
============================================ */
.text-xs { font-size: var(--font-size-xs) !important; }
.text-sm { font-size: var(--font-size-sm) !important; }
.text-base { font-size: var(--font-size-base) !important; }
.text-lg { font-size: var(--font-size-lg) !important; }
.text-xl { font-size: var(--font-size-xl) !important; }
.text-2xl { font-size: var(--font-size-2xl) !important; }
.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; }
.text-warning { color: var(--color-warning) !important; }
.font-normal { font-weight: var(--font-weight-normal) !important; }
.font-medium { font-weight: var(--font-weight-medium) !important; }
.font-semibold { font-weight: var(--font-weight-semibold) !important; }
.font-bold { font-weight: var(--font-weight-bold) !important; }
.text-uppercase { text-transform: uppercase !important; }
.text-lowercase { text-transform: lowercase !important; }
.text-capitalize { text-transform: capitalize !important; }
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================
DISPLAY & VISIBILITY
============================================ */
.d-none { display: none !important; }
.d-block { display: block !important; }
.d-inline { display: inline !important; }
.d-inline-block { display: inline-block !important; }
.d-flex { display: flex !important; }
.d-grid { display: grid !important; }
.hidden { visibility: hidden !important; }
.visible { visibility: visible !important; }
/* ============================================
BORDER UTILITIES
============================================ */
.border { border: var(--border-width-thin) solid var(--color-border-light) !important; }
.border-0 { border: none !important; }
.border-top { border-top: var(--border-width-thin) solid var(--color-border-light) !important; }
.border-bottom { border-bottom: var(--border-width-thin) solid var(--color-border-light) !important; }
.rounded { border-radius: var(--border-radius-md) !important; }
.rounded-sm { border-radius: var(--border-radius-sm) !important; }
.rounded-lg { border-radius: var(--border-radius-lg) !important; }
.rounded-full { border-radius: var(--border-radius-full) !important; }
/* ============================================
SHADOW UTILITIES
============================================ */
.shadow-none { box-shadow: none !important; }
.shadow-sm { box-shadow: var(--shadow-sm) !important; }
.shadow { box-shadow: var(--shadow-md) !important; }
.shadow-lg { box-shadow: var(--shadow-lg) !important; }
.shadow-xl { box-shadow: var(--shadow-xl) !important; }
/* ============================================
INTERACTION
============================================ */
.cursor-pointer { cursor: pointer !important; }
.cursor-not-allowed { cursor: not-allowed !important; }
.cursor-default { cursor: default !important; }
.pointer-events-none { pointer-events: none !important; }
.user-select-none { user-select: none !important; }
/* ============================================
POSITION
============================================ */
.position-relative { position: relative !important; }
.position-absolute { position: absolute !important; }
.position-fixed { position: fixed !important; }
.position-sticky { position: sticky !important; }
/* ============================================
OVERFLOW
============================================ */
.overflow-hidden { overflow: hidden !important; }
.overflow-auto { overflow: auto !important; }
.overflow-scroll { overflow: scroll !important; }
/* ============================================
WIDTH & HEIGHT
============================================ */
.w-100 { width: 100% !important; }
.w-auto { width: auto !important; }
.h-100 { height: 100% !important; }
.h-auto { height: auto !important; }
.min-h-screen { min-height: 100vh !important; }
/* ============================================
RESPONSIVE UTILITIES
============================================ */
@media (max-width: 480px) {
.mobile-hidden { display: none !important; }
.mobile-block { display: block !important; }
.mobile-text-center { text-align: center !important; }
}
@media (min-width: 481px) {
.desktop-hidden { display: none !important; }
}

View File

@ -39,14 +39,6 @@ export function calculateSimilarity(str1, str2) {
if (lower1 === lower2) return 100; if (lower1 === lower2) return 100;
if (lower1.length === 0 || lower2.length === 0) return 0; if (lower1.length === 0 || lower2.length === 0) return 0;
// Check if one string contains the other (substring match)
if (lower1.includes(lower2) || lower2.includes(lower1)) {
// Give high similarity for substring matches
const minLen = Math.min(lower1.length, lower2.length);
const maxLen = Math.max(lower1.length, lower2.length);
return Math.round((minLen / maxLen) * 100);
}
const distance = levenshteinDistance(lower1, lower2); const distance = levenshteinDistance(lower1, lower2);
const maxLength = Math.max(lower1.length, lower2.length); const maxLength = Math.max(lower1.length, lower2.length);
const similarity = ((maxLength - distance) / maxLength) * 100; const similarity = ((maxLength - distance) / maxLength) * 100;
@ -61,7 +53,7 @@ export function calculateSimilarity(str1, str2) {
* @param {number} threshold - Minimum similarity percentage (default 80) * @param {number} threshold - Minimum similarity percentage (default 80)
* @returns {Array} - Array of similar items sorted by similarity * @returns {Array} - Array of similar items sorted by similarity
*/ */
export function findSimilarItems(inputName, existingItems, threshold = 70) { export function findSimilarItems(inputName, existingItems, threshold = 80) {
const similar = []; const similar = [];
for (const item of existingItems) { for (const item of existingItems) {