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) => {
|
exports.markBought = async (req, res) => {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
await List.setBought(req.body.id, userId);
|
const { id, quantity } = req.body;
|
||||||
|
await List.setBought(id, userId, quantity);
|
||||||
res.json({ message: "Item marked bought" });
|
res.json({ message: "Item marked bought" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const User = require("../models/user.model");
|
const User = require("../models/user.model");
|
||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
|
||||||
exports.test = async (req, res) => {
|
exports.test = async (req, res) => {
|
||||||
console.log("User route is working");
|
console.log("User route is working");
|
||||||
@ -45,11 +46,92 @@ exports.deleteUser = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
exports.checkIfUserExists = async (req, res) => {
|
exports.checkIfUserExists = async (req, res) => {
|
||||||
const { username } = req.query;
|
const { username } = req.query;
|
||||||
const users = await User.checkIfUserExists(username);
|
const exists = await User.checkIfUserExists(username);
|
||||||
res.json(users);
|
res.json(exists);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCurrentUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const user = await User.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting current user:", err);
|
||||||
|
res.status(500).json({ error: "Failed to get user profile" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateCurrentUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { display_name } = req.body;
|
||||||
|
|
||||||
|
if (!display_name || display_name.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: "Display name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (display_name.length > 100) {
|
||||||
|
return res.status(400).json({ error: "Display name must be 100 characters or less" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Profile updated successfully", user: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating user profile:", err);
|
||||||
|
res.status(500).json({ error: "Failed to update profile" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.changePassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { current_password, new_password } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!current_password || !new_password) {
|
||||||
|
return res.status(400).json({ error: "Current password and new password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length < 6) {
|
||||||
|
return res.status(400).json({ error: "New password must be at least 6 characters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current password hash
|
||||||
|
const currentHash = await User.getUserPasswordHash(userId);
|
||||||
|
|
||||||
|
if (!currentHash) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await bcrypt.compare(current_password, currentHash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res.status(401).json({ error: "Current password is incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const hashedPassword = await bcrypt.hash(new_password, salt);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await User.updateUserPassword(userId, hashedPassword);
|
||||||
|
|
||||||
|
res.json({ message: "Password changed successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error changing password:", err);
|
||||||
|
res.status(500).json({ error: "Failed to change password" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT gh.added_by, gh.added_on,
|
SELECT DISTINCT gh.added_by
|
||||||
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
|
||||||
FROM grocery_history gh
|
FROM grocery_history gh
|
||||||
WHERE gh.list_item_id = gl.id
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
) gh
|
) gh
|
||||||
JOIN users u ON gh.added_by = u.id
|
JOIN users u ON gh.added_by = u.id
|
||||||
WHERE gh.rn <= gl.quantity
|
|
||||||
) as added_by_users,
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on,
|
gl.modified_on as last_added_on,
|
||||||
ic.item_type,
|
ic.item_type,
|
||||||
@ -35,7 +34,30 @@ exports.getUnboughtItems = async () => {
|
|||||||
|
|
||||||
exports.getItemByName = async (itemName) => {
|
exports.getItemByName = async (itemName) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
"SELECT * FROM grocery_list WHERE item_name ILIKE $1",
|
`SELECT
|
||||||
|
gl.id,
|
||||||
|
LOWER(gl.item_name) AS item_name,
|
||||||
|
gl.quantity,
|
||||||
|
gl.bought,
|
||||||
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
|
gl.image_mime_type,
|
||||||
|
(
|
||||||
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT gh.added_by
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
) as added_by_users,
|
||||||
|
gl.modified_on as last_added_on,
|
||||||
|
ic.item_type,
|
||||||
|
ic.item_group,
|
||||||
|
ic.zone
|
||||||
|
FROM grocery_list gl
|
||||||
|
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||||
|
WHERE gl.item_name ILIKE $1`,
|
||||||
[itemName]
|
[itemName]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -88,11 +110,31 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.setBought = async (id, userId) => {
|
exports.setBought = async (id, userId, quantityBought) => {
|
||||||
await pool.query(
|
// Get current item
|
||||||
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
const item = await pool.query(
|
||||||
|
"SELECT quantity FROM grocery_list WHERE id = $1",
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!item.rows[0]) return;
|
||||||
|
|
||||||
|
const currentQuantity = item.rows[0].quantity;
|
||||||
|
const remainingQuantity = currentQuantity - quantityBought;
|
||||||
|
|
||||||
|
if (remainingQuantity <= 0) {
|
||||||
|
// Mark as bought if all quantity is purchased
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Reduce quantity if partial purchase
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||||
|
[remainingQuantity, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -107,7 +149,7 @@ exports.addHistoryRecord = async (itemId, quantity, userId) => {
|
|||||||
|
|
||||||
exports.getSuggestions = async (query) => {
|
exports.getSuggestions = async (query) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT item_name
|
`SELECT DISTINCT LOWER(item_name) as item_name
|
||||||
FROM grocery_list
|
FROM grocery_list
|
||||||
WHERE item_name ILIKE $1
|
WHERE item_name ILIKE $1
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
@ -126,15 +168,14 @@ exports.getRecentlyBoughtItems = async () => {
|
|||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT gh.added_by, gh.added_on,
|
SELECT DISTINCT gh.added_by
|
||||||
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
|
||||||
FROM grocery_history gh
|
FROM grocery_history gh
|
||||||
WHERE gh.list_item_id = gl.id
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
) gh
|
) gh
|
||||||
JOIN users u ON gh.added_by = u.id
|
JOIN users u ON gh.added_by = u.id
|
||||||
WHERE gh.rn <= gl.quantity
|
|
||||||
) as added_by_users,
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on
|
gl.modified_on as last_added_on
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
|
|||||||
@ -24,10 +24,49 @@ exports.createUser = async (username, hashedPassword, name) => {
|
|||||||
|
|
||||||
|
|
||||||
exports.getAllUsers = async () => {
|
exports.getAllUsers = async () => {
|
||||||
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
|
const result = await pool.query("SELECT id, username, name, role, display_name FROM users ORDER BY id ASC");
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getUserById = async (id) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT id, username, name, role, display_name FROM users WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateUserProfile = async (id, updates) => {
|
||||||
|
const { display_name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET display_name = COALESCE($1, display_name)
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, username, name, role, display_name`,
|
||||||
|
[display_name, id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateUserPassword = async (id, hashedPassword) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET password = $1
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[hashedPassword, id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserPasswordHash = async (id) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT password FROM users WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.password;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.updateUserRole = async (id, role) => {
|
exports.updateUserRole = async (id, role) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
@ -7,4 +7,9 @@ const { ROLES } = require("../models/user.model");
|
|||||||
router.get("/exists", usersController.checkIfUserExists);
|
router.get("/exists", usersController.checkIfUserExists);
|
||||||
router.get("/test", usersController.test);
|
router.get("/test", usersController.test);
|
||||||
|
|
||||||
|
// Current user profile routes (authenticated)
|
||||||
|
router.get("/me", auth, usersController.getCurrentUser);
|
||||||
|
router.patch("/me", auth, usersController.updateCurrentUser);
|
||||||
|
router.post("/me/change-password", auth, usersController.changePassword);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
473
docs/code-cleanup-guide.md
Normal file
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 { ROLES } from "./constants/roles";
|
||||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||||
|
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||||
import GroceryList from "./pages/GroceryList.jsx";
|
import GroceryList from "./pages/GroceryList.jsx";
|
||||||
import Login from "./pages/Login.jsx";
|
import Login from "./pages/Login.jsx";
|
||||||
import Register from "./pages/Register.jsx";
|
import Register from "./pages/Register.jsx";
|
||||||
|
import Settings from "./pages/Settings.jsx";
|
||||||
|
|
||||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||||
@ -18,35 +20,38 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<SettingsProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
|
||||||
{/* Public route */}
|
{/* Public route */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
{/* Private routes with layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<AppLayout />
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/" element={<GroceryList />} />
|
|
||||||
|
|
||||||
|
{/* Private routes with layout */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
|
||||||
element={
|
element={
|
||||||
<RoleGuard allowed={[ROLES.ADMIN]}>
|
<PrivateRoute>
|
||||||
<AdminPanel />
|
<AppLayout />
|
||||||
</RoleGuard>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</Route>
|
<Route path="/" element={<GroceryList />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|
||||||
</Routes>
|
<Route
|
||||||
</BrowserRouter>
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RoleGuard allowed={[ROLES.ADMIN]}>
|
||||||
|
<AdminPanel />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</SettingsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
|
|||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const markBought = (id) => api.post("/list/mark-bought", { id });
|
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
|
||||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||||
|
|
||||||
|
|||||||
@ -3,4 +3,16 @@ import api from "./axios";
|
|||||||
export const getAllUsers = () => api.get("/admin/users");
|
export const getAllUsers = () => api.get("/admin/users");
|
||||||
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
|
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
|
||||||
export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
|
export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
|
||||||
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });
|
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });
|
||||||
|
|
||||||
|
export const getCurrentUser = async () => {
|
||||||
|
return api.get("/users/me");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCurrentUser = async (display_name) => {
|
||||||
|
return api.patch("/users/me", { display_name });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changePassword = async (current_password, new_password) => {
|
||||||
|
return api.post("/users/me/change-password", { current_password, new_password });
|
||||||
|
};
|
||||||
@ -2,7 +2,7 @@ import { ROLES } from "../../constants/roles";
|
|||||||
|
|
||||||
export default function UserRoleCard({ user, onRoleChange }) {
|
export default function UserRoleCard({ user, onRoleChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="user-card">
|
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<strong>{user.name}</strong>
|
<strong>{user.name}</strong>
|
||||||
<span className="user-username">@{user.username}</span>
|
<span className="user-username">@{user.username}</span>
|
||||||
@ -10,7 +10,8 @@ export default function UserRoleCard({ user, onRoleChange }) {
|
|||||||
<select
|
<select
|
||||||
onChange={(e) => onRoleChange(user.id, e.target.value)}
|
onChange={(e) => onRoleChange(user.id, e.target.value)}
|
||||||
value={user.role}
|
value={user.role}
|
||||||
className="role-select"
|
className="form-select"
|
||||||
|
style={{ fontSize: 'var(--font-size-sm)' }}
|
||||||
>
|
>
|
||||||
<option value={ROLES.VIEWER}>Viewer</option>
|
<option value={ROLES.VIEWER}>Viewer</option>
|
||||||
<option value={ROLES.EDITOR}>Editor</option>
|
<option value={ROLES.EDITOR}>Editor</option>
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { useRef, useState } from "react";
|
import { memo, useRef, useState } from "react";
|
||||||
import AddImageModal from "../modals/AddImageModal";
|
import AddImageModal from "../modals/AddImageModal";
|
||||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||||
import ImageModal from "../modals/ImageModal";
|
|
||||||
|
|
||||||
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||||
|
const [currentItem, setCurrentItem] = useState(item);
|
||||||
|
|
||||||
const longPressTimer = useRef(null);
|
const longPressTimer = useRef(null);
|
||||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
@ -57,13 +56,14 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
|||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
|
setCurrentItem(item);
|
||||||
setShowConfirmBuyModal(true);
|
setShowConfirmBuyModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmBuy = (quantity) => {
|
const handleConfirmBuy = (quantity) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(quantity);
|
onClick(currentItem.id, quantity);
|
||||||
}
|
}
|
||||||
setShowConfirmBuyModal(false);
|
setShowConfirmBuyModal(false);
|
||||||
};
|
};
|
||||||
@ -72,10 +72,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
|||||||
setShowConfirmBuyModal(false);
|
setShowConfirmBuyModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (newItem) => {
|
||||||
|
setCurrentItem(newItem);
|
||||||
|
};
|
||||||
|
|
||||||
const handleImageClick = (e) => {
|
const handleImageClick = (e) => {
|
||||||
e.stopPropagation(); // Prevent triggering the bought action
|
e.stopPropagation(); // Prevent triggering the bought action
|
||||||
if (item.item_image) {
|
if (item.item_image) {
|
||||||
setShowModal(true);
|
// Open buy modal which now shows the image
|
||||||
|
setCurrentItem(item);
|
||||||
|
setShowConfirmBuyModal(true);
|
||||||
} else {
|
} else {
|
||||||
setShowAddImageModal(true);
|
setShowAddImageModal(true);
|
||||||
}
|
}
|
||||||
@ -114,7 +120,7 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
className="glist-li"
|
className={`glist-li ${compact ? 'compact' : ''}`}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@ -150,14 +156,6 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{showModal && (
|
|
||||||
<ImageModal
|
|
||||||
imageUrl={imageUrl}
|
|
||||||
itemName={item.item_name}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddImageModal && (
|
{showAddImageModal && (
|
||||||
<AddImageModal
|
<AddImageModal
|
||||||
itemName={item.item_name}
|
itemName={item.item_name}
|
||||||
@ -168,11 +166,32 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre
|
|||||||
|
|
||||||
{showConfirmBuyModal && (
|
{showConfirmBuyModal && (
|
||||||
<ConfirmBuyModal
|
<ConfirmBuyModal
|
||||||
item={item}
|
item={currentItem}
|
||||||
onConfirm={handleConfirmBuy}
|
onConfirm={handleConfirmBuy}
|
||||||
onCancel={handleCancelBuy}
|
onCancel={handleCancelBuy}
|
||||||
|
allItems={allItems}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoize component to prevent re-renders when props haven't changed
|
||||||
|
export default memo(GroceryListItem, (prevProps, nextProps) => {
|
||||||
|
// Only re-render if the item data or handlers have changed
|
||||||
|
return (
|
||||||
|
prevProps.item.id === nextProps.item.id &&
|
||||||
|
prevProps.item.item_name === nextProps.item.item_name &&
|
||||||
|
prevProps.item.quantity === nextProps.item.quantity &&
|
||||||
|
prevProps.item.item_image === nextProps.item.item_image &&
|
||||||
|
prevProps.item.bought === nextProps.item.bought &&
|
||||||
|
prevProps.item.last_added_on === nextProps.item.last_added_on &&
|
||||||
|
prevProps.item.zone === nextProps.item.zone &&
|
||||||
|
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
|
||||||
|
prevProps.onClick === nextProps.onClick &&
|
||||||
|
prevProps.onImageAdded === nextProps.onImageAdded &&
|
||||||
|
prevProps.onLongPress === nextProps.onLongPress &&
|
||||||
|
prevProps.allItems?.length === nextProps.allItems?.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import "../../styles/components/SuggestionList.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
@ -8,27 +9,12 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
if (!suggestions.length) return null;
|
if (!suggestions.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul className="suggestion-list">
|
||||||
className="suggestion-list"
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
maxHeight: "150px",
|
|
||||||
overflowY: "auto",
|
|
||||||
listStyle: "none",
|
|
||||||
padding: 0,
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{suggestions.map((s) => (
|
{suggestions.map((s) => (
|
||||||
<li
|
<li
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => onSelect(s)}
|
onClick={() => onSelect(s)}
|
||||||
style={{
|
className="suggestion-item"
|
||||||
padding: "0.5em",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: "1px solid #eee",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export default function Navbar() {
|
|||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div className="navbar-links">
|
<div className="navbar-links">
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
|
<Link to="/settings">Settings</Link>
|
||||||
|
|
||||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,11 +39,11 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="add-image-modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="add-image-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Add Image</h2>
|
<h2 className="modal-title">Add Image</h2>
|
||||||
<p className="add-image-subtitle">
|
<p className="text-center mb-4" style={{ color: 'var(--color-text-secondary)', fontSize: '0.95em' }}>
|
||||||
There's no image for <strong>"{itemName}"</strong> yet. Add a new image?
|
There's no image for <strong className="text-primary">"{itemName}"</strong> yet. Add a new image?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!imagePreview ? (
|
{!imagePreview ? (
|
||||||
@ -83,12 +83,12 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="add-image-actions">
|
<div className="modal-actions">
|
||||||
<button onClick={onClose} className="add-image-cancel">
|
<button onClick={onClose} className="btn btn-outline flex-1">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<button onClick={handleConfirm} className="add-image-confirm">
|
<button onClick={handleConfirm} className="btn btn-success flex-1">
|
||||||
Add Image
|
Add Image
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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";
|
import "../../styles/ConfirmBuyModal.css";
|
||||||
|
|
||||||
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
export default function ConfirmBuyModal({
|
||||||
|
item,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
allItems = [],
|
||||||
|
onNavigate
|
||||||
|
}) {
|
||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
|
||||||
|
// Update quantity when item changes (navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
setQuantity(item.quantity);
|
||||||
|
}, [item.id, item.quantity]);
|
||||||
|
|
||||||
|
// Find current index and check for prev/next
|
||||||
|
const currentIndex = allItems.findIndex(i => i.id === item.id);
|
||||||
|
const hasPrev = currentIndex > 0;
|
||||||
|
const hasNext = currentIndex < allItems.length - 1;
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = () => {
|
||||||
if (quantity < maxQuantity) {
|
if (quantity < maxQuantity) {
|
||||||
setQuantity(prev => prev + 1);
|
setQuantity(prev => prev + 1);
|
||||||
@ -21,14 +37,61 @@ export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
|||||||
onConfirm(quantity);
|
onConfirm(quantity);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (hasPrev && onNavigate) {
|
||||||
|
const prevItem = allItems[currentIndex - 1];
|
||||||
|
onNavigate(prevItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (hasNext && onNavigate) {
|
||||||
|
const nextItem = allItems[currentIndex + 1];
|
||||||
|
onNavigate(nextItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageUrl = item.item_image && item.image_mime_type
|
||||||
|
? `data:${item.image_mime_type};base64,${item.item_image}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
||||||
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Mark as Bought</h2>
|
<div className="confirm-buy-header">
|
||||||
<p className="confirm-buy-item-name">"{item.item_name}"</p>
|
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
||||||
|
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-buy-image-section">
|
||||||
|
<button
|
||||||
|
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||||
|
onClick={handlePrev}
|
||||||
|
style={{ visibility: hasPrev ? 'visible' : 'hidden' }}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="confirm-buy-image-container">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
||||||
|
) : (
|
||||||
|
<div className="confirm-buy-image-placeholder">📦</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||||
|
onClick={handleNext}
|
||||||
|
style={{ visibility: hasNext ? 'visible' : 'hidden' }}
|
||||||
|
disabled={!hasNext}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-quantity-section">
|
<div className="confirm-buy-quantity-section">
|
||||||
<p className="confirm-buy-label">Quantity to buy:</p>
|
|
||||||
<div className="confirm-buy-counter">
|
<div className="confirm-buy-counter">
|
||||||
<button
|
<button
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import ClassificationSection from "../forms/ClassificationSection";
|
import AddImageModal from "./AddImageModal";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
||||||
const [itemName, setItemName] = useState(item.item_name || "");
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
const [itemGroup, setItemGroup] = useState("");
|
const [itemGroup, setItemGroup] = useState("");
|
||||||
const [zone, setZone] = useState("");
|
const [zone, setZone] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showImageModal, setShowImageModal] = useState(false);
|
||||||
|
|
||||||
// Load existing classification
|
// Load existing classification
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -58,44 +60,131 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (imageFile) => {
|
||||||
|
if (onImageUpdate) {
|
||||||
|
try {
|
||||||
|
await onImageUpdate(item.id, itemName, quantity, imageFile);
|
||||||
|
setShowImageModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload image:", error);
|
||||||
|
alert("Failed to upload image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementQuantity = () => {
|
||||||
|
setQuantity(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementQuantity = () => {
|
||||||
|
setQuantity(prev => Math.max(1, prev - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="edit-modal-title">Edit Item</h2>
|
<h2 className="edit-modal-title">Edit Item</h2>
|
||||||
|
|
||||||
<div className="edit-modal-field">
|
{/* Item Name - no label */}
|
||||||
<label>Item Name</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={itemName}
|
||||||
value={itemName}
|
onChange={(e) => setItemName(e.target.value)}
|
||||||
onChange={(e) => setItemName(e.target.value)}
|
className="edit-modal-input"
|
||||||
className="edit-modal-input"
|
placeholder="Item name"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="edit-modal-field">
|
{/* Quantity Control - like AddItemForm */}
|
||||||
<label>Quantity</label>
|
<div className="edit-modal-quantity-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quantity-btn quantity-btn-minus"
|
||||||
|
onClick={decrementQuantity}
|
||||||
|
disabled={quantity <= 1}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
className="edit-modal-quantity-input"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(parseInt(e.target.value))}
|
readOnly
|
||||||
className="edit-modal-input"
|
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quantity-btn quantity-btn-plus"
|
||||||
|
onClick={incrementQuantity}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
<ClassificationSection
|
{/* Inline Classification Fields */}
|
||||||
itemType={itemType}
|
<div className="edit-modal-inline-field">
|
||||||
itemGroup={itemGroup}
|
<label>Type</label>
|
||||||
zone={zone}
|
<select
|
||||||
onItemTypeChange={handleItemTypeChange}
|
value={itemType}
|
||||||
onItemGroupChange={setItemGroup}
|
onChange={(e) => handleItemTypeChange(e.target.value)}
|
||||||
onZoneChange={setZone}
|
className="edit-modal-select"
|
||||||
fieldClass="edit-modal-field"
|
>
|
||||||
selectClass="edit-modal-select"
|
<option value="">-- Select Type --</option>
|
||||||
/>
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className="edit-modal-inline-field">
|
||||||
|
<label>Group</label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => setItemGroup(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="edit-modal-inline-field">
|
||||||
|
<label>Zone</label>
|
||||||
|
<select
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => setZone(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Zone --</option>
|
||||||
|
{getZoneValues().map((z) => (
|
||||||
|
<option key={z} value={z}>
|
||||||
|
{z}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="edit-modal-btn edit-modal-btn-image"
|
||||||
|
onClick={() => setShowImageModal(true)}
|
||||||
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="edit-modal-actions">
|
<div className="edit-modal-actions">
|
||||||
<button
|
<button
|
||||||
@ -114,6 +203,14 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showImageModal && (
|
||||||
|
<AddImageModal
|
||||||
|
itemName={itemName}
|
||||||
|
onClose={() => setShowImageModal(false)}
|
||||||
|
onAddImage={handleImageUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
|
|||||||
<div className="image-modal-overlay" onClick={onClose}>
|
<div className="image-modal-overlay" onClick={onClose}>
|
||||||
<div className="image-modal-content" onClick={onClose}>
|
<div className="image-modal-content" onClick={onClose}>
|
||||||
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
||||||
<p className="image-modal-caption">{itemName}</p>
|
<p className="text-center mt-3 text-lg font-semibold">{itemName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,25 +2,22 @@ import "../../styles/SimilarItemModal.css";
|
|||||||
|
|
||||||
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
||||||
return (
|
return (
|
||||||
<div className="similar-item-modal-overlay" onClick={onCancel}>
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
<div className="similar-item-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Similar Item Found</h2>
|
<h2 className="modal-title">Similar Item Found</h2>
|
||||||
<p className="similar-item-question">
|
<p className="text-center text-lg mb-4">
|
||||||
Do you mean <strong>"{suggestedName}"</strong>?
|
Instead of <strong className="similar-item-original">"{originalName.toLowerCase()}"</strong>, use <strong className="similar-item-suggested">"{suggestedName}"</strong>?
|
||||||
</p>
|
|
||||||
<p className="similar-item-clarification">
|
|
||||||
You entered: "{originalName}"
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="similar-item-actions">
|
<div className="similar-modal-actions">
|
||||||
<button onClick={onCancel} className="similar-item-cancel">
|
<button onClick={onYes} className="btn btn-success">
|
||||||
Cancel
|
Yes, Use Suggestion
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onNo} className="similar-item-no">
|
<button onClick={onNo} className="btn btn-primary">
|
||||||
No, Create New
|
No, Create New
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onYes} className="similar-item-yes">
|
<button onClick={onCancel} className="btn btn-danger">
|
||||||
Yes, Use Suggestion
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-body);
|
background: var(--color-bg-body);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing-md);
|
padding: 0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@ -94,11 +99,6 @@ body {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
padding: var(--container-padding);
|
padding: var(--container-padding);
|
||||||
}
|
}
|
||||||
background: white;
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import './styles/theme.css'
|
import './styles/theme.css'
|
||||||
|
import './styles/utilities.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { getAllUsers, updateRole } from "../api/users";
|
import { getAllUsers, updateRole } from "../api/users";
|
||||||
import UserRoleCard from "../components/common/UserRoleCard";
|
import UserRoleCard from "../components/common/UserRoleCard";
|
||||||
import "../styles/UserRoleCard.css";
|
import "../styles/UserRoleCard.css";
|
||||||
|
import "../styles/pages/AdminPanel.css";
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@ -22,16 +23,18 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "2rem" }}>
|
<div className="p-4" style={{ minHeight: '100vh' }}>
|
||||||
<h1>Admin Panel</h1>
|
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
<div style={{ marginTop: "2rem" }}>
|
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
|
||||||
{users.map((user) => (
|
<div className="mt-4">
|
||||||
<UserRoleCard
|
{users.map((user) => (
|
||||||
key={user.id}
|
<UserRoleCard
|
||||||
user={user}
|
key={user.id}
|
||||||
onRoleChange={changeRole}
|
user={user}
|
||||||
/>
|
onRoleChange={changeRole}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,25 +1,40 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
import {
|
||||||
|
addItem,
|
||||||
|
getClassification,
|
||||||
|
getItemByName,
|
||||||
|
getList,
|
||||||
|
getRecentlyBought,
|
||||||
|
getSuggestions,
|
||||||
|
markBought,
|
||||||
|
updateItemImage,
|
||||||
|
updateItemWithClassification
|
||||||
|
} from "../api/list";
|
||||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
|
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
|
import { ZONE_FLOW } from "../constants/classifications";
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { role } = useContext(AuthContext);
|
const { role } = useContext(AuthContext);
|
||||||
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
|
// === State === //
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
||||||
const [sortedItems, setSortedItems] = useState([]);
|
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
||||||
const [sortMode, setSortMode] = useState("zone");
|
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(true);
|
const [showAddForm, setShowAddForm] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -30,7 +45,13 @@ export default function GroceryList() {
|
|||||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
||||||
|
const [collapsedZones, setCollapsedZones] = useState({});
|
||||||
|
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
||||||
|
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
|
// === Data Loading ===
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getList();
|
const res = await getList();
|
||||||
@ -39,6 +60,7 @@ export default function GroceryList() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const loadRecentlyBought = async () => {
|
const loadRecentlyBought = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRecentlyBought();
|
const res = await getRecentlyBought();
|
||||||
@ -49,13 +71,24 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems();
|
loadItems();
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let sorted = [...items];
|
// === Zone Collapse Handler ===
|
||||||
|
const toggleZoneCollapse = (zone) => {
|
||||||
|
setCollapsedZones(prev => ({
|
||||||
|
...prev,
|
||||||
|
[zone]: !prev[zone]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Sorted Items Computation ===
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
const sorted = [...items];
|
||||||
|
|
||||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||||
@ -64,11 +97,22 @@ export default function GroceryList() {
|
|||||||
if (sortMode === "zone") {
|
if (sortMode === "zone") {
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
// Items without classification go to the end
|
// Items without classification go to the end
|
||||||
if (!a.item_type && b.item_type) return 1;
|
if (!a.zone && b.zone) return 1;
|
||||||
if (a.item_type && !b.item_type) return -1;
|
if (a.zone && !b.zone) return -1;
|
||||||
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
||||||
|
|
||||||
// Sort by item_type
|
// Sort by ZONE_FLOW order
|
||||||
|
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
||||||
|
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
||||||
|
|
||||||
|
// If zone not in ZONE_FLOW, put at end
|
||||||
|
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
||||||
|
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||||
|
|
||||||
|
const zoneCompare = aIndex - bIndex;
|
||||||
|
if (zoneCompare !== 0) return zoneCompare;
|
||||||
|
|
||||||
|
// Then by item_type
|
||||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||||
if (typeCompare !== 0) return typeCompare;
|
if (typeCompare !== 0) return typeCompare;
|
||||||
|
|
||||||
@ -76,18 +120,16 @@ export default function GroceryList() {
|
|||||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||||
if (groupCompare !== 0) return groupCompare;
|
if (groupCompare !== 0) return groupCompare;
|
||||||
|
|
||||||
// Then by zone
|
|
||||||
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
|
||||||
if (zoneCompare !== 0) return zoneCompare;
|
|
||||||
|
|
||||||
// Finally by name
|
// Finally by name
|
||||||
return a.item_name.localeCompare(b.item_name);
|
return a.item_name.localeCompare(b.item_name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSortedItems(sorted);
|
return sorted;
|
||||||
}, [items, sortMode]);
|
}, [items, sortMode]);
|
||||||
|
|
||||||
|
|
||||||
|
// === Suggestion Handler ===
|
||||||
const handleSuggest = async (text) => {
|
const handleSuggest = async (text) => {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
@ -95,34 +137,27 @@ export default function GroceryList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine both unbought and recently bought items for similarity checking
|
|
||||||
const allItems = [...items, ...recentlyBoughtItems];
|
|
||||||
|
|
||||||
// Check if exact match exists (case-insensitive)
|
|
||||||
const lowerText = text.toLowerCase().trim();
|
const lowerText = text.toLowerCase().trim();
|
||||||
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
|
||||||
|
|
||||||
if (exactMatch) {
|
|
||||||
setButtonText("Add");
|
|
||||||
} else {
|
|
||||||
setButtonText("Create + Add");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let suggestions = await getSuggestions(text);
|
const response = await getSuggestions(text);
|
||||||
suggestions = suggestions.data.map(s => s.item_name);
|
const suggestionList = response.data.map(s => s.item_name);
|
||||||
setSuggestions(suggestions);
|
setSuggestions(suggestionList);
|
||||||
|
|
||||||
|
// All suggestions are now lowercase from DB, direct comparison
|
||||||
|
const exactMatch = suggestionList.includes(lowerText);
|
||||||
|
setButtonText(exactMatch ? "Add" : "Create + Add");
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setButtonText("Create + Add");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async (itemName, quantity) => {
|
|
||||||
|
// === Item Addition Handlers ===
|
||||||
|
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
|
|
||||||
const lowerItemName = itemName.toLowerCase().trim();
|
|
||||||
|
|
||||||
// First check if exact item exists in database (case-insensitive)
|
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(itemName);
|
||||||
@ -131,29 +166,27 @@ export default function GroceryList() {
|
|||||||
existingItem = null;
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If exact item exists, skip similarity check and process directly
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
await processItemAddition(itemName, quantity);
|
await processItemAddition(itemName, quantity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check for similar items if exact item doesn't exist
|
setItems(prevItems => {
|
||||||
const allItems = [...items, ...recentlyBoughtItems];
|
const allItems = [...prevItems, ...recentlyBoughtItems];
|
||||||
const similar = findSimilarItems(itemName, allItems, 70);
|
const similar = findSimilarItems(itemName, allItems, 70);
|
||||||
if (similar.length > 0) {
|
if (similar.length > 0) {
|
||||||
// Show modal and wait for user decision
|
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
setShowSimilarModal(true);
|
||||||
setShowSimilarModal(true);
|
return prevItems;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with normal flow for new items
|
processItemAddition(itemName, quantity);
|
||||||
await processItemAddition(itemName, quantity);
|
return prevItems;
|
||||||
};
|
});
|
||||||
|
}, [recentlyBoughtItems]);
|
||||||
|
|
||||||
const processItemAddition = async (itemName, quantity) => {
|
|
||||||
|
|
||||||
// Check if item exists in database (case-insensitive)
|
const processItemAddition = useCallback(async (itemName, quantity) => {
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(itemName);
|
||||||
@ -162,126 +195,205 @@ export default function GroceryList() {
|
|||||||
existingItem = null;
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem && existingItem.bought === false) {
|
if (existingItem?.bought === false) {
|
||||||
// Item exists and is unbought - update quantity
|
|
||||||
const currentQuantity = existingItem.quantity;
|
const currentQuantity = existingItem.quantity;
|
||||||
const newQuantity = currentQuantity + quantity;
|
const newQuantity = currentQuantity + quantity;
|
||||||
const yes = window.confirm(
|
|
||||||
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
|
||||||
);
|
|
||||||
if (!yes) return;
|
|
||||||
|
|
||||||
await addItem(itemName, newQuantity, null);
|
// Show modal instead of window.confirm
|
||||||
setSuggestions([]);
|
setConfirmAddExistingData({
|
||||||
setButtonText("Add Item");
|
itemName,
|
||||||
loadItems();
|
currentQuantity,
|
||||||
|
addingQuantity: quantity,
|
||||||
|
newQuantity,
|
||||||
|
existingItem
|
||||||
|
});
|
||||||
|
setShowConfirmAddExisting(true);
|
||||||
} else if (existingItem) {
|
} else if (existingItem) {
|
||||||
// Item exists in database (was previously bought) - just add quantity
|
|
||||||
await addItem(itemName, quantity, null);
|
await addItem(itemName, quantity, null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
|
||||||
|
// Reload lists to reflect the changes
|
||||||
|
await loadItems();
|
||||||
|
await loadRecentlyBought();
|
||||||
} else {
|
} else {
|
||||||
// NEW ITEM - show combined add details modal
|
|
||||||
setPendingItem({ itemName, quantity });
|
setPendingItem({ itemName, quantity });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSimilarCancel = () => {
|
|
||||||
|
// === Similar Item Modal Handlers ===
|
||||||
|
const handleSimilarCancel = useCallback(() => {
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSimilarNo = async () => {
|
|
||||||
|
const handleSimilarNo = useCallback(async () => {
|
||||||
if (!similarItemSuggestion) return;
|
if (!similarItemSuggestion) return;
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
// Create new item with original name
|
|
||||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
};
|
}, [similarItemSuggestion, processItemAddition]);
|
||||||
|
|
||||||
const handleSimilarYes = async () => {
|
|
||||||
|
const handleSimilarYes = useCallback(async () => {
|
||||||
if (!similarItemSuggestion) return;
|
if (!similarItemSuggestion) return;
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
// Use suggested item name
|
|
||||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
};
|
}, [similarItemSuggestion, processItemAddition]);
|
||||||
|
|
||||||
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
|
||||||
|
// === Confirm Add Existing Modal Handlers ===
|
||||||
|
const handleConfirmAddExisting = useCallback(async () => {
|
||||||
|
if (!confirmAddExistingData) return;
|
||||||
|
|
||||||
|
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
||||||
|
|
||||||
|
setShowConfirmAddExisting(false);
|
||||||
|
setConfirmAddExistingData(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the item
|
||||||
|
await addItem(itemName, newQuantity, null);
|
||||||
|
|
||||||
|
// Fetch the updated item with properly formatted data
|
||||||
|
const response = await getItemByName(itemName);
|
||||||
|
const updatedItem = response.data;
|
||||||
|
|
||||||
|
// Update state with the full item data
|
||||||
|
setItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === existingItem.id ? updatedItem : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSuggestions([]);
|
||||||
|
setButtonText("Add Item");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update item:", error);
|
||||||
|
// Fallback to full reload on error
|
||||||
|
await loadItems();
|
||||||
|
}
|
||||||
|
}, [confirmAddExistingData, loadItems]);
|
||||||
|
|
||||||
|
const handleCancelAddExisting = useCallback(() => {
|
||||||
|
setShowConfirmAddExisting(false);
|
||||||
|
setConfirmAddExistingData(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
// === Add Details Modal Handlers ===
|
||||||
|
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add item to grocery_list with image
|
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||||
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
let newItem = addResponse.data;
|
||||||
|
|
||||||
// If classification provided, add it
|
|
||||||
if (classification) {
|
if (classification) {
|
||||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||||
const itemId = itemResponse.data.id;
|
const itemId = itemResponse.data.id;
|
||||||
await updateItemWithClassification(itemId, undefined, undefined, classification);
|
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||||
|
newItem = { ...newItem, ...updateResponse.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
|
||||||
|
if (newItem) {
|
||||||
|
setItems(prevItems => [...prevItems, newItem]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
}, [pendingItem]);
|
||||||
|
|
||||||
const handleAddDetailsSkip = async () => {
|
|
||||||
|
const handleAddDetailsSkip = useCallback(async () => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add item without image or classification
|
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
|
||||||
|
if (response.data) {
|
||||||
|
setItems(prevItems => [...prevItems, response.data]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
}, [pendingItem]);
|
||||||
|
|
||||||
const handleAddDetailsCancel = () => {
|
|
||||||
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
// === Item Action Handlers ===
|
||||||
|
const handleBought = useCallback(async (id, quantity) => {
|
||||||
|
const item = items.find(i => i.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
await markBought(id, quantity);
|
||||||
|
|
||||||
|
// If buying full quantity, remove from list
|
||||||
|
if (quantity >= item.quantity) {
|
||||||
|
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
||||||
|
} else {
|
||||||
|
// If partial, update quantity
|
||||||
|
const response = await getItemByName(item.item_name);
|
||||||
|
if (response.data) {
|
||||||
|
setItems(prevItems =>
|
||||||
|
prevItems.map(item => item.id === id ? response.data : item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleBought = async (id, quantity) => {
|
|
||||||
await markBought(id);
|
|
||||||
loadItems();
|
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
};
|
}, [items]);
|
||||||
|
|
||||||
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
|
|
||||||
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||||
try {
|
try {
|
||||||
await updateItemImage(id, itemName, quantity, imageFile);
|
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
||||||
loadItems(); // Reload to show new image
|
|
||||||
|
setItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === id ? { ...item, ...response.data } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setRecentlyBoughtItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === id ? { ...item, ...response.data } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add image:", error);
|
console.error("Failed to add image:", error);
|
||||||
alert("Failed to add image. Please try again.");
|
alert("Failed to add image. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleLongPress = async (item) => {
|
|
||||||
|
const handleLongPress = useCallback(async (item) => {
|
||||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch existing classification
|
|
||||||
const classificationResponse = await getClassification(item.id);
|
const classificationResponse = await getClassification(item.id);
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...item,
|
...item,
|
||||||
@ -293,27 +405,42 @@ export default function GroceryList() {
|
|||||||
setEditingItem({ ...item, classification: null });
|
setEditingItem({ ...item, classification: null });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}
|
}
|
||||||
};
|
}, [role]);
|
||||||
|
|
||||||
const handleEditSave = async (id, itemName, quantity, classification) => {
|
|
||||||
|
// === Edit Modal Handlers ===
|
||||||
|
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||||
try {
|
try {
|
||||||
await updateItemWithClassification(id, itemName, quantity, classification);
|
const response = await updateItemWithClassification(id, itemName, quantity, classification);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
loadItems();
|
|
||||||
loadRecentlyBought();
|
const updatedItem = response.data;
|
||||||
|
setItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === id ? { ...item, ...updatedItem } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setRecentlyBoughtItems(prevItems =>
|
||||||
|
prevItems.map(item =>
|
||||||
|
item.id === id ? { ...item, ...updatedItem } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
throw error; // Re-throw to let modal handle it
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleEditCancel = () => {
|
|
||||||
|
const handleEditCancel = useCallback(() => {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Group items by zone for classification view
|
|
||||||
|
// === Helper Functions ===
|
||||||
const groupItemsByZone = (items) => {
|
const groupItemsByZone = (items) => {
|
||||||
const groups = {};
|
const groups = {};
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@ -326,8 +453,10 @@ export default function GroceryList() {
|
|||||||
return groups;
|
return groups;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <p>Loading...</p>;
|
if (loading) return <p>Loading...</p>;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
@ -346,43 +475,60 @@ export default function GroceryList() {
|
|||||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||||
|
|
||||||
{sortMode === "zone" ? (
|
{sortMode === "zone" ? (
|
||||||
// Grouped view by zone
|
|
||||||
(() => {
|
(() => {
|
||||||
const grouped = groupItemsByZone(sortedItems);
|
const grouped = groupItemsByZone(sortedItems);
|
||||||
return Object.keys(grouped).map(zone => (
|
return Object.keys(grouped).map(zone => {
|
||||||
<div key={zone} className="glist-classification-group">
|
const isCollapsed = collapsedZones[zone];
|
||||||
<h3 className="glist-classification-header">
|
const itemCount = grouped[zone].length;
|
||||||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
return (
|
||||||
</h3>
|
<div key={zone} className="glist-classification-group">
|
||||||
<ul className="glist-ul">
|
<h3
|
||||||
{grouped[zone].map((item) => (
|
className="glist-classification-header clickable"
|
||||||
<GroceryListItem
|
onClick={() => toggleZoneCollapse(zone)}
|
||||||
key={item.id}
|
>
|
||||||
item={item}
|
<span>
|
||||||
onClick={(quantity) =>
|
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
<span className="glist-zone-count"> ({itemCount})</span>
|
||||||
}
|
</span>
|
||||||
onImageAdded={
|
<span className="glist-zone-indicator">
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
{isCollapsed ? "▼" : "▲"}
|
||||||
}
|
</span>
|
||||||
onLongPress={
|
</h3>
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
{!isCollapsed && (
|
||||||
}
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
/>
|
{grouped[zone].map((item) => (
|
||||||
))}
|
<GroceryListItem
|
||||||
</ul>
|
key={item.id}
|
||||||
</div>
|
item={item}
|
||||||
));
|
allItems={sortedItems}
|
||||||
|
compact={settings.compactView}
|
||||||
|
onClick={(id, quantity) =>
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
||||||
|
}
|
||||||
|
onImageAdded={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
|
}
|
||||||
|
onLongPress={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
// Regular flat list view
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
<ul className="glist-ul">
|
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={(quantity) =>
|
allItems={sortedItems}
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
compact={settings.compactView}
|
||||||
|
onClick={(id, quantity) =>
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
||||||
}
|
}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
@ -395,33 +541,48 @@ export default function GroceryList() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentlyBoughtItems.length > 0 && (
|
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
||||||
<>
|
<>
|
||||||
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
<h2
|
||||||
<ul className="glist-ul">
|
className="glist-section-title clickable"
|
||||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
|
||||||
<GroceryListItem
|
>
|
||||||
key={item.id}
|
<span>Recently Bought (24HR)</span>
|
||||||
item={item}
|
<span className="glist-section-indicator">
|
||||||
onClick={null}
|
{recentlyBoughtCollapsed ? "▼" : "▲"}
|
||||||
onImageAdded={
|
</span>
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
</h2>
|
||||||
}
|
|
||||||
onLongPress={
|
{!recentlyBoughtCollapsed && (
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
<>
|
||||||
}
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
/>
|
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||||
))}
|
<GroceryListItem
|
||||||
</ul>
|
key={item.id}
|
||||||
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
item={item}
|
||||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
allItems={recentlyBoughtItems}
|
||||||
<button
|
compact={settings.compactView}
|
||||||
className="glist-show-more-btn"
|
onClick={null}
|
||||||
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
|
onImageAdded={
|
||||||
>
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
|
}
|
||||||
</button>
|
onLongPress={
|
||||||
</div>
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
className="glist-show-more-btn"
|
||||||
|
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
|
||||||
|
>
|
||||||
|
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -458,6 +619,17 @@ export default function GroceryList() {
|
|||||||
item={editingItem}
|
item={editingItem}
|
||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
|
onImageUpdate={handleImageAdded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showConfirmAddExisting && confirmAddExistingData && (
|
||||||
|
<ConfirmAddExistingModal
|
||||||
|
itemName={confirmAddExistingData.itemName}
|
||||||
|
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||||
|
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||||
|
onConfirm={handleConfirmAddExisting}
|
||||||
|
onCancel={handleCancelAddExisting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,16 +27,16 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-wrapper">
|
<div className="flex-center" style={{ minHeight: '100vh', padding: '1em', background: '#f8f9fa' }}>
|
||||||
<div className="login-box">
|
<div className="card card-elevated" style={{ width: '100%', maxWidth: '360px' }}>
|
||||||
<h1 className="login-title">Login</h1>
|
<h1 className="text-center text-2xl mb-3">Login</h1>
|
||||||
|
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
className="login-input"
|
className="form-input my-2"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -44,7 +44,7 @@ export default function Login() {
|
|||||||
<div className="login-password-wrapper">
|
<div className="login-password-wrapper">
|
||||||
<FormInput
|
<FormInput
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="login-input"
|
className="form-input"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -58,11 +58,11 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="login-button">Login</button>
|
<button type="submit" className="btn btn-primary btn-block mt-2">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="login-register">
|
<p className="text-center mt-3">
|
||||||
Need an account? <Link to="/register">Register here</Link>
|
Need an account? <Link to="/register" className="text-primary">Register here</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function Register() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="register-container">
|
<div className="register-container">
|
||||||
<h1>Register</h1>
|
<h1 className="text-center mb-4 text-2xl font-bold">Register</h1>
|
||||||
|
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
<ErrorMessage message={success} type="success" />
|
<ErrorMessage message={success} type="success" />
|
||||||
@ -67,6 +67,7 @@ export default function Register() {
|
|||||||
<form className="register-form" onSubmit={submit}>
|
<form className="register-form" onSubmit={submit}>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
|
className="form-input"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -74,6 +75,7 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
|
className="form-input"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onKeyUp={(e) => setUsername(e.target.value)}
|
onKeyUp={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -81,6 +83,7 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
|
className="form-input"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -88,18 +91,19 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
|
className="form-input"
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button disabled={error !== ""} type="submit">
|
<button disabled={error !== ""} type="submit" className="btn btn-primary btn-block mt-2">
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="register-link">
|
<p className="text-center mt-3">
|
||||||
Already have an account? <Link to="/login">Login here</Link>
|
Already have an account? <Link to="/login" className="text-primary font-semibold">Login here</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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 {
|
/* AddImageModal - custom styles for unique components */
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: fadeIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-modal {
|
|
||||||
background: white;
|
|
||||||
padding: 2em;
|
|
||||||
border-radius: 12px;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: slideUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-modal h2 {
|
|
||||||
margin: 0 0 0.5em 0;
|
|
||||||
font-size: 1.5em;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-subtitle {
|
|
||||||
margin: 0 0 1.5em 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-subtitle strong {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-options {
|
.add-image-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -48,32 +8,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn {
|
.add-image-option-btn {
|
||||||
padding: 1.2em;
|
padding: var(--spacing-lg);
|
||||||
border: 2px solid #ddd;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
font-size: 1.1em;
|
font-size: var(--font-size-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5em;
|
gap: var(--spacing-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn:hover {
|
.add-image-option-btn:hover {
|
||||||
border-color: #007bff;
|
border-color: var(--color-primary);
|
||||||
background: #f8f9fa;
|
background: var(--color-bg-hover);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn.camera {
|
.add-image-option-btn.camera {
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn.gallery {
|
.add-image-option-btn.gallery {
|
||||||
color: #28a745;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-preview-container {
|
.add-image-preview-container {
|
||||||
@ -86,9 +47,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
border: 2px solid #ddd;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-preview img {
|
.add-image-preview img {
|
||||||
@ -119,58 +81,3 @@
|
|||||||
.add-image-remove:hover {
|
.add-image-remove:hover {
|
||||||
background: rgba(255, 0, 0, 1);
|
background: rgba(255, 0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
margin-top: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-cancel,
|
|
||||||
.add-image-confirm {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.8em;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-cancel {
|
|
||||||
background: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-cancel:hover {
|
|
||||||
background: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-confirm {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-confirm:hover {
|
|
||||||
background: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(30px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,68 +4,128 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--modal-backdrop-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal);
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-modal {
|
.confirm-buy-modal {
|
||||||
background: white;
|
background: var(--modal-bg);
|
||||||
padding: 2em;
|
padding: var(--spacing-md);
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-xl);
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-xl);
|
||||||
animation: slideUp 0.3s ease-out;
|
animation: slideUp 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-modal h2 {
|
.confirm-buy-header {
|
||||||
margin: 0 0 0.5em 0;
|
|
||||||
font-size: 1.5em;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-zone {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-item-name {
|
.confirm-buy-item-name {
|
||||||
margin: 0 0 1.5em 0;
|
margin: 0;
|
||||||
font-size: 1.1em;
|
font-size: 1.2em;
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
|
border-radius: var(--border-radius-full);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-btn:disabled {
|
||||||
|
border-color: var(--color-border-medium);
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-container {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-placeholder {
|
||||||
|
font-size: 4em;
|
||||||
|
color: var(--color-border-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-quantity-section {
|
.confirm-buy-quantity-section {
|
||||||
margin: 2em 0;
|
margin: 0.8em 0;
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-label {
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
font-size: 1em;
|
|
||||||
color: #555;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter {
|
.confirm-buy-counter {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1em;
|
gap: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn {
|
.confirm-buy-counter-btn {
|
||||||
width: 50px;
|
width: 45px;
|
||||||
height: 50px;
|
height: 45px;
|
||||||
border: 2px solid #007bff;
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
font-size: 1.8em;
|
font-size: 1.6em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -74,67 +134,68 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn:hover:not(:disabled) {
|
.confirm-buy-counter-btn:hover:not(:disabled) {
|
||||||
background: #007bff;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn:disabled {
|
.confirm-buy-counter-btn:disabled {
|
||||||
border-color: #ccc;
|
border-color: var(--color-border-medium);
|
||||||
color: #ccc;
|
color: var(--color-text-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-display {
|
.confirm-buy-counter-display {
|
||||||
width: 80px;
|
width: 70px;
|
||||||
height: 50px;
|
height: 45px;
|
||||||
border: 2px solid #ddd;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5em;
|
font-size: 1.4em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
background: #f8f9fa;
|
background: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-display:focus {
|
.confirm-buy-counter-display:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-actions {
|
.confirm-buy-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 0.6em;
|
||||||
margin-top: 2em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel,
|
.confirm-buy-cancel,
|
||||||
.confirm-buy-confirm {
|
.confirm-buy-confirm {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.9em;
|
padding: 0.75em 0.5em;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
font-size: 1em;
|
font-size: 0.95em;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel {
|
.confirm-buy-cancel {
|
||||||
background: #f0f0f0;
|
background: var(--color-gray-200);
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel:hover {
|
.confirm-buy-cancel:hover {
|
||||||
background: #e0e0e0;
|
background: var(--color-gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-confirm {
|
.confirm-buy-confirm {
|
||||||
background: #28a745;
|
background: var(--color-success);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-confirm:hover {
|
.confirm-buy-confirm:hover {
|
||||||
background: #218838;
|
background: var(--color-success-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@ -156,3 +217,88 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.confirm-buy-modal {
|
||||||
|
padding: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-header {
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-zone {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-item-name {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-section {
|
||||||
|
gap: 0.5em;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-actions {
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-top: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-cancel,
|
||||||
|
.confirm-buy-confirm {
|
||||||
|
padding: 0.7em 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-container {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-counter-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-counter-display {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-quantity-section {
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.confirm-buy-modal {
|
||||||
|
padding: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-cancel,
|
||||||
|
.confirm-buy-confirm {
|
||||||
|
padding: 0.65em 0.3em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-container {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
/* ImageModal - specialized full-screen image viewer */
|
||||||
|
|
||||||
.image-modal-overlay {
|
.image-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -44,30 +46,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: -15px;
|
|
||||||
right: -15px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ff4444;
|
|
||||||
color: white;
|
|
||||||
border: 3px solid white;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1001;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-close:hover {
|
|
||||||
background: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
@ -76,14 +54,6 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-modal-caption {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.image-modal-overlay {
|
.image-modal-overlay {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@ -92,8 +62,5 @@
|
|||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-modal-caption {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,115 +1,24 @@
|
|||||||
.similar-item-modal-overlay {
|
/* SimilarItemModal - custom styles */
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: fadeIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-modal {
|
.similar-item-suggested {
|
||||||
background: white;
|
color: var(--color-success);
|
||||||
padding: 2em;
|
font-weight: 600;
|
||||||
border-radius: 12px;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: slideUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-modal h2 {
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
font-size: 1.5em;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-question {
|
|
||||||
margin: 0 0 0.5em 0;
|
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-item-question strong {
|
.similar-item-original {
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-item-clarification {
|
.similar-modal-actions {
|
||||||
margin: 0 0 2em 0;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: #666;
|
|
||||||
text-align: center;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.8em;
|
flex-direction: column;
|
||||||
margin-top: 1.5em;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-item-cancel,
|
.similar-modal-actions .btn {
|
||||||
.similar-item-no,
|
width: 100%;
|
||||||
.similar-item-yes {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.8em;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-item-cancel {
|
|
||||||
background: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-cancel:hover {
|
|
||||||
background: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-no {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-no:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-yes {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-yes:hover {
|
|
||||||
background: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(30px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,40 +1,13 @@
|
|||||||
.user-card {
|
/* UserRoleCard - custom styles only */
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-username {
|
.user-username {
|
||||||
color: #666;
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-select {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-select:hover {
|
|
||||||
border-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -29,6 +29,12 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-form-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-input:focus {
|
.add-item-form-input:focus {
|
||||||
@ -107,6 +113,8 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +141,8 @@
|
|||||||
border-radius: var(--button-border-radius);
|
border-radius: var(--button-border-radius);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: var(--button-font-weight);
|
||||||
cursor: pointer;
|
flex: 1;
|
||||||
|
min-width: 120px
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
@ -150,12 +159,13 @@
|
|||||||
|
|
||||||
.add-item-form-submit.disabled,
|
.add-item-form-submit.disabled,
|
||||||
.add-item-form-submit:disabled {
|
.add-item-form-submit:disabled {
|
||||||
background: var(--color-bg-disabled);
|
background: var(--color-gray-400);
|
||||||
color: var(--color-text-disabled);
|
color: var(--color-gray-600);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 1;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
border: var(--border-width-thin) solid var(--color-gray-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
|||||||
@ -4,43 +4,43 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--modal-backdrop-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal);
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
background: white;
|
background: var(--modal-bg);
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-xl);
|
||||||
padding: 1.5em;
|
padding: var(--spacing-xl);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: 1.4em;
|
font-size: var(--font-size-xl);
|
||||||
margin: 0 0 0.3em 0;
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-subtitle {
|
.add-item-details-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: var(--color-text-secondary);
|
||||||
margin: 0 0 1.5em 0;
|
margin: 0 0 var(--spacing-xl) 0;
|
||||||
font-size: 0.9em;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section {
|
.add-item-details-section {
|
||||||
margin-bottom: 1.5em;
|
margin-bottom: var(--spacing-xl);
|
||||||
padding-bottom: 1.5em;
|
padding-bottom: var(--spacing-xl);
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section:last-of-type {
|
.add-item-details-section:last-of-type {
|
||||||
@ -48,9 +48,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section-title {
|
.add-item-details-section-title {
|
||||||
font-size: 1.1em;
|
font-size: var(--font-size-lg);
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
color: #555;
|
color: var(--color-text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,27 +68,27 @@
|
|||||||
.add-item-details-image-btn {
|
.add-item-details-image-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: 0.8em;
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
border: 2px solid #007bff;
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: var(--button-font-weight);
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-btn:hover {
|
.add-item-details-image-btn:hover {
|
||||||
background: #007bff;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-preview {
|
.add-item-details-image-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid #e0e0e0;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-preview img {
|
.add-item-details-image-preview img {
|
||||||
|
|||||||
@ -1,44 +1,45 @@
|
|||||||
/* Classification Section */
|
/* Classification Section */
|
||||||
.classification-section {
|
.classification-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-title {
|
.classification-title {
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: var(--spacing-md);
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-field {
|
.classification-field {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-field label {
|
.classification-field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.9em;
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: var(--spacing-xs);
|
||||||
color: #555;
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select {
|
.classification-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem;
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border: 1px solid #ccc;
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--input-border-radius);
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select:focus {
|
.classification-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--input-focus-border-color);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
box-shadow: var(--input-focus-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select:hover {
|
.classification-select:hover {
|
||||||
border-color: #999;
|
border-color: var(--color-border-dark);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--modal-backdrop-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal);
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-content {
|
.edit-modal-content {
|
||||||
background: white;
|
background: var(--modal-bg);
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-xl);
|
||||||
padding: 1.5em;
|
padding: var(--spacing-lg);
|
||||||
max-width: 480px;
|
max-width: 420px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-title {
|
.edit-modal-title {
|
||||||
font-size: 1.5em;
|
font-size: var(--font-size-xl);
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-subtitle {
|
|
||||||
font-size: 1.1em;
|
|
||||||
margin: 0.5em 0 0.8em 0;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-field {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-field label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.3em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-input,
|
.edit-modal-input,
|
||||||
.edit-modal-select {
|
.edit-modal-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6em;
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border: 1px solid #ccc;
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--input-border-radius);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: border-color 0.2s;
|
transition: var(--transition-base);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-input:focus,
|
.edit-modal-input:focus,
|
||||||
.edit-modal-select:focus {
|
.edit-modal-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--input-focus-border-color);
|
||||||
|
box-shadow: var(--input-focus-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quantity Control - matching AddItemForm */
|
||||||
|
.edit-modal-quantity-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-quantity-input {
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
|
border-radius: var(--input-border-radius);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Classification Fields */
|
||||||
|
.edit-modal-inline-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-inline-field label {
|
||||||
|
min-width: 60px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-inline-field .edit-modal-select {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-divider {
|
.edit-modal-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #e0e0e0;
|
background: var(--color-border-light);
|
||||||
margin: 1.5em 0;
|
margin: var(--spacing-md) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-actions {
|
.edit-modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.8em;
|
gap: var(--spacing-sm);
|
||||||
margin-top: 1.5em;
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn {
|
.edit-modal-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.7em;
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--button-border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: var(--button-font-weight);
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn:disabled {
|
.edit-modal-btn:disabled {
|
||||||
@ -94,19 +147,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-cancel {
|
.edit-modal-btn-cancel {
|
||||||
background: #6c757d;
|
background: var(--color-secondary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-cancel:hover:not(:disabled) {
|
.edit-modal-btn-cancel:hover:not(:disabled) {
|
||||||
background: #5a6268;
|
background: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-save {
|
.edit-modal-btn-save {
|
||||||
background: #007bff;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-save:hover:not(:disabled) {
|
.edit-modal-btn-save:hover:not(:disabled) {
|
||||||
background: #0056b3;
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-image {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
border: var(--border-width-medium) solid var(--color-success);
|
||||||
|
border-radius: var(--button-border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
transition: var(--transition-base);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-success);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-image:hover:not(:disabled) {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-image:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
color: var(--color-gray-700);
|
||||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||||
padding-top: var(--spacing-md);
|
padding-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-section-title.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
user-select: none;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-section-title.clickable:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-section-indicator {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-section-header .glist-section-title {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-collapse-btn {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: var(--button-border-radius);
|
||||||
|
transition: var(--transition-base);
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-collapse-btn:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Groups */
|
/* Classification Groups */
|
||||||
@ -45,6 +103,34 @@
|
|||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-classification-header.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-classification-header.clickable:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-zone-count {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-zone-indicator {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
@ -94,49 +180,52 @@
|
|||||||
|
|
||||||
/* Suggestion dropdown */
|
/* Suggestion dropdown */
|
||||||
.glist-suggest-box {
|
.glist-suggest-box {
|
||||||
background: #fff;
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid #ccc;
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 999;
|
z-index: var(--z-dropdown);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
box-shadow: var(--shadow-card);
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
width: calc(100% - 8em);
|
width: calc(100% - 8em);
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-suggest-item {
|
.glist-suggest-item {
|
||||||
padding: 0.5em;
|
padding: var(--spacing-sm);
|
||||||
padding-inline: 2em;
|
padding-inline: var(--spacing-xl);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-suggest-item:hover {
|
.glist-suggest-item:hover {
|
||||||
background: #eee;
|
background: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grocery list items */
|
/* Grocery list items */
|
||||||
.glist-ul {
|
.glist-ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: 1em;
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-li {
|
.glist-li {
|
||||||
background: #fff;
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid #e0e0e0;
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
margin-bottom: 0.8em;
|
margin-bottom: var(--spacing-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, transform 0.2s;
|
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-li:hover {
|
.glist-li:hover {
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,21 +240,21 @@
|
|||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
min-width: 50px;
|
min-width: 50px;
|
||||||
background: #f5f5f5;
|
background: var(--color-gray-100);
|
||||||
border: 2px solid #e0e0e0;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
color: #ccc;
|
color: var(--color-border-medium);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image.has-image {
|
.glist-item-image.has-image {
|
||||||
border-color: #007bff;
|
border-color: var(--color-primary);
|
||||||
background: #fff;
|
background: var(--color-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image img {
|
.glist-item-image img {
|
||||||
@ -174,11 +263,6 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image.has-image:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-item-content {
|
.glist-item-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -197,37 +281,69 @@
|
|||||||
.glist-item-name {
|
.glist-item-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-quantity {
|
.glist-item-quantity {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: rgba(0, 123, 255, 0.9);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.3em;
|
font-size: 0.3em;
|
||||||
padding: 0.2em 0.4em;
|
padding: 0.2em 0.4em;
|
||||||
border-radius: 0 6px 0 4px;
|
border-radius: 0 var(--border-radius-md) 0 var(--border-radius-sm);
|
||||||
min-width: 20%;
|
min-width: 20%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-users {
|
.glist-item-users {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: #888;
|
color: var(--color-text-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact View */
|
||||||
|
.glist-ul.compact .glist-li {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-ul.compact .glist-item-layout {
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-ul.compact .glist-item-image {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-ul.compact .glist-item-quantity {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-ul.compact .glist-item-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-ul.compact .glist-item-users {
|
||||||
|
font-size: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sorting dropdown */
|
/* Sorting dropdown */
|
||||||
.glist-sort {
|
.glist-sort {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0.3em 0;
|
margin: var(--spacing-xs) 0;
|
||||||
padding: 0.5em;
|
padding: var(--spacing-sm);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image upload */
|
/* Image upload */
|
||||||
@ -237,18 +353,19 @@
|
|||||||
|
|
||||||
.glist-image-label {
|
.glist-image-label {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.6em;
|
padding: var(--spacing-sm);
|
||||||
background: #f0f0f0;
|
background: var(--color-gray-100);
|
||||||
border: 2px dashed #ccc;
|
border: var(--border-width-medium) dashed var(--color-border-medium);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-sm);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-image-label:hover {
|
.glist-image-label:hover {
|
||||||
background: #e8e8e8;
|
background: var(--color-bg-hover);
|
||||||
border-color: #007bff;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-image-preview {
|
.glist-image-preview {
|
||||||
@ -260,8 +377,8 @@
|
|||||||
.glist-image-preview img {
|
.glist-image-preview img {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
border: 2px solid #ddd;
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-remove-image {
|
.glist-remove-image {
|
||||||
@ -270,10 +387,10 @@
|
|||||||
right: -8px;
|
right: -8px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: var(--border-radius-full);
|
||||||
background: #ff4444;
|
background: var(--color-danger);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
border: 2px solid white;
|
border: var(--border-width-medium) solid var(--color-bg-surface);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -283,7 +400,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glist-remove-image:hover {
|
.glist-remove-image:hover {
|
||||||
background: #cc0000;
|
background: var(--color-danger-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating Action Button (FAB) */
|
/* Floating Action Button (FAB) */
|
||||||
@ -291,10 +408,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: #28a745;
|
background: var(--color-success);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: var(--border-radius-full);
|
||||||
width: 62px;
|
width: 62px;
|
||||||
height: 62px;
|
height: 62px;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
@ -302,12 +419,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
box-shadow: var(--shadow-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-fab:hover {
|
.glist-fab:hover {
|
||||||
background: #218838;
|
background: var(--color-success-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile tweaks */
|
/* Mobile tweaks */
|
||||||
|
|||||||
@ -1,37 +1,4 @@
|
|||||||
.login-wrapper {
|
/* Login page - custom password toggle only */
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
padding: 1em;
|
|
||||||
background: #f8f9fa;
|
|
||||||
min-height: 100vh;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
background: white;
|
|
||||||
padding: 1.5em;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.6em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.6em;
|
|
||||||
margin: 0.4em 0;
|
|
||||||
font-size: 1em;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-password-wrapper {
|
.login-password-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -40,7 +7,7 @@
|
|||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-password-wrapper .login-input {
|
.login-password-wrapper .form-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -67,38 +34,3 @@
|
|||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.7em;
|
|
||||||
margin-top: 0.6em;
|
|
||||||
background: #007bff;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
background: #0068d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-error {
|
|
||||||
color: red;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 0.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-register {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-register a {
|
|
||||||
color: #007bff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-register a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1,12 @@
|
|||||||
|
/* Register page - container only */
|
||||||
|
|
||||||
.register-container {
|
.register-container {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #ffffff;
|
background: var(--color-bg-primary);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-lg);
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-container h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.register-form {
|
.register-form {
|
||||||
@ -21,64 +15,3 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.register-form input {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-form input:focus {
|
|
||||||
border-color: #0077ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-form button {
|
|
||||||
padding: 12px;
|
|
||||||
border: none;
|
|
||||||
background: #0077ff;
|
|
||||||
color: white;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 10px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-form button:hover:not(:disabled) {
|
|
||||||
background: #005fcc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-form button:disabled {
|
|
||||||
background: #a8a8a8;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
height: 15px;
|
|
||||||
color: red;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
color: green;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-link {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-link a {
|
|
||||||
color: #0077ff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.register-link a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|||||||
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;
|
--modal-max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK MODE
|
||||||
|
============================================ */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Primary Colors */
|
||||||
|
--color-primary: #4da3ff;
|
||||||
|
--color-primary-hover: #66b3ff;
|
||||||
|
--color-primary-light: #1a3a52;
|
||||||
|
--color-primary-dark: #3d8fdb;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-success: #4ade80;
|
||||||
|
--color-success-hover: #5fe88d;
|
||||||
|
--color-success-light: #1a3a28;
|
||||||
|
|
||||||
|
--color-danger: #f87171;
|
||||||
|
--color-danger-hover: #fa8585;
|
||||||
|
--color-danger-light: #4a2020;
|
||||||
|
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-warning-hover: #fcd34d;
|
||||||
|
--color-warning-light: #3a2f0f;
|
||||||
|
|
||||||
|
--color-info: #38bdf8;
|
||||||
|
--color-info-hover: #5dc9fc;
|
||||||
|
--color-info-light: #1a2f3a;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text-primary: #f1f5f9;
|
||||||
|
--color-text-secondary: #94a3b8;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-text-inverse: #1e293b;
|
||||||
|
--color-text-disabled: #475569;
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
--color-bg-body: #0f172a;
|
||||||
|
--color-bg-surface: #1e293b;
|
||||||
|
--color-bg-hover: #334155;
|
||||||
|
--color-bg-disabled: #1e293b;
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--color-border-light: #334155;
|
||||||
|
--color-border-medium: #475569;
|
||||||
|
--color-border-dark: #64748b;
|
||||||
|
--color-border-disabled: #334155;
|
||||||
|
|
||||||
|
/* Neutral Colors - Dark adjusted */
|
||||||
|
--color-gray-50: #1e293b;
|
||||||
|
--color-gray-100: #1e293b;
|
||||||
|
--color-gray-200: #334155;
|
||||||
|
--color-gray-300: #475569;
|
||||||
|
--color-gray-400: #64748b;
|
||||||
|
--color-gray-500: #94a3b8;
|
||||||
|
--color-gray-600: #cbd5e1;
|
||||||
|
--color-gray-700: #e2e8f0;
|
||||||
|
--color-gray-800: #f1f5f9;
|
||||||
|
--color-gray-900: #f8fafc;
|
||||||
|
|
||||||
|
/* Shadows - Lighter for dark mode */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
||||||
|
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
--modal-backdrop-bg: rgba(0, 0, 0, 0.8);
|
||||||
|
--modal-bg: var(--color-bg-surface);
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-border-color: var(--color-border-medium);
|
||||||
|
--input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3);
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
--card-bg: var(--color-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
DARK MODE SUPPORT (Future Implementation)
|
DARK MODE SUPPORT (Future Implementation)
|
||||||
============================================ */
|
============================================ */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
/* Uncomment to enable dark mode
|
/* Auto mode will use data-theme attribute set by JS */
|
||||||
:root {
|
|
||||||
--color-text-primary: #f8f9fa;
|
|
||||||
--color-text-secondary: #adb5bd;
|
|
||||||
--color-bg-body: #212529;
|
|
||||||
--color-bg-surface: #343a40;
|
|
||||||
--color-border-light: #495057;
|
|
||||||
--color-border-medium: #6c757d;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Manual dark mode class override */
|
|
||||||
|
/* Manual dark mode class override (deprecated - use data-theme) */
|
||||||
.dark-mode {
|
.dark-mode {
|
||||||
--color-text-primary: #f8f9fa;
|
--color-text-primary: #f8f9fa;
|
||||||
--color-text-secondary: #adb5bd;
|
--color-text-secondary: #adb5bd;
|
||||||
|
|||||||
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