Compare commits
11 Commits
8d5b2d3ea3
...
1281c91c28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1281c91c28 | ||
|
|
889914a57f | ||
|
|
fa41f12e3d | ||
|
|
aea07374d9 | ||
|
|
68976a7683 | ||
|
|
4139a07cd2 | ||
|
|
5ce4177446 | ||
|
|
0c16d22c1e | ||
|
|
bc7e212eea | ||
|
|
1300cbb0a8 | ||
|
|
ce2574c454 |
198
ACCOUNT_MANAGEMENT_IMPLEMENTATION.md
Normal file
198
ACCOUNT_MANAGEMENT_IMPLEMENTATION.md
Normal 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
|
||||
@ -32,7 +32,8 @@ exports.addItem = async (req, res) => {
|
||||
|
||||
exports.markBought = async (req, res) => {
|
||||
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" });
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const User = require("../models/user.model");
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
exports.test = async (req, res) => {
|
||||
console.log("User route is working");
|
||||
@ -45,11 +46,92 @@ exports.deleteUser = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
exports.checkIfUserExists = async (req, res) => {
|
||||
const { username } = req.query;
|
||||
const users = await User.checkIfUserExists(username);
|
||||
res.json(users);
|
||||
const exists = await User.checkIfUserExists(username);
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
10
backend/migrations/add_display_name_column.sql
Normal file
10
backend/migrations/add_display_name_column.sql
Normal 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;
|
||||
@ -11,15 +11,14 @@ exports.getUnboughtItems = async () => {
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
gl.image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT gh.added_by, gh.added_on,
|
||||
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
||||
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
|
||||
WHERE gh.rn <= gl.quantity
|
||||
) as added_by_users,
|
||||
gl.modified_on as last_added_on,
|
||||
ic.item_type,
|
||||
@ -35,7 +34,30 @@ exports.getUnboughtItems = async () => {
|
||||
|
||||
exports.getItemByName = async (itemName) => {
|
||||
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]
|
||||
);
|
||||
|
||||
@ -88,11 +110,31 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
|
||||
};
|
||||
|
||||
|
||||
exports.setBought = async (id, userId) => {
|
||||
exports.setBought = async (id, userId, quantityBought) => {
|
||||
// Get current item
|
||||
const item = await pool.query(
|
||||
"SELECT quantity FROM grocery_list WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!item.rows[0]) return;
|
||||
|
||||
const currentQuantity = item.rows[0].quantity;
|
||||
const remainingQuantity = currentQuantity - quantityBought;
|
||||
|
||||
if (remainingQuantity <= 0) {
|
||||
// Mark as bought if all quantity is purchased
|
||||
await pool.query(
|
||||
"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) => {
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT item_name
|
||||
`SELECT DISTINCT LOWER(item_name) as item_name
|
||||
FROM grocery_list
|
||||
WHERE item_name ILIKE $1
|
||||
LIMIT 10`,
|
||||
@ -126,15 +168,14 @@ exports.getRecentlyBoughtItems = async () => {
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
gl.image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT gh.added_by, gh.added_on,
|
||||
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
||||
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
|
||||
WHERE gh.rn <= gl.quantity
|
||||
) as added_by_users,
|
||||
gl.modified_on as last_added_on
|
||||
FROM grocery_list gl
|
||||
|
||||
@ -24,10 +24,49 @@ exports.createUser = async (username, hashedPassword, name) => {
|
||||
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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) => {
|
||||
const result = await pool.query(
|
||||
|
||||
@ -7,4 +7,9 @@ const { ROLES } = require("../models/user.model");
|
||||
router.get("/exists", usersController.checkIfUserExists);
|
||||
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;
|
||||
|
||||
473
docs/code-cleanup-guide.md
Normal file
473
docs/code-cleanup-guide.md
Normal 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
415
docs/settings-dark-mode.md
Normal 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
|
||||
@ -2,11 +2,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { ROLES } from "./constants/roles";
|
||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||
|
||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||
import GroceryList from "./pages/GroceryList.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
import Register from "./pages/Register.jsx";
|
||||
import Settings from "./pages/Settings.jsx";
|
||||
|
||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||
@ -18,6 +20,7 @@ function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<SettingsProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
|
||||
@ -34,6 +37,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<GroceryList />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
@ -47,6 +51,7 @@ function App() {
|
||||
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SettingsProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
|
||||
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 getRecentlyBought = () => api.get("/list/recently-bought");
|
||||
|
||||
|
||||
@ -4,3 +4,15 @@ export const getAllUsers = () => api.get("/admin/users");
|
||||
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
|
||||
export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
|
||||
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 });
|
||||
};
|
||||
@ -2,7 +2,7 @@ import { ROLES } from "../../constants/roles";
|
||||
|
||||
export default function UserRoleCard({ user, onRoleChange }) {
|
||||
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">
|
||||
<strong>{user.name}</strong>
|
||||
<span className="user-username">@{user.username}</span>
|
||||
@ -10,7 +10,8 @@ export default function UserRoleCard({ user, onRoleChange }) {
|
||||
<select
|
||||
onChange={(e) => onRoleChange(user.id, e.target.value)}
|
||||
value={user.role}
|
||||
className="role-select"
|
||||
className="form-select"
|
||||
style={{ fontSize: 'var(--font-size-sm)' }}
|
||||
>
|
||||
<option value={ROLES.VIEWER}>Viewer</option>
|
||||
<option value={ROLES.EDITOR}>Editor</option>
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { memo, useRef, useState } from "react";
|
||||
import AddImageModal from "../modals/AddImageModal";
|
||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||
import ImageModal from "../modals/ImageModal";
|
||||
|
||||
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
|
||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||
const [currentItem, setCurrentItem] = useState(item);
|
||||
|
||||
const longPressTimer = useRef(null);
|
||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||
@ -57,13 +56,14 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (onClick) {
|
||||
setCurrentItem(item);
|
||||
setShowConfirmBuyModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBuy = (quantity) => {
|
||||
if (onClick) {
|
||||
onClick(quantity);
|
||||
onClick(currentItem.id, quantity);
|
||||
}
|
||||
setShowConfirmBuyModal(false);
|
||||
};
|
||||
@ -72,10 +72,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
||||
setShowConfirmBuyModal(false);
|
||||
};
|
||||
|
||||
const handleNavigate = (newItem) => {
|
||||
setCurrentItem(newItem);
|
||||
};
|
||||
|
||||
const handleImageClick = (e) => {
|
||||
e.stopPropagation(); // Prevent triggering the bought action
|
||||
if (item.item_image) {
|
||||
setShowModal(true);
|
||||
// Open buy modal which now shows the image
|
||||
setCurrentItem(item);
|
||||
setShowConfirmBuyModal(true);
|
||||
} else {
|
||||
setShowAddImageModal(true);
|
||||
}
|
||||
@ -114,7 +120,7 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className="glist-li"
|
||||
className={`glist-li ${compact ? 'compact' : ''}`}
|
||||
onClick={handleItemClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
@ -150,14 +156,6 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{showModal && (
|
||||
<ImageModal
|
||||
imageUrl={imageUrl}
|
||||
itemName={item.item_name}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddImageModal && (
|
||||
<AddImageModal
|
||||
itemName={item.item_name}
|
||||
@ -168,11 +166,32 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
||||
|
||||
{showConfirmBuyModal && (
|
||||
<ConfirmBuyModal
|
||||
item={item}
|
||||
item={currentItem}
|
||||
onConfirm={handleConfirmBuy}
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import "../../styles/components/SuggestionList.css";
|
||||
|
||||
interface Props {
|
||||
suggestions: string[];
|
||||
@ -8,27 +9,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
||||
if (!suggestions.length) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="suggestion-list"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #ccc",
|
||||
maxHeight: "150px",
|
||||
overflowY: "auto",
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<ul className="suggestion-list">
|
||||
{suggestions.map((s) => (
|
||||
<li
|
||||
key={s}
|
||||
onClick={() => onSelect(s)}
|
||||
style={{
|
||||
padding: "0.5em",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
className="suggestion-item"
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
|
||||
@ -11,6 +11,7 @@ export default function Navbar() {
|
||||
<nav className="navbar">
|
||||
<div className="navbar-links">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/settings">Settings</Link>
|
||||
|
||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||
</div>
|
||||
|
||||
@ -39,11 +39,11 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="add-image-modal-overlay" onClick={onClose}>
|
||||
<div className="add-image-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Add Image</h2>
|
||||
<p className="add-image-subtitle">
|
||||
There's no image for <strong>"{itemName}"</strong> yet. Add a new image?
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Add Image</h2>
|
||||
<p className="text-center mb-4" style={{ color: 'var(--color-text-secondary)', fontSize: '0.95em' }}>
|
||||
There's no image for <strong className="text-primary">"{itemName}"</strong> yet. Add a new image?
|
||||
</p>
|
||||
|
||||
{!imagePreview ? (
|
||||
@ -83,12 +83,12 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<div className="add-image-actions">
|
||||
<button onClick={onClose} className="add-image-cancel">
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose} className="btn btn-outline flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
{imagePreview && (
|
||||
<button onClick={handleConfirm} className="add-image-confirm">
|
||||
<button onClick={handleConfirm} className="btn btn-success flex-1">
|
||||
Add Image
|
||||
</button>
|
||||
)}
|
||||
|
||||
47
frontend/src/components/modals/ConfirmAddExistingModal.jsx
Normal file
47
frontend/src/components/modals/ConfirmAddExistingModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 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 = () => {
|
||||
if (quantity < maxQuantity) {
|
||||
setQuantity(prev => prev + 1);
|
||||
@ -21,14 +37,61 @@ export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
||||
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 (
|
||||
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
||||
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Mark as Bought</h2>
|
||||
<p className="confirm-buy-item-name">"{item.item_name}"</p>
|
||||
<div className="confirm-buy-header">
|
||||
{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">
|
||||
<p className="confirm-buy-label">Quantity to buy:</p>
|
||||
<div className="confirm-buy-counter">
|
||||
<button
|
||||
onClick={handleDecrement}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||
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 [quantity, setQuantity] = useState(item.quantity || 1);
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemGroup, setItemGroup] = useState("");
|
||||
const [zone, setZone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
|
||||
// Load existing classification
|
||||
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 (
|
||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="edit-modal-title">Edit Item</h2>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label>Item Name</label>
|
||||
{/* Item Name - no label */}
|
||||
<input
|
||||
type="text"
|
||||
value={itemName}
|
||||
onChange={(e) => setItemName(e.target.value)}
|
||||
className="edit-modal-input"
|
||||
placeholder="Item name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label>Quantity</label>
|
||||
{/* Quantity Control - like AddItemForm */}
|
||||
<div className="edit-modal-quantity-control">
|
||||
<button
|
||||
type="button"
|
||||
className="quantity-btn quantity-btn-minus"
|
||||
onClick={decrementQuantity}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="edit-modal-quantity-input"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(parseInt(e.target.value))}
|
||||
className="edit-modal-input"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="quantity-btn quantity-btn-plus"
|
||||
onClick={incrementQuantity}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-divider" />
|
||||
|
||||
<ClassificationSection
|
||||
itemType={itemType}
|
||||
itemGroup={itemGroup}
|
||||
zone={zone}
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
fieldClass="edit-modal-field"
|
||||
selectClass="edit-modal-select"
|
||||
/>
|
||||
{/* Inline Classification Fields */}
|
||||
<div className="edit-modal-inline-field">
|
||||
<label>Type</label>
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={(e) => handleItemTypeChange(e.target.value)}
|
||||
className="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">
|
||||
<button
|
||||
@ -114,6 +203,14 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showImageModal && (
|
||||
<AddImageModal
|
||||
itemName={itemName}
|
||||
onClose={() => setShowImageModal(false)}
|
||||
onAddImage={handleImageUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
|
||||
<div className="image-modal-overlay" onClick={onClose}>
|
||||
<div className="image-modal-content" onClick={onClose}>
|
||||
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
||||
<p className="image-modal-caption">{itemName}</p>
|
||||
<p className="text-center mt-3 text-lg font-semibold">{itemName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,25 +2,22 @@ import "../../styles/SimilarItemModal.css";
|
||||
|
||||
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
||||
return (
|
||||
<div className="similar-item-modal-overlay" onClick={onCancel}>
|
||||
<div className="similar-item-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Similar Item Found</h2>
|
||||
<p className="similar-item-question">
|
||||
Do you mean <strong>"{suggestedName}"</strong>?
|
||||
</p>
|
||||
<p className="similar-item-clarification">
|
||||
You entered: "{originalName}"
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Similar Item Found</h2>
|
||||
<p className="text-center text-lg mb-4">
|
||||
Instead of <strong className="similar-item-original">"{originalName.toLowerCase()}"</strong>, use <strong className="similar-item-suggested">"{suggestedName}"</strong>?
|
||||
</p>
|
||||
|
||||
<div className="similar-item-actions">
|
||||
<button onClick={onCancel} className="similar-item-cancel">
|
||||
Cancel
|
||||
<div className="similar-modal-actions">
|
||||
<button onClick={onYes} className="btn btn-success">
|
||||
Yes, Use Suggestion
|
||||
</button>
|
||||
<button onClick={onNo} className="similar-item-no">
|
||||
<button onClick={onNo} className="btn btn-primary">
|
||||
No, Create New
|
||||
</button>
|
||||
<button onClick={onYes} className="similar-item-yes">
|
||||
Yes, Use Suggestion
|
||||
<button onClick={onCancel} className="btn btn-danger">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
122
frontend/src/context/SettingsContext.jsx
Normal file
122
frontend/src/context/SettingsContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -84,9 +84,14 @@ body {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-body);
|
||||
margin: 0;
|
||||
padding: var(--spacing-md);
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -94,11 +99,6 @@ body {
|
||||
margin: auto;
|
||||
padding: var(--container-padding);
|
||||
}
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
|
||||
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import './styles/theme.css'
|
||||
import './styles/utilities.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { getAllUsers, updateRole } from "../api/users";
|
||||
import UserRoleCard from "../components/common/UserRoleCard";
|
||||
import "../styles/UserRoleCard.css";
|
||||
import "../styles/pages/AdminPanel.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [users, setUsers] = useState([]);
|
||||
@ -22,9 +23,10 @@ export default function AdminPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2rem" }}>
|
||||
<h1>Admin Panel</h1>
|
||||
<div style={{ marginTop: "2rem" }}>
|
||||
<div className="p-4" style={{ minHeight: '100vh' }}>
|
||||
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
|
||||
<div className="mt-4">
|
||||
{users.map((user) => (
|
||||
<UserRoleCard
|
||||
key={user.id}
|
||||
@ -34,5 +36,6 @@ export default function AdminPanel() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,25 +1,40 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
addItem,
|
||||
getClassification,
|
||||
getItemByName,
|
||||
getList,
|
||||
getRecentlyBought,
|
||||
getSuggestions,
|
||||
markBought,
|
||||
updateItemImage,
|
||||
updateItemWithClassification
|
||||
} from "../api/list";
|
||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||
import SortDropdown from "../components/common/SortDropdown";
|
||||
import AddItemForm from "../components/forms/AddItemForm";
|
||||
import GroceryListItem from "../components/items/GroceryListItem";
|
||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||
import EditItemModal from "../components/modals/EditItemModal";
|
||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||
import { ZONE_FLOW } from "../constants/classifications";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SettingsContext } from "../context/SettingsContext";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
|
||||
export default function GroceryList() {
|
||||
const { role } = useContext(AuthContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
|
||||
// === State === //
|
||||
const [items, setItems] = useState([]);
|
||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
||||
const [sortedItems, setSortedItems] = useState([]);
|
||||
const [sortMode, setSortMode] = useState("zone");
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [showAddForm, setShowAddForm] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -30,7 +45,13 @@ export default function GroceryList() {
|
||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
const res = await getList();
|
||||
@ -39,6 +60,7 @@ export default function GroceryList() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
const loadRecentlyBought = async () => {
|
||||
try {
|
||||
const res = await getRecentlyBought();
|
||||
@ -49,13 +71,24 @@ export default function GroceryList() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
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 === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||
@ -64,11 +97,22 @@ export default function GroceryList() {
|
||||
if (sortMode === "zone") {
|
||||
sorted.sort((a, b) => {
|
||||
// Items without classification go to the end
|
||||
if (!a.item_type && b.item_type) return 1;
|
||||
if (a.item_type && !b.item_type) return -1;
|
||||
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
||||
if (!a.zone && b.zone) return 1;
|
||||
if (a.zone && !b.zone) return -1;
|
||||
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 || "");
|
||||
if (typeCompare !== 0) return typeCompare;
|
||||
|
||||
@ -76,18 +120,16 @@ export default function GroceryList() {
|
||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||
if (groupCompare !== 0) return groupCompare;
|
||||
|
||||
// Then by zone
|
||||
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
||||
if (zoneCompare !== 0) return zoneCompare;
|
||||
|
||||
// Finally by name
|
||||
return a.item_name.localeCompare(b.item_name);
|
||||
});
|
||||
}
|
||||
|
||||
setSortedItems(sorted);
|
||||
return sorted;
|
||||
}, [items, sortMode]);
|
||||
|
||||
|
||||
// === Suggestion Handler ===
|
||||
const handleSuggest = async (text) => {
|
||||
if (!text.trim()) {
|
||||
setSuggestions([]);
|
||||
@ -95,34 +137,27 @@ export default function GroceryList() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine both unbought and recently bought items for similarity checking
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
|
||||
// Check if exact match exists (case-insensitive)
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
||||
|
||||
if (exactMatch) {
|
||||
setButtonText("Add");
|
||||
} else {
|
||||
setButtonText("Create + Add");
|
||||
}
|
||||
|
||||
try {
|
||||
let suggestions = await getSuggestions(text);
|
||||
suggestions = suggestions.data.map(s => s.item_name);
|
||||
setSuggestions(suggestions);
|
||||
const response = await getSuggestions(text);
|
||||
const suggestionList = response.data.map(s => s.item_name);
|
||||
setSuggestions(suggestionList);
|
||||
|
||||
// All suggestions are now lowercase from DB, direct comparison
|
||||
const exactMatch = suggestionList.includes(lowerText);
|
||||
setButtonText(exactMatch ? "Add" : "Create + Add");
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
setButtonText("Create + Add");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (itemName, quantity) => {
|
||||
|
||||
// === Item Addition Handlers ===
|
||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||
if (!itemName.trim()) return;
|
||||
|
||||
const lowerItemName = itemName.toLowerCase().trim();
|
||||
|
||||
// First check if exact item exists in database (case-insensitive)
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
@ -131,29 +166,27 @@ export default function GroceryList() {
|
||||
existingItem = null;
|
||||
}
|
||||
|
||||
// If exact item exists, skip similarity check and process directly
|
||||
if (existingItem) {
|
||||
await processItemAddition(itemName, quantity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check for similar items if exact item doesn't exist
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
setItems(prevItems => {
|
||||
const allItems = [...prevItems, ...recentlyBoughtItems];
|
||||
const similar = findSimilarItems(itemName, allItems, 70);
|
||||
if (similar.length > 0) {
|
||||
// Show modal and wait for user decision
|
||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||
setShowSimilarModal(true);
|
||||
return;
|
||||
return prevItems;
|
||||
}
|
||||
|
||||
// Continue with normal flow for new items
|
||||
await processItemAddition(itemName, quantity);
|
||||
};
|
||||
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;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
@ -162,126 +195,205 @@ export default function GroceryList() {
|
||||
existingItem = null;
|
||||
}
|
||||
|
||||
if (existingItem && existingItem.bought === false) {
|
||||
// Item exists and is unbought - update quantity
|
||||
if (existingItem?.bought === false) {
|
||||
const currentQuantity = existingItem.quantity;
|
||||
const newQuantity = currentQuantity + quantity;
|
||||
const yes = window.confirm(
|
||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
||||
);
|
||||
if (!yes) return;
|
||||
|
||||
await addItem(itemName, newQuantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
// Show modal instead of window.confirm
|
||||
setConfirmAddExistingData({
|
||||
itemName,
|
||||
currentQuantity,
|
||||
addingQuantity: quantity,
|
||||
newQuantity,
|
||||
existingItem
|
||||
});
|
||||
setShowConfirmAddExisting(true);
|
||||
} else if (existingItem) {
|
||||
// Item exists in database (was previously bought) - just add quantity
|
||||
await addItem(itemName, quantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
|
||||
// Reload lists to reflect the changes
|
||||
await loadItems();
|
||||
await loadRecentlyBought();
|
||||
} else {
|
||||
// NEW ITEM - show combined add details modal
|
||||
setPendingItem({ itemName, quantity });
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSimilarCancel = () => {
|
||||
|
||||
// === Similar Item Modal Handlers ===
|
||||
const handleSimilarCancel = useCallback(() => {
|
||||
setShowSimilarModal(false);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSimilarNo = async () => {
|
||||
|
||||
const handleSimilarNo = useCallback(async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Create new item with original name
|
||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
}, [similarItemSuggestion, processItemAddition]);
|
||||
|
||||
const handleSimilarYes = async () => {
|
||||
|
||||
const handleSimilarYes = useCallback(async () => {
|
||||
if (!similarItemSuggestion) return;
|
||||
setShowSimilarModal(false);
|
||||
// Use suggested item name
|
||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||
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;
|
||||
|
||||
try {
|
||||
// Add item to grocery_list with image
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
let newItem = addResponse.data;
|
||||
|
||||
// If classification provided, add it
|
||||
if (classification) {
|
||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||
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);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
|
||||
if (newItem) {
|
||||
setItems(prevItems => [...prevItems, newItem]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
};
|
||||
}, [pendingItem]);
|
||||
|
||||
const handleAddDetailsSkip = async () => {
|
||||
|
||||
const handleAddDetailsSkip = useCallback(async () => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
try {
|
||||
// Add item without image or classification
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
|
||||
if (response.data) {
|
||||
setItems(prevItems => [...prevItems, response.data]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
};
|
||||
}, [pendingItem]);
|
||||
|
||||
const handleAddDetailsCancel = () => {
|
||||
|
||||
const handleAddDetailsCancel = useCallback(() => {
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
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();
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
|
||||
|
||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||
try {
|
||||
await updateItemImage(id, itemName, quantity, imageFile);
|
||||
loadItems(); // Reload to show new image
|
||||
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
||||
|
||||
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) {
|
||||
console.error("Failed to add image:", error);
|
||||
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;
|
||||
|
||||
try {
|
||||
// Fetch existing classification
|
||||
const classificationResponse = await getClassification(item.id);
|
||||
setEditingItem({
|
||||
...item,
|
||||
@ -293,27 +405,42 @@ export default function GroceryList() {
|
||||
setEditingItem({ ...item, classification: null });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
};
|
||||
}, [role]);
|
||||
|
||||
const handleEditSave = async (id, itemName, quantity, classification) => {
|
||||
|
||||
// === Edit Modal Handlers ===
|
||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||
try {
|
||||
await updateItemWithClassification(id, itemName, quantity, classification);
|
||||
const response = await updateItemWithClassification(id, itemName, quantity, classification);
|
||||
setShowEditModal(false);
|
||||
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) {
|
||||
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);
|
||||
setEditingItem(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Group items by zone for classification view
|
||||
|
||||
// === Helper Functions ===
|
||||
const groupItemsByZone = (items) => {
|
||||
const groups = {};
|
||||
items.forEach(item => {
|
||||
@ -326,8 +453,10 @@ export default function GroceryList() {
|
||||
return groups;
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <p>Loading...</p>;
|
||||
|
||||
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
@ -346,21 +475,35 @@ export default function GroceryList() {
|
||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||
|
||||
{sortMode === "zone" ? (
|
||||
// Grouped view by zone
|
||||
(() => {
|
||||
const grouped = groupItemsByZone(sortedItems);
|
||||
return Object.keys(grouped).map(zone => (
|
||||
return Object.keys(grouped).map(zone => {
|
||||
const isCollapsed = collapsedZones[zone];
|
||||
const itemCount = grouped[zone].length;
|
||||
return (
|
||||
<div key={zone} className="glist-classification-group">
|
||||
<h3 className="glist-classification-header">
|
||||
<h3
|
||||
className="glist-classification-header clickable"
|
||||
onClick={() => toggleZoneCollapse(zone)}
|
||||
>
|
||||
<span>
|
||||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||
<span className="glist-zone-count"> ({itemCount})</span>
|
||||
</span>
|
||||
<span className="glist-zone-indicator">
|
||||
{isCollapsed ? "▼" : "▲"}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="glist-ul">
|
||||
{!isCollapsed && (
|
||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||
{grouped[zone].map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(quantity) =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||
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
|
||||
@ -371,18 +514,21 @@ export default function GroceryList() {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
// Regular flat list view
|
||||
<ul className="glist-ul">
|
||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||
{sortedItems.map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(quantity) =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||
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
|
||||
@ -395,14 +541,27 @@ export default function GroceryList() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recentlyBoughtItems.length > 0 && (
|
||||
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
||||
<>
|
||||
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||
<ul className="glist-ul">
|
||||
<h2
|
||||
className="glist-section-title clickable"
|
||||
onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
|
||||
>
|
||||
<span>Recently Bought (24HR)</span>
|
||||
<span className="glist-section-indicator">
|
||||
{recentlyBoughtCollapsed ? "▼" : "▲"}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{!recentlyBoughtCollapsed && (
|
||||
<>
|
||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
allItems={recentlyBoughtItems}
|
||||
compact={settings.compactView}
|
||||
onClick={null}
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
@ -425,6 +584,8 @@ export default function GroceryList() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
@ -458,6 +619,17 @@ export default function GroceryList() {
|
||||
item={editingItem}
|
||||
onSave={handleEditSave}
|
||||
onCancel={handleEditCancel}
|
||||
onImageUpdate={handleImageAdded}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showConfirmAddExisting && confirmAddExistingData && (
|
||||
<ConfirmAddExistingModal
|
||||
itemName={confirmAddExistingData.itemName}
|
||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||
onConfirm={handleConfirmAddExisting}
|
||||
onCancel={handleCancelAddExisting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -27,16 +27,16 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-wrapper">
|
||||
<div className="login-box">
|
||||
<h1 className="login-title">Login</h1>
|
||||
<div className="flex-center" style={{ minHeight: '100vh', padding: '1em', background: '#f8f9fa' }}>
|
||||
<div className="card card-elevated" style={{ width: '100%', maxWidth: '360px' }}>
|
||||
<h1 className="text-center text-2xl mb-3">Login</h1>
|
||||
|
||||
<ErrorMessage message={error} />
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<FormInput
|
||||
type="text"
|
||||
className="login-input"
|
||||
className="form-input my-2"
|
||||
placeholder="Username"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@ -44,7 +44,7 @@ export default function Login() {
|
||||
<div className="login-password-wrapper">
|
||||
<FormInput
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="login-input"
|
||||
className="form-input"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
@ -58,11 +58,11 @@ export default function Login() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-button">Login</button>
|
||||
<button type="submit" className="btn btn-primary btn-block mt-2">Login</button>
|
||||
</form>
|
||||
|
||||
<p className="login-register">
|
||||
Need an account? <Link to="/register">Register here</Link>
|
||||
<p className="text-center mt-3">
|
||||
Need an account? <Link to="/register" className="text-primary">Register here</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,7 @@ export default function Register() {
|
||||
|
||||
return (
|
||||
<div className="register-container">
|
||||
<h1>Register</h1>
|
||||
<h1 className="text-center mb-4 text-2xl font-bold">Register</h1>
|
||||
|
||||
<ErrorMessage message={error} />
|
||||
<ErrorMessage message={success} type="success" />
|
||||
@ -67,6 +67,7 @@ export default function Register() {
|
||||
<form className="register-form" onSubmit={submit}>
|
||||
<FormInput
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
@ -74,6 +75,7 @@ export default function Register() {
|
||||
|
||||
<FormInput
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Username"
|
||||
onKeyUp={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
@ -81,6 +83,7 @@ export default function Register() {
|
||||
|
||||
<FormInput
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="Password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@ -88,18 +91,19 @@ export default function Register() {
|
||||
|
||||
<FormInput
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="Confirm Password"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<button disabled={error !== ""} type="submit">
|
||||
<button disabled={error !== ""} type="submit" className="btn btn-primary btn-block mt-2">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="register-link">
|
||||
Already have an account? <Link to="/login">Login here</Link>
|
||||
<p className="text-center mt-3">
|
||||
Already have an account? <Link to="/login" className="text-primary font-semibold">Login here</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
426
frontend/src/pages/Settings.jsx
Normal file
426
frontend/src/pages/Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,44 +1,4 @@
|
||||
.add-image-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.add-image-modal {
|
||||
background: white;
|
||||
padding: 2em;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.add-image-modal h2 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 1.5em;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-image-subtitle {
|
||||
margin: 0 0 1.5em 0;
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-image-subtitle strong {
|
||||
color: #007bff;
|
||||
}
|
||||
/* AddImageModal - custom styles for unique components */
|
||||
|
||||
.add-image-options {
|
||||
display: flex;
|
||||
@ -48,32 +8,33 @@
|
||||
}
|
||||
|
||||
.add-image-option-btn {
|
||||
padding: 1.2em;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 1.1em;
|
||||
padding: var(--spacing-lg);
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--color-bg-surface);
|
||||
font-size: var(--font-size-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.add-image-option-btn:hover {
|
||||
border-color: #007bff;
|
||||
background: #f8f9fa;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.add-image-option-btn.camera {
|
||||
color: #007bff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.add-image-option-btn.gallery {
|
||||
color: #28a745;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.add-image-preview-container {
|
||||
@ -86,9 +47,10 @@
|
||||
position: relative;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.add-image-preview img {
|
||||
@ -119,58 +81,3 @@
|
||||
.add-image-remove:hover {
|
||||
background: rgba(255, 0, 0, 1);
|
||||
}
|
||||
|
||||
.add-image-actions {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.add-image-cancel,
|
||||
.add-image-confirm {
|
||||
flex: 1;
|
||||
padding: 0.8em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-image-cancel {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-image-cancel:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.add-image-confirm {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-image-confirm:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,68 +4,128 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: var(--modal-backdrop-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.confirm-buy-modal {
|
||||
background: white;
|
||||
padding: 2em;
|
||||
border-radius: 12px;
|
||||
background: var(--modal-bg);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-xl);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-xl);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.confirm-buy-modal h2 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 1.5em;
|
||||
color: #333;
|
||||
.confirm-buy-header {
|
||||
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 {
|
||||
margin: 0 0 1.5em 0;
|
||||
font-size: 1.1em;
|
||||
color: #007bff;
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
color: var(--color-primary);
|
||||
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 {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.confirm-buy-label {
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 1em;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.confirm-buy-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.confirm-buy-counter-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #007bff;
|
||||
font-size: 1.8em;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border: var(--border-width-medium) solid var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -74,67 +134,68 @@
|
||||
}
|
||||
|
||||
.confirm-buy-counter-btn:hover:not(:disabled) {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.confirm-buy-counter-btn:disabled {
|
||||
border-color: #ccc;
|
||||
color: #ccc;
|
||||
border-color: var(--color-border-medium);
|
||||
color: var(--color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.confirm-buy-counter-display {
|
||||
width: 80px;
|
||||
height: 50px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
width: 70px;
|
||||
height: 45px;
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
background: #f8f9fa;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.confirm-buy-counter-display:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.confirm-buy-actions {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
margin-top: 2em;
|
||||
gap: 0.6em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.confirm-buy-cancel,
|
||||
.confirm-buy-confirm {
|
||||
flex: 1;
|
||||
padding: 0.9em;
|
||||
padding: 0.75em 0.5em;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius-lg);
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.confirm-buy-cancel {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
background: var(--color-gray-200);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.confirm-buy-cancel:hover {
|
||||
background: #e0e0e0;
|
||||
background: var(--color-gray-300);
|
||||
}
|
||||
|
||||
.confirm-buy-confirm {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: var(--color-success);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.confirm-buy-confirm:hover {
|
||||
background: #218838;
|
||||
background: var(--color-success-hover);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@ -156,3 +217,88 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
/* ImageModal - specialized full-screen image viewer */
|
||||
|
||||
.image-modal-overlay {
|
||||
position: fixed;
|
||||
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 {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
@ -76,14 +54,6 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.image-modal-caption {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-modal-overlay {
|
||||
padding: 1rem;
|
||||
@ -92,8 +62,5 @@
|
||||
.image-modal-img {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.image-modal-caption {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,115 +1,24 @@
|
||||
.similar-item-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
/* SimilarItemModal - custom styles */
|
||||
|
||||
.similar-item-modal {
|
||||
background: white;
|
||||
padding: 2em;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.similar-item-modal h2 {
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 1.5em;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.similar-item-question {
|
||||
margin: 0 0 0.5em 0;
|
||||
.similar-item-suggested {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.similar-item-question strong {
|
||||
color: #007bff;
|
||||
.similar-item-original {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.similar-item-clarification {
|
||||
margin: 0 0 2em 0;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.similar-item-actions {
|
||||
.similar-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
margin-top: 1.5em;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.similar-item-cancel,
|
||||
.similar-item-no,
|
||||
.similar-item-yes {
|
||||
flex: 1;
|
||||
padding: 0.8em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
.similar-modal-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,40 +1,13 @@
|
||||
.user-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
/* UserRoleCard - custom styles only */
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.user-username {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -29,6 +29,12 @@
|
||||
font-family: var(--font-family-base);
|
||||
transition: var(--transition-base);
|
||||
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 {
|
||||
@ -107,6 +113,8 @@
|
||||
font-family: var(--font-family-base);
|
||||
text-align: center;
|
||||
transition: var(--transition-base);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
||||
}
|
||||
|
||||
@ -133,7 +141,8 @@
|
||||
border-radius: var(--button-border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 120px
|
||||
transition: var(--transition-base);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
@ -150,12 +159,13 @@
|
||||
|
||||
.add-item-form-submit.disabled,
|
||||
.add-item-form-submit:disabled {
|
||||
background: var(--color-bg-disabled);
|
||||
color: var(--color-text-disabled);
|
||||
background: var(--color-gray-400);
|
||||
color: var(--color-gray-600);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
opacity: 1;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
border: var(--border-width-thin) solid var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@ -4,43 +4,43 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: var(--modal-backdrop-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1em;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.add-item-details-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5em;
|
||||
background: var(--modal-bg);
|
||||
border-radius: var(--border-radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.add-item-details-title {
|
||||
font-size: 1.4em;
|
||||
margin: 0 0 0.3em 0;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.add-item-details-subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 0 0 1.5em 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-xl) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.add-item-details-section {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1.5em;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding-bottom: var(--spacing-xl);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.add-item-details-section:last-of-type {
|
||||
@ -48,9 +48,9 @@
|
||||
}
|
||||
|
||||
.add-item-details-section-title {
|
||||
font-size: 1.1em;
|
||||
margin: 0 0 1em 0;
|
||||
color: #555;
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@ -68,27 +68,27 @@
|
||||
.add-item-details-image-btn {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 0.8em;
|
||||
padding: var(--button-padding-y) var(--button-padding-x);
|
||||
font-size: 0.95em;
|
||||
border: 2px solid #007bff;
|
||||
background: white;
|
||||
color: #007bff;
|
||||
border-radius: 8px;
|
||||
border: var(--border-width-medium) solid var(--color-primary);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.add-item-details-image-btn:hover {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.add-item-details-image-preview {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
border: 2px solid #e0e0e0;
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.add-item-details-image-preview img {
|
||||
|
||||
@ -1,44 +1,45 @@
|
||||
/* Classification Section */
|
||||
.classification-section {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.classification-title {
|
||||
font-size: 1em;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #333;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.classification-field {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.classification-field label {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #555;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.classification-select {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: var(--font-size-base);
|
||||
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);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.classification-select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
border-color: var(--input-focus-border-color);
|
||||
box-shadow: var(--input-focus-shadow);
|
||||
}
|
||||
|
||||
.classification-select:hover {
|
||||
border-color: #999;
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
41
frontend/src/styles/components/ConfirmAddExistingModal.css
Normal file
41
frontend/src/styles/components/ConfirmAddExistingModal.css
Normal 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);
|
||||
}
|
||||
|
||||
@ -4,88 +4,141 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: var(--modal-backdrop-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1em;
|
||||
z-index: var(--z-modal);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5em;
|
||||
max-width: 480px;
|
||||
background: var(--modal-bg);
|
||||
border-radius: var(--border-radius-xl);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.edit-modal-title {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-modal-subtitle {
|
||||
font-size: 1.1em;
|
||||
margin: 0.5em 0 0.8em 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.edit-modal-field {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.edit-modal-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.edit-modal-input,
|
||||
.edit-modal-select {
|
||||
width: 100%;
|
||||
padding: 0.6em;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: var(--font-size-base);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
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-select:focus {
|
||||
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 {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 1.5em 0;
|
||||
background: var(--color-border-light);
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.edit-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
margin-top: 1.5em;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.edit-modal-btn {
|
||||
flex: 1;
|
||||
padding: 0.7em;
|
||||
font-size: 1em;
|
||||
padding: var(--button-padding-y) var(--button-padding-x);
|
||||
font-size: var(--font-size-base);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--button-border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.edit-modal-btn:disabled {
|
||||
@ -94,19 +147,43 @@
|
||||
}
|
||||
|
||||
.edit-modal-btn-cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.edit-modal-btn-cancel:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
background: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.edit-modal-btn-save {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
42
frontend/src/styles/components/SuggestionList.css
Normal file
42
frontend/src/styles/components/SuggestionList.css
Normal 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);
|
||||
}
|
||||
9
frontend/src/styles/pages/AdminPanel.css
Normal file
9
frontend/src/styles/pages/AdminPanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,64 @@
|
||||
color: var(--color-gray-700);
|
||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||
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 */
|
||||
@ -45,6 +103,34 @@
|
||||
background: var(--color-primary-light);
|
||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||
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 */
|
||||
@ -94,49 +180,52 @@
|
||||
|
||||
/* Suggestion dropdown */
|
||||
.glist-suggest-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
background: var(--color-bg-surface);
|
||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||
padding: 1em;
|
||||
z-index: var(--z-dropdown);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--spacing-md);
|
||||
width: calc(100% - 8em);
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.glist-suggest-item {
|
||||
padding: 0.5em;
|
||||
padding-inline: 2em;
|
||||
padding: var(--spacing-sm);
|
||||
padding-inline: var(--spacing-xl);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.glist-suggest-item:hover {
|
||||
background: #eee;
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Grocery list items */
|
||||
.glist-ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 1em;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.glist-li {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.8em;
|
||||
background: var(--color-bg-surface);
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glist-li:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@ -151,21 +240,21 @@
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
min-width: 50px;
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: var(--color-gray-100);
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
color: #ccc;
|
||||
color: var(--color-border-medium);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image {
|
||||
border-color: #007bff;
|
||||
background: #fff;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.glist-item-image img {
|
||||
@ -174,11 +263,6 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.glist-item-image.has-image:hover {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.glist-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -197,37 +281,69 @@
|
||||
.glist-item-name {
|
||||
font-weight: 800;
|
||||
font-size: 0.8em;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.glist-item-quantity {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 123, 255, 0.9);
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: 700;
|
||||
font-size: 0.3em;
|
||||
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%;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.glist-item-users {
|
||||
font-size: 0.7em;
|
||||
color: #888;
|
||||
color: var(--color-text-secondary);
|
||||
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 */
|
||||
.glist-sort {
|
||||
width: 100%;
|
||||
margin: 0.3em 0;
|
||||
padding: 0.5em;
|
||||
font-size: 1em;
|
||||
border-radius: 4px;
|
||||
margin: var(--spacing-xs) 0;
|
||||
padding: var(--spacing-sm);
|
||||
font-size: var(--font-size-base);
|
||||
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 */
|
||||
@ -237,18 +353,19 @@
|
||||
|
||||
.glist-image-label {
|
||||
display: block;
|
||||
padding: 0.6em;
|
||||
background: #f0f0f0;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-gray-100);
|
||||
border: var(--border-width-medium) dashed var(--color-border-medium);
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: var(--transition-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.glist-image-label:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: #007bff;
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.glist-image-preview {
|
||||
@ -260,8 +377,8 @@
|
||||
.glist-image-preview img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.glist-remove-image {
|
||||
@ -270,10 +387,10 @@
|
||||
right: -8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-inverse);
|
||||
border: var(--border-width-medium) solid var(--color-bg-surface);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
@ -283,7 +400,7 @@
|
||||
}
|
||||
|
||||
.glist-remove-image:hover {
|
||||
background: #cc0000;
|
||||
background: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
@ -291,10 +408,10 @@
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: var(--color-success);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--border-radius-full);
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
font-size: 2em;
|
||||
@ -302,12 +419,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.glist-fab:hover {
|
||||
background: #218838;
|
||||
background: var(--color-success-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
|
||||
@ -1,37 +1,4 @@
|
||||
.login-wrapper {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: white;
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 1.6em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 0.6em;
|
||||
margin: 0.4em 0;
|
||||
font-size: 1em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
/* Login page - custom password toggle only */
|
||||
|
||||
.login-password-wrapper {
|
||||
display: flex;
|
||||
@ -40,7 +7,7 @@
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.login-password-wrapper .login-input {
|
||||
.login-password-wrapper .form-input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
@ -67,38 +34,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
/* Register page - container only */
|
||||
|
||||
.register-container {
|
||||
max-width: 400px;
|
||||
margin: 50px auto;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.register-container h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
background: var(--color-bg-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
@ -21,64 +15,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
213
frontend/src/styles/pages/Settings.css
Normal file
213
frontend/src/styles/pages/Settings.css
Normal 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);
|
||||
}
|
||||
@ -189,23 +189,94 @@
|
||||
--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)
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Uncomment to enable dark mode
|
||||
:root {
|
||||
--color-text-primary: #f8f9fa;
|
||||
--color-text-secondary: #adb5bd;
|
||||
--color-bg-body: #212529;
|
||||
--color-bg-surface: #343a40;
|
||||
--color-border-light: #495057;
|
||||
--color-border-medium: #6c757d;
|
||||
}
|
||||
*/
|
||||
/* Auto mode will use data-theme attribute set by JS */
|
||||
}
|
||||
|
||||
/* Manual dark mode class override */
|
||||
|
||||
/* Manual dark mode class override (deprecated - use data-theme) */
|
||||
.dark-mode {
|
||||
--color-text-primary: #f8f9fa;
|
||||
--color-text-secondary: #adb5bd;
|
||||
|
||||
570
frontend/src/styles/utilities.css
Normal file
570
frontend/src/styles/utilities.css
Normal 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; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user