Compare commits

..

11 Commits

Author SHA1 Message Date
Nico
1281c91c28 add password and display name manipulation
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 13s
Build & Deploy Costco Grocery List / deploy (push) Successful in 6s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-24 21:38:33 -08:00
Nico
889914a57f css refactor 2026-01-24 21:10:57 -08:00
Nico
fa41f12e3d revamp edit item modal 2026-01-23 22:24:05 -08:00
Nico
aea07374d9 update css 2026-01-23 22:23:37 -08:00
Nico
68976a7683 frontend refresh and update backend for frontend's new behavior 2026-01-23 00:25:11 -08:00
Nico
4139a07cd2 potential fix to user duplicates when user adds mopre than 1 of same item 2026-01-22 23:54:53 -08:00
Nico
5ce4177446 Implement settings page and feature and also add dark mode 2026-01-22 23:41:35 -08:00
Nico
0c16d22c1e cleaned up grocerylist page, created reference document for cleaning up files, and consolidated all readme docs within docs folder 2026-01-22 01:07:15 -08:00
Nico
bc7e212eea fix zone ordering 2026-01-22 00:47:26 -08:00
Nico
1300cbb0a8 merge image and confirmbuy modal and improve on them 2026-01-22 00:41:46 -08:00
Nico
ce2574c454 improve on responsiveness with the use of react.memo 2026-01-22 00:12:36 -08:00
54 changed files with 4107 additions and 967 deletions

View File

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

View File

@ -32,7 +32,8 @@ 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;
await List.setBought(req.body.id, userId); const { id, quantity } = req.body;
await List.setBought(id, userId, quantity);
res.json({ message: "Item marked bought" }); res.json({ message: "Item marked bought" });
}; };

View File

@ -1,4 +1,5 @@
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");
@ -45,11 +46,92 @@ exports.deleteUser = async (req, res) => {
} }
}; };
exports.checkIfUserExists = async (req, res) => { exports.checkIfUserExists = async (req, res) => {
const { username } = req.query; const { username } = req.query;
const users = await User.checkIfUserExists(username); const exists = await User.checkIfUserExists(username);
res.json(users); res.json(exists);
};
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

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

@ -11,15 +11,14 @@ exports.getUnboughtItems = async () => {
ENCODE(gl.item_image, 'base64') as item_image, ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type, gl.image_mime_type,
( (
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC) SELECT ARRAY_AGG(DISTINCT u.name)
FROM ( FROM (
SELECT gh.added_by, gh.added_on, SELECT DISTINCT gh.added_by
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
FROM grocery_history gh FROM grocery_history gh
WHERE gh.list_item_id = gl.id WHERE gh.list_item_id = gl.id
ORDER BY gh.added_by
) gh ) gh
JOIN users u ON gh.added_by = u.id JOIN users u ON gh.added_by = u.id
WHERE gh.rn <= gl.quantity
) as added_by_users, ) as added_by_users,
gl.modified_on as last_added_on, gl.modified_on as last_added_on,
ic.item_type, ic.item_type,
@ -35,7 +34,30 @@ exports.getUnboughtItems = async () => {
exports.getItemByName = async (itemName) => { exports.getItemByName = async (itemName) => {
const result = await pool.query( const result = await pool.query(
"SELECT * FROM grocery_list WHERE item_name ILIKE $1", `SELECT
gl.id,
LOWER(gl.item_name) AS item_name,
gl.quantity,
gl.bought,
ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type,
(
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]
); );
@ -88,11 +110,31 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
}; };
exports.setBought = async (id, userId) => { exports.setBought = async (id, userId, quantityBought) => {
await pool.query( // Get current item
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1", const item = await pool.query(
"SELECT quantity FROM grocery_list WHERE id = $1",
[id] [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(
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[id]
);
} else {
// Reduce quantity if partial purchase
await pool.query(
"UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, id]
);
}
}; };
@ -107,7 +149,7 @@ 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 item_name `SELECT DISTINCT LOWER(item_name) as item_name
FROM grocery_list FROM grocery_list
WHERE item_name ILIKE $1 WHERE item_name ILIKE $1
LIMIT 10`, LIMIT 10`,
@ -126,15 +168,14 @@ exports.getRecentlyBoughtItems = async () => {
ENCODE(gl.item_image, 'base64') as item_image, ENCODE(gl.item_image, 'base64') as item_image,
gl.image_mime_type, gl.image_mime_type,
( (
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC) SELECT ARRAY_AGG(DISTINCT u.name)
FROM ( FROM (
SELECT gh.added_by, gh.added_on, SELECT DISTINCT gh.added_by
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
FROM grocery_history gh FROM grocery_history gh
WHERE gh.list_item_id = gl.id WHERE gh.list_item_id = gl.id
ORDER BY gh.added_by
) gh ) gh
JOIN users u ON gh.added_by = u.id JOIN users u ON gh.added_by = u.id
WHERE gh.rn <= gl.quantity
) as added_by_users, ) 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

View File

@ -24,10 +24,49 @@ exports.createUser = async (username, hashedPassword, name) => {
exports.getAllUsers = async () => { exports.getAllUsers = async () => {
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC"); const result = await pool.query("SELECT id, username, name, role, display_name 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

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

473
docs/code-cleanup-guide.md Normal file
View File

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

415
docs/settings-dark-mode.md Normal file
View File

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

@ -2,11 +2,13 @@ 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 { 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,35 +20,38 @@ function App() {
return ( return (
<ConfigProvider> <ConfigProvider>
<AuthProvider> <AuthProvider>
<BrowserRouter> <SettingsProvider>
<Routes> <BrowserRouter>
<Routes>
{/* Public route */} {/* Public route */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
{/* Private routes with layout */}
<Route
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<GroceryList />} />
{/* Private routes with layout */}
<Route <Route
path="/admin"
element={ element={
<RoleGuard allowed={[ROLES.ADMIN]}> <PrivateRoute>
<AdminPanel /> <AppLayout />
</RoleGuard> </PrivateRoute>
} }
/> >
</Route> <Route path="/" element={<GroceryList />} />
<Route path="/settings" element={<Settings />} />
</Routes> <Route
</BrowserRouter> path="/admin"
element={
<RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
</Route>
</Routes>
</BrowserRouter>
</SettingsProvider>
</AuthProvider> </AuthProvider>
</ConfigProvider> </ConfigProvider>
); );

View File

@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
classification classification
}); });
}; };
export const markBought = (id) => api.post("/list/mark-bought", { id }); export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
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,3 +4,15 @@ 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="user-card"> <div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
<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,7 +10,8 @@ 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="role-select" className="form-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,12 +1,11 @@
import { useRef, useState } from "react"; import { memo, 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";
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) { function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
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 });
@ -57,13 +56,14 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
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(quantity); onClick(currentItem.id, quantity);
} }
setShowConfirmBuyModal(false); setShowConfirmBuyModal(false);
}; };
@ -72,10 +72,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
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) {
setShowModal(true); // Open buy modal which now shows the image
setCurrentItem(item);
setShowConfirmBuyModal(true);
} else { } else {
setShowAddImageModal(true); setShowAddImageModal(true);
} }
@ -114,7 +120,7 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
return ( return (
<> <>
<li <li
className="glist-li" className={`glist-li ${compact ? 'compact' : ''}`}
onClick={handleItemClick} onClick={handleItemClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
@ -150,14 +156,6 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
</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}
@ -168,11 +166,32 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
{showConfirmBuyModal && ( {showConfirmBuyModal && (
<ConfirmBuyModal <ConfirmBuyModal
item={item} item={currentItem}
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,3 +1,4 @@
import "../../styles/components/SuggestionList.css";
interface Props { interface Props {
suggestions: string[]; suggestions: string[];
@ -8,27 +9,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null; if (!suggestions.length) return null;
return ( return (
<ul <ul className="suggestion-list">
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)}
style={{ className="suggestion-item"
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
> >
{s} {s}
</li> </li>

View File

@ -11,6 +11,7 @@ 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="add-image-modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<div className="add-image-modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Add Image</h2> <h2 className="modal-title">Add Image</h2>
<p className="add-image-subtitle"> <p className="text-center mb-4" style={{ color: 'var(--color-text-secondary)', fontSize: '0.95em' }}>
There's no image for <strong>"{itemName}"</strong> yet. Add a new image? There's no image for <strong className="text-primary">"{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="add-image-actions"> <div className="modal-actions">
<button onClick={onClose} className="add-image-cancel"> <button onClick={onClose} className="btn btn-outline flex-1">
Cancel Cancel
</button> </button>
{imagePreview && ( {imagePreview && (
<button onClick={handleConfirm} className="add-image-confirm"> <button onClick={handleConfirm} className="btn btn-success flex-1">
Add Image Add Image
</button> </button>
)} )}

View File

@ -0,0 +1,47 @@
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,10 +1,26 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import "../../styles/ConfirmBuyModal.css"; import "../../styles/ConfirmBuyModal.css";
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) { export default function ConfirmBuyModal({
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);
@ -21,14 +37,61 @@ export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
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()}>
<h2>Mark as Bought</h2> <div className="confirm-buy-header">
<p className="confirm-buy-item-name">"{item.item_name}"</p> {item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
<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,14 +1,16 @@
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 ClassificationSection from "../forms/ClassificationSection"; import AddImageModal from "./AddImageModal";
export default function EditItemModal({ item, onSave, onCancel }) { export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
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(() => {
@ -58,44 +60,131 @@ export default function EditItemModal({ item, onSave, onCancel }) {
} }
}; };
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>
<div className="edit-modal-field"> {/* Item Name - no label */}
<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>
<div className="edit-modal-field"> {/* Quantity Control - like AddItemForm */}
<label>Quantity</label> <div className="edit-modal-quantity-control">
<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}
onChange={(e) => setQuantity(parseInt(e.target.value))} readOnly
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" />
<ClassificationSection {/* Inline Classification Fields */}
itemType={itemType} <div className="edit-modal-inline-field">
itemGroup={itemGroup} <label>Type</label>
zone={zone} <select
onItemTypeChange={handleItemTypeChange} value={itemType}
onItemGroupChange={setItemGroup} onChange={(e) => handleItemTypeChange(e.target.value)}
onZoneChange={setZone} className="edit-modal-select"
fieldClass="edit-modal-field" >
selectClass="edit-modal-select" <option value="">-- Select Type --</option>
/> {Object.values(ITEM_TYPES).map((type) => (
<option key={type} value={type}>
{getItemTypeLabel(type)}
</option>
))}
</select>
</div>
{itemType && (
<div className="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
@ -114,6 +203,14 @@ export default function EditItemModal({ item, onSave, onCancel }) {
</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="image-modal-caption">{itemName}</p> <p className="text-center mt-3 text-lg font-semibold">{itemName}</p>
</div> </div>
</div> </div>
); );

View File

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

View File

@ -0,0 +1,122 @@
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,9 +84,14 @@ 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: var(--spacing-md); padding: 0;
-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 {
@ -94,11 +99,6 @@ 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,6 +3,7 @@ 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

@ -2,6 +2,7 @@ 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([]);
@ -22,16 +23,18 @@ export default function AdminPanel() {
} }
return ( return (
<div style={{ padding: "2rem" }}> <div className="p-4" style={{ minHeight: '100vh' }}>
<h1>Admin Panel</h1> <div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
<div style={{ marginTop: "2rem" }}> <h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
{users.map((user) => ( <div className="mt-4">
<UserRoleCard {users.map((user) => (
key={user.id} <UserRoleCard
user={user} key={user.id}
onRoleChange={changeRole} user={user}
/> onRoleChange={changeRole}
))} />
))}
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,25 +1,40 @@
import { useContext, useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list"; import {
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(10); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [sortedItems, setSortedItems] = useState([]); const [sortMode, setSortMode] = useState(settings.defaultSortMode);
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);
@ -30,7 +45,13 @@ 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();
@ -39,6 +60,7 @@ export default function GroceryList() {
setLoading(false); setLoading(false);
}; };
const loadRecentlyBought = async () => { const loadRecentlyBought = async () => {
try { try {
const res = await getRecentlyBought(); const res = await getRecentlyBought();
@ -49,13 +71,24 @@ export default function GroceryList() {
} }
}; };
useEffect(() => { useEffect(() => {
loadItems(); loadItems();
loadRecentlyBought(); loadRecentlyBought();
}, []); }, []);
useEffect(() => {
let sorted = [...items]; // === Zone Collapse Handler ===
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));
@ -64,11 +97,22 @@ 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.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 -1;
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name); if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
// Sort by item_type // Sort by ZONE_FLOW order
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;
@ -76,18 +120,16 @@ 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);
}); });
} }
setSortedItems(sorted); return sorted;
}, [items, sortMode]); }, [items, sortMode]);
// === Suggestion Handler ===
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
if (!text.trim()) { if (!text.trim()) {
setSuggestions([]); setSuggestions([]);
@ -95,34 +137,27 @@ 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 {
let suggestions = await getSuggestions(text); const response = await getSuggestions(text);
suggestions = suggestions.data.map(s => s.item_name); const suggestionList = response.data.map(s => s.item_name);
setSuggestions(suggestions); setSuggestions(suggestionList);
// 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);
@ -131,29 +166,27 @@ 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;
} }
// Only check for similar items if exact item doesn't exist setItems(prevItems => {
const allItems = [...items, ...recentlyBoughtItems]; const allItems = [...prevItems, ...recentlyBoughtItems];
const similar = findSimilarItems(itemName, allItems, 70); const similar = findSimilarItems(itemName, allItems, 70);
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; }
}
// Continue with normal flow for new items processItemAddition(itemName, quantity);
await processItemAddition(itemName, quantity); return prevItems;
}; });
}, [recentlyBoughtItems]);
const processItemAddition = async (itemName, quantity) => {
// Check if item exists in database (case-insensitive) const processItemAddition = useCallback(async (itemName, quantity) => {
let existingItem = null; let existingItem = null;
try { try {
const response = await getItemByName(itemName); const response = await getItemByName(itemName);
@ -162,126 +195,205 @@ export default function GroceryList() {
existingItem = null; existingItem = null;
} }
if (existingItem && existingItem.bought === false) { if (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;
await addItem(itemName, newQuantity, null); // Show modal instead of window.confirm
setSuggestions([]); setConfirmAddExistingData({
setButtonText("Add Item"); itemName,
loadItems(); currentQuantity,
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 {
// Add item to grocery_list with image const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); let newItem = addResponse.data;
// 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;
await updateItemWithClassification(itemId, undefined, undefined, classification); const updateResponse = 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 {
// Add item without image or classification const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
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 {
await updateItemImage(id, itemName, quantity, imageFile); const response = 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,
@ -293,27 +405,42 @@ 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 {
await updateItemWithClassification(id, itemName, quantity, classification); const response = await updateItemWithClassification(id, itemName, quantity, classification);
setShowEditModal(false); setShowEditModal(false);
setEditingItem(null); setEditingItem(null);
loadItems();
loadRecentlyBought(); const updatedItem = response.data;
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; // Re-throw to let modal handle it throw error;
} }
}; }, []);
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 => {
@ -326,8 +453,10 @@ 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">
@ -346,43 +475,60 @@ 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 => {
<div key={zone} className="glist-classification-group"> const isCollapsed = collapsedZones[zone];
<h3 className="glist-classification-header"> const itemCount = grouped[zone].length;
{zone === 'unclassified' ? 'Unclassified' : zone} return (
</h3> <div key={zone} className="glist-classification-group">
<ul className="glist-ul"> <h3
{grouped[zone].map((item) => ( className="glist-classification-header clickable"
<GroceryListItem onClick={() => toggleZoneCollapse(zone)}
key={item.id} >
item={item} <span>
onClick={(quantity) => {zone === 'unclassified' ? 'Unclassified' : zone}
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity) <span className="glist-zone-count"> ({itemCount})</span>
} </span>
onImageAdded={ <span className="glist-zone-indicator">
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null {isCollapsed ? "▼" : "▲"}
} </span>
onLongPress={ </h3>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null {!isCollapsed && (
} <ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
/> {grouped[zone].map((item) => (
))} <GroceryListItem
</ul> key={item.id}
</div> item={item}
)); allItems={sortedItems}
compact={settings.compactView}
onClick={(id, quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
)}
</div>
);
});
})() })()
) : ( ) : (
// Regular flat list view <ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
<ul className="glist-ul">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
onClick={(quantity) => allItems={sortedItems}
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity) compact={settings.compactView}
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
@ -395,33 +541,48 @@ export default function GroceryList() {
</ul> </ul>
)} )}
{recentlyBoughtItems.length > 0 && ( {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
<> <>
<h2 className="glist-section-title">Recently Bought (24HR)</h2> <h2
<ul className="glist-ul"> className="glist-section-title clickable"
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => ( onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
<GroceryListItem >
key={item.id} <span>Recently Bought (24HR)</span>
item={item} <span className="glist-section-indicator">
onClick={null} {recentlyBoughtCollapsed ? "▼" : "▲"}
onImageAdded={ </span>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null </h2>
}
onLongPress={ {!recentlyBoughtCollapsed && (
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null <>
} <ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
/> {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
))} <GroceryListItem
</ul> key={item.id}
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && ( item={item}
<div style={{ textAlign: 'center', marginTop: '1rem' }}> allItems={recentlyBoughtItems}
<button compact={settings.compactView}
className="glist-show-more-btn" onClick={null}
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)} onImageAdded={
> [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining) }
</button> onLongPress={
</div> [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
}
/>
))}
</ul>
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<button
className="glist-show-more-btn"
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
>
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
</button>
</div>
)}
</>
)} )}
</> </>
)} )}
@ -458,6 +619,17 @@ 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="login-wrapper"> <div className="flex-center" style={{ minHeight: '100vh', padding: '1em', background: '#f8f9fa' }}>
<div className="login-box"> <div className="card card-elevated" style={{ width: '100%', maxWidth: '360px' }}>
<h1 className="login-title">Login</h1> <h1 className="text-center text-2xl mb-3">Login</h1>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<form onSubmit={submit}> <form onSubmit={submit}>
<FormInput <FormInput
type="text" type="text"
className="login-input" className="form-input my-2"
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="login-input" className="form-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="login-button">Login</button> <button type="submit" className="btn btn-primary btn-block mt-2">Login</button>
</form> </form>
<p className="login-register"> <p className="text-center mt-3">
Need an account? <Link to="/register">Register here</Link> Need an account? <Link to="/register" className="text-primary">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>Register</h1> <h1 className="text-center mb-4 text-2xl font-bold">Register</h1>
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<ErrorMessage message={success} type="success" /> <ErrorMessage message={success} type="success" />
@ -67,6 +67,7 @@ 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
@ -74,6 +75,7 @@ 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
@ -81,6 +83,7 @@ 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
@ -88,18 +91,19 @@ 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"> <button disabled={error !== ""} type="submit" className="btn btn-primary btn-block mt-2">
Create Account Create Account
</button> </button>
</form> </form>
<p className="register-link"> <p className="text-center mt-3">
Already have an account? <Link to="/login">Login here</Link> Already have an account? <Link to="/login" className="text-primary font-semibold">Login here</Link>
</p> </p>
</div> </div>
); );

View File

@ -0,0 +1,426 @@
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,44 +1,4 @@
.add-image-modal-overlay { /* AddImageModal - custom styles for unique components */
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;
@ -48,32 +8,33 @@
} }
.add-image-option-btn { .add-image-option-btn {
padding: 1.2em; padding: var(--spacing-lg);
border: 2px solid #ddd; border: var(--border-width-medium) solid var(--color-border-light);
border-radius: 8px; border-radius: var(--border-radius-lg);
background: white; background: var(--color-bg-surface);
font-size: 1.1em; font-size: var(--font-size-lg);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5em; gap: var(--spacing-sm);
color: var(--color-text-primary);
} }
.add-image-option-btn:hover { .add-image-option-btn:hover {
border-color: #007bff; border-color: var(--color-primary);
background: #f8f9fa; background: var(--color-bg-hover);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2); box-shadow: var(--shadow-md);
} }
.add-image-option-btn.camera { .add-image-option-btn.camera {
color: #007bff; color: var(--color-primary);
} }
.add-image-option-btn.gallery { .add-image-option-btn.gallery {
color: #28a745; color: var(--color-success);
} }
.add-image-preview-container { .add-image-preview-container {
@ -86,9 +47,10 @@
position: relative; position: relative;
width: 250px; width: 250px;
height: 250px; height: 250px;
border: 2px solid #ddd; border: var(--border-width-medium) solid var(--color-border-light);
border-radius: 8px; border-radius: var(--border-radius-lg);
overflow: hidden; overflow: hidden;
background: var(--color-gray-100);
} }
.add-image-preview img { .add-image-preview img {
@ -119,58 +81,3 @@
.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,68 +4,128 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.6); background: var(--modal-backdrop-bg);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1000; z-index: var(--z-modal);
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
} }
.confirm-buy-modal { .confirm-buy-modal {
background: white; background: var(--modal-bg);
padding: 2em; padding: var(--spacing-md);
border-radius: 12px; border-radius: var(--border-radius-xl);
max-width: 450px; max-width: 450px;
width: 90%; width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-xl);
animation: slideUp 0.3s ease-out; animation: slideUp 0.3s ease-out;
} }
.confirm-buy-modal h2 { .confirm-buy-header {
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 0 1.5em 0; margin: 0;
font-size: 1.1em; font-size: 1.2em;
color: #007bff; color: var(--color-primary);
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: 2em 0; margin: 0.8em 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: 1em; gap: 0.8em;
} }
.confirm-buy-counter-btn { .confirm-buy-counter-btn {
width: 50px; width: 45px;
height: 50px; height: 45px;
border: 2px solid #007bff; border: var(--border-width-medium) solid var(--color-primary);
border-radius: 8px; border-radius: var(--border-radius-lg);
background: white; background: var(--color-bg-surface);
color: #007bff; color: var(--color-primary);
font-size: 1.8em; font-size: 1.6em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -74,67 +134,68 @@
} }
.confirm-buy-counter-btn:hover:not(:disabled) { .confirm-buy-counter-btn:hover:not(:disabled) {
background: #007bff; background: var(--color-primary);
color: white; color: var(--color-text-inverse);
} }
.confirm-buy-counter-btn:disabled { .confirm-buy-counter-btn:disabled {
border-color: #ccc; border-color: var(--color-border-medium);
color: #ccc; color: var(--color-text-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
.confirm-buy-counter-display { .confirm-buy-counter-display {
width: 80px; width: 70px;
height: 50px; height: 45px;
border: 2px solid #ddd; border: var(--border-width-medium) solid var(--color-border-light);
border-radius: 8px; border-radius: var(--border-radius-lg);
text-align: center; text-align: center;
font-size: 1.5em; font-size: 1.4em;
font-weight: bold; font-weight: bold;
color: #333; color: var(--color-text-primary);
background: #f8f9fa; background: var(--color-gray-100);
} }
.confirm-buy-counter-display:focus { .confirm-buy-counter-display:focus {
outline: none; outline: none;
border-color: #007bff; border-color: var(--color-primary);
} }
.confirm-buy-actions { .confirm-buy-actions {
display: flex; display: flex;
gap: 1em; gap: 0.6em;
margin-top: 2em; margin-top: 1em;
} }
.confirm-buy-cancel, .confirm-buy-cancel,
.confirm-buy-confirm { .confirm-buy-confirm {
flex: 1; flex: 1;
padding: 0.9em; padding: 0.75em 0.5em;
border: none; border: none;
border-radius: 8px; border-radius: var(--border-radius-lg);
font-size: 1em; font-size: 0.95em;
font-weight: 500; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
white-space: nowrap;
} }
.confirm-buy-cancel { .confirm-buy-cancel {
background: #f0f0f0; background: var(--color-gray-200);
color: #333; color: var(--color-text-primary);
} }
.confirm-buy-cancel:hover { .confirm-buy-cancel:hover {
background: #e0e0e0; background: var(--color-gray-300);
} }
.confirm-buy-confirm { .confirm-buy-confirm {
background: #28a745; background: var(--color-success);
color: white; color: var(--color-text-inverse);
} }
.confirm-buy-confirm:hover { .confirm-buy-confirm:hover {
background: #218838; background: var(--color-success-hover);
} }
@keyframes fadeIn { @keyframes fadeIn {
@ -156,3 +217,88 @@
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,3 +1,5 @@
/* ImageModal - specialized full-screen image viewer */
.image-modal-overlay { .image-modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -44,30 +46,6 @@
} }
} }
.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;
@ -76,14 +54,6 @@
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;
@ -92,8 +62,5 @@
.image-modal-img { .image-modal-img {
max-height: 60vh; max-height: 60vh;
} }
.image-modal-caption {
font-size: 1rem;
}
} }

View File

@ -1,115 +1,24 @@
.similar-item-modal-overlay { /* SimilarItemModal - custom styles */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.similar-item-modal { .similar-item-suggested {
background: white; color: var(--color-success);
padding: 2em; font-weight: 600;
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; font-size: 1.1em;
color: #333;
text-align: center;
} }
.similar-item-question strong { .similar-item-original {
color: #007bff; color: var(--color-primary);
font-weight: 600;
font-size: 1.1em;
} }
.similar-item-clarification { .similar-modal-actions {
margin: 0 0 2em 0;
font-size: 0.9em;
color: #666;
text-align: center;
font-style: italic;
}
.similar-item-actions {
display: flex; display: flex;
gap: 0.8em; flex-direction: column;
margin-top: 1.5em; gap: var(--spacing-sm);
} }
.similar-item-cancel, .similar-modal-actions .btn {
.similar-item-no, width: 100%;
.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,40 +1,13 @@
.user-card { /* UserRoleCard - custom styles only */
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: 0.25rem; gap: var(--spacing-xs);
} }
.user-username { .user-username {
color: #666; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: var(--font-size-sm);
} }
.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,6 +29,12 @@
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 {
@ -107,6 +113,8 @@
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 */
} }
@ -133,7 +141,8 @@
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);
cursor: pointer; flex: 1;
min-width: 120px
transition: var(--transition-base); transition: var(--transition-base);
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
} }
@ -150,12 +159,13 @@
.add-item-form-submit.disabled, .add-item-form-submit.disabled,
.add-item-form-submit:disabled { .add-item-form-submit:disabled {
background: var(--color-bg-disabled); background: var(--color-gray-400);
color: var(--color-text-disabled); color: var(--color-gray-600);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 1;
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: rgba(0, 0, 0, 0.6); background: var(--modal-backdrop-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: var(--z-modal);
padding: 1em; padding: var(--spacing-md);
} }
.add-item-details-modal { .add-item-details-modal {
background: white; background: var(--modal-bg);
border-radius: 12px; border-radius: var(--border-radius-xl);
padding: 1.5em; padding: var(--spacing-xl);
max-width: 500px; max-width: 500px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-xl);
} }
.add-item-details-title { .add-item-details-title {
font-size: 1.4em; font-size: var(--font-size-xl);
margin: 0 0 0.3em 0; margin: 0 0 var(--spacing-xs) 0;
text-align: center; text-align: center;
color: #333; color: var(--color-text-primary);
} }
.add-item-details-subtitle { .add-item-details-subtitle {
text-align: center; text-align: center;
color: #666; color: var(--color-text-secondary);
margin: 0 0 1.5em 0; margin: 0 0 var(--spacing-xl) 0;
font-size: 0.9em; font-size: var(--font-size-sm);
} }
.add-item-details-section { .add-item-details-section {
margin-bottom: 1.5em; margin-bottom: var(--spacing-xl);
padding-bottom: 1.5em; padding-bottom: var(--spacing-xl);
border-bottom: 1px solid #e0e0e0; border-bottom: var(--border-width-thin) solid var(--color-border-light);
} }
.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: 1.1em; font-size: var(--font-size-lg);
margin: 0 0 1em 0; margin: 0 0 var(--spacing-md) 0;
color: #555; color: var(--color-text-secondary);
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: 0.8em; padding: var(--button-padding-y) var(--button-padding-x);
font-size: 0.95em; font-size: 0.95em;
border: 2px solid #007bff; border: var(--border-width-medium) solid var(--color-primary);
background: white; background: var(--color-bg-surface);
color: #007bff; color: var(--color-primary);
border-radius: 8px; border-radius: var(--border-radius-lg);
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: var(--button-font-weight);
transition: all 0.2s; transition: var(--transition-base);
} }
.add-item-details-image-btn:hover { .add-item-details-image-btn:hover {
background: #007bff; background: var(--color-primary);
color: white; color: var(--color-text-inverse);
} }
.add-item-details-image-preview { .add-item-details-image-preview {
position: relative; position: relative;
border-radius: 8px; border-radius: var(--border-radius-lg);
overflow: hidden; overflow: hidden;
border: 2px solid #e0e0e0; border: var(--border-width-medium) solid var(--color-border-light);
} }
.add-item-details-image-preview img { .add-item-details-image-preview img {

View File

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

View File

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

@ -0,0 +1,42 @@
/* 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

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

View File

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

View File

@ -1,37 +1,4 @@
.login-wrapper { /* Login page - custom password toggle only */
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;
@ -40,7 +7,7 @@
margin: 0.4em 0; margin: 0.4em 0;
} }
.login-password-wrapper .login-input { .login-password-wrapper .form-input {
flex: 1; flex: 1;
width: auto; width: auto;
margin: 0; margin: 0;
@ -67,38 +34,3 @@
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,18 +1,12 @@
/* 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: #ffffff; background: var(--color-bg-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-lg);
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 {
@ -21,64 +15,3 @@
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

@ -0,0 +1,213 @@
/* 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,23 +189,94 @@
--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) {
/* Uncomment to enable dark mode /* Auto mode will use data-theme attribute set by JS */
: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

@ -0,0 +1,570 @@
/**
* 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; }
}