Compare commits
No commits in common. "1281c91c28d6437ef09062f4a0c62e89ba4252c3" and "8d5b2d3ea31ba436f53bf5248f1a842a11ee0b13" have entirely different histories.
1281c91c28
...
8d5b2d3ea3
@ -1,198 +0,0 @@
|
|||||||
# Account Management Implementation (Phase 4)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Phase 4 adds account management features allowing users to:
|
|
||||||
- Change their display name (friendly name separate from username)
|
|
||||||
- Change their password with current password verification
|
|
||||||
|
|
||||||
## Database Changes
|
|
||||||
|
|
||||||
### Migration: `add_display_name_column.sql`
|
|
||||||
```sql
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
|
|
||||||
|
|
||||||
UPDATE users
|
|
||||||
SET display_name = name
|
|
||||||
WHERE display_name IS NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
**To run migration:**
|
|
||||||
Connect to your PostgreSQL database and execute:
|
|
||||||
```bash
|
|
||||||
psql -U your_user -d your_database -f backend/migrations/add_display_name_column.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend Implementation
|
|
||||||
|
|
||||||
### New Model Functions (`backend/models/user.model.js`)
|
|
||||||
- `getUserById(id)` - Fetch user by ID including display_name
|
|
||||||
- `updateUserProfile(id, updates)` - Update user profile (display_name)
|
|
||||||
- `updateUserPassword(id, hashedPassword)` - Update user password
|
|
||||||
- `getUserPasswordHash(id)` - Get current password hash for verification
|
|
||||||
|
|
||||||
### New Controllers (`backend/controllers/users.controller.js`)
|
|
||||||
- `getCurrentUser` - GET authenticated user's profile
|
|
||||||
- `updateCurrentUser` - PATCH user's display name
|
|
||||||
- Validates: 1-100 characters, trims whitespace
|
|
||||||
- `changePassword` - POST password change
|
|
||||||
- Validates: current password correct, new password min 6 chars, passwords match
|
|
||||||
- Uses bcrypt for password verification and hashing
|
|
||||||
|
|
||||||
### New Routes (`backend/routes/users.routes.js`)
|
|
||||||
All routes require authentication (`auth` middleware):
|
|
||||||
- `GET /api/users/me` - Get current user profile
|
|
||||||
- `PATCH /api/users/me` - Update display name
|
|
||||||
- `POST /api/users/me/change-password` - Change password
|
|
||||||
|
|
||||||
**Request bodies:**
|
|
||||||
```javascript
|
|
||||||
// Update display name
|
|
||||||
PATCH /api/users/me
|
|
||||||
{
|
|
||||||
"display_name": "New Display Name"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change password
|
|
||||||
POST /api/users/me/change-password
|
|
||||||
{
|
|
||||||
"current_password": "oldpass123",
|
|
||||||
"new_password": "newpass123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Implementation
|
|
||||||
|
|
||||||
### API Functions (`frontend/src/api/users.js`)
|
|
||||||
- `getCurrentUser()` - Fetch current user profile
|
|
||||||
- `updateCurrentUser(display_name)` - Update display name
|
|
||||||
- `changePassword(current_password, new_password)` - Change password
|
|
||||||
|
|
||||||
### Settings Page Updates (`frontend/src/pages/Settings.jsx`)
|
|
||||||
Added new "Account" tab with two sections:
|
|
||||||
|
|
||||||
**Display Name Section:**
|
|
||||||
- Input field with character counter (max 100)
|
|
||||||
- Real-time validation
|
|
||||||
- Save button with loading state
|
|
||||||
|
|
||||||
**Password Change Section:**
|
|
||||||
- Current password field
|
|
||||||
- New password field (min 6 chars)
|
|
||||||
- Confirm password field
|
|
||||||
- Client-side validation before submission
|
|
||||||
- Loading state during submission
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Success/error messages displayed at top of tab
|
|
||||||
- Form validation (character limits, password matching)
|
|
||||||
- Disabled buttons during API calls
|
|
||||||
- Auto-clears password fields on success
|
|
||||||
|
|
||||||
### Styling (`frontend/src/styles/pages/Settings.css`)
|
|
||||||
Added:
|
|
||||||
- `.account-form` - Form container styling
|
|
||||||
- `.account-message` - Success/error message styling
|
|
||||||
- `.account-message.success` - Green success messages
|
|
||||||
- `.account-message.error` - Red error messages
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
### Password Requirements
|
|
||||||
- **Backend validation:**
|
|
||||||
- Minimum 6 characters
|
|
||||||
- Current password verification before change
|
|
||||||
- bcrypt hashing (10 rounds)
|
|
||||||
|
|
||||||
- **Frontend validation:**
|
|
||||||
- HTML5 minlength attribute
|
|
||||||
- Client-side password matching check
|
|
||||||
- Current password required
|
|
||||||
|
|
||||||
### Display Name Validation
|
|
||||||
- **Backend:**
|
|
||||||
- 1-100 character limit
|
|
||||||
- Whitespace trimming
|
|
||||||
|
|
||||||
- **Frontend:**
|
|
||||||
- HTML5 maxlength attribute
|
|
||||||
- Character counter
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
1. Navigate to Settings → Account tab
|
|
||||||
2. **Change Display Name:**
|
|
||||||
- Enter new display name (1-100 chars)
|
|
||||||
- Click "Save Display Name"
|
|
||||||
3. **Change Password:**
|
|
||||||
- Enter current password
|
|
||||||
- Enter new password (min 6 chars)
|
|
||||||
- Confirm new password
|
|
||||||
- Click "Change Password"
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
**Testing the endpoints:**
|
|
||||||
```bash
|
|
||||||
# Get current user
|
|
||||||
curl -X GET http://localhost:5000/api/users/me \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
|
||||||
|
|
||||||
# Update display name
|
|
||||||
curl -X PATCH http://localhost:5000/api/users/me \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"display_name": "New Name"}'
|
|
||||||
|
|
||||||
# Change password
|
|
||||||
curl -X POST http://localhost:5000/api/users/me/change-password \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"current_password": "oldpass",
|
|
||||||
"new_password": "newpass"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### Optional Enhancements
|
|
||||||
1. **Password strength indicator** - Visual feedback on password complexity
|
|
||||||
2. **Display name in navbar** - Show display_name instead of username in UI
|
|
||||||
3. **Email verification** - Add email field and verification
|
|
||||||
4. **2FA support** - Two-factor authentication option
|
|
||||||
5. **Password history** - Prevent reusing recent passwords
|
|
||||||
6. **Session management** - View/revoke active sessions
|
|
||||||
|
|
||||||
### Integration with AuthContext
|
|
||||||
Consider updating `AuthContext` to:
|
|
||||||
- Store and expose display_name
|
|
||||||
- Refresh user data after profile updates
|
|
||||||
- Show display_name in navbar/profile components
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- ✅ `backend/migrations/add_display_name_column.sql` (NEW)
|
|
||||||
- ✅ `backend/models/user.model.js`
|
|
||||||
- ✅ `backend/controllers/users.controller.js`
|
|
||||||
- ✅ `backend/routes/users.routes.js`
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- ✅ `frontend/src/api/users.js`
|
|
||||||
- ✅ `frontend/src/pages/Settings.jsx`
|
|
||||||
- ✅ `frontend/src/styles/pages/Settings.css`
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Run database migration
|
|
||||||
- [ ] Test GET /api/users/me endpoint
|
|
||||||
- [ ] Test display name update with valid data
|
|
||||||
- [ ] Test display name update with invalid data (empty, too long)
|
|
||||||
- [ ] Test password change with correct current password
|
|
||||||
- [ ] Test password change with incorrect current password
|
|
||||||
- [ ] Test password change with mismatched new passwords
|
|
||||||
- [ ] Test password change with weak password (< 6 chars)
|
|
||||||
- [ ] Verify frontend validation prevents invalid submissions
|
|
||||||
- [ ] Verify success/error messages display correctly
|
|
||||||
- [ ] Test UI responsiveness on mobile
|
|
||||||
@ -32,8 +32,7 @@ exports.addItem = async (req, res) => {
|
|||||||
|
|
||||||
exports.markBought = async (req, res) => {
|
exports.markBought = async (req, res) => {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { id, quantity } = req.body;
|
await List.setBought(req.body.id, userId);
|
||||||
await List.setBought(id, userId, quantity);
|
|
||||||
res.json({ message: "Item marked bought" });
|
res.json({ message: "Item marked bought" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
const User = require("../models/user.model");
|
const User = require("../models/user.model");
|
||||||
const bcrypt = require("bcryptjs");
|
|
||||||
|
|
||||||
exports.test = async (req, res) => {
|
exports.test = async (req, res) => {
|
||||||
console.log("User route is working");
|
console.log("User route is working");
|
||||||
@ -46,92 +45,11 @@ exports.deleteUser = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
exports.checkIfUserExists = async (req, res) => {
|
exports.checkIfUserExists = async (req, res) => {
|
||||||
const { username } = req.query;
|
const { username } = req.query;
|
||||||
const exists = await User.checkIfUserExists(username);
|
const users = await User.checkIfUserExists(username);
|
||||||
res.json(exists);
|
res.json(users);
|
||||||
};
|
|
||||||
|
|
||||||
exports.getCurrentUser = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const user = await User.getUserById(userId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(user);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error getting current user:", err);
|
|
||||||
res.status(500).json({ error: "Failed to get user profile" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateCurrentUser = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const { display_name } = req.body;
|
|
||||||
|
|
||||||
if (!display_name || display_name.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: "Display name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (display_name.length > 100) {
|
|
||||||
return res.status(400).json({ error: "Display name must be 100 characters or less" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() });
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: "Profile updated successfully", user: updated });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error updating user profile:", err);
|
|
||||||
res.status(500).json({ error: "Failed to update profile" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.changePassword = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const { current_password, new_password } = req.body;
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!current_password || !new_password) {
|
|
||||||
return res.status(400).json({ error: "Current password and new password are required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new_password.length < 6) {
|
|
||||||
return res.status(400).json({ error: "New password must be at least 6 characters" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current password hash
|
|
||||||
const currentHash = await User.getUserPasswordHash(userId);
|
|
||||||
|
|
||||||
if (!currentHash) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify current password
|
|
||||||
const isValidPassword = await bcrypt.compare(current_password, currentHash);
|
|
||||||
|
|
||||||
if (!isValidPassword) {
|
|
||||||
return res.status(401).json({ error: "Current password is incorrect" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash new password
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
const hashedPassword = await bcrypt.hash(new_password, salt);
|
|
||||||
|
|
||||||
// Update password
|
|
||||||
await User.updateUserPassword(userId, hashedPassword);
|
|
||||||
|
|
||||||
res.json({ message: "Password changed successfully" });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error changing password:", err);
|
|
||||||
res.status(500).json({ error: "Failed to change password" });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
-- Add display_name column to users table
|
|
||||||
-- This allows users to have a friendly name separate from their username
|
|
||||||
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
|
|
||||||
|
|
||||||
-- Set display_name to name for existing users (as default)
|
|
||||||
UPDATE users
|
|
||||||
SET display_name = name
|
|
||||||
WHERE display_name IS NULL;
|
|
||||||
@ -11,14 +11,15 @@ 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(DISTINCT u.name)
|
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT gh.added_by
|
SELECT gh.added_by, gh.added_on,
|
||||||
|
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,
|
||||||
@ -34,30 +35,7 @@ exports.getUnboughtItems = async () => {
|
|||||||
|
|
||||||
exports.getItemByName = async (itemName) => {
|
exports.getItemByName = async (itemName) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
"SELECT * FROM grocery_list WHERE item_name ILIKE $1",
|
||||||
gl.id,
|
|
||||||
LOWER(gl.item_name) AS item_name,
|
|
||||||
gl.quantity,
|
|
||||||
gl.bought,
|
|
||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
|
||||||
gl.image_mime_type,
|
|
||||||
(
|
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT gh.added_by
|
|
||||||
FROM grocery_history gh
|
|
||||||
WHERE gh.list_item_id = gl.id
|
|
||||||
ORDER BY gh.added_by
|
|
||||||
) gh
|
|
||||||
JOIN users u ON gh.added_by = u.id
|
|
||||||
) as added_by_users,
|
|
||||||
gl.modified_on as last_added_on,
|
|
||||||
ic.item_type,
|
|
||||||
ic.item_group,
|
|
||||||
ic.zone
|
|
||||||
FROM grocery_list gl
|
|
||||||
LEFT JOIN item_classification ic ON gl.id = ic.id
|
|
||||||
WHERE gl.item_name ILIKE $1`,
|
|
||||||
[itemName]
|
[itemName]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -110,31 +88,11 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.setBought = async (id, userId, quantityBought) => {
|
exports.setBought = async (id, userId) => {
|
||||||
// Get current item
|
|
||||||
const item = await pool.query(
|
|
||||||
"SELECT quantity FROM grocery_list WHERE id = $1",
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item.rows[0]) return;
|
|
||||||
|
|
||||||
const currentQuantity = item.rows[0].quantity;
|
|
||||||
const remainingQuantity = currentQuantity - quantityBought;
|
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
|
||||||
// Mark as bought if all quantity is purchased
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Reduce quantity if partial purchase
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
|
||||||
[remainingQuantity, id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -149,7 +107,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 LOWER(item_name) as item_name
|
`SELECT DISTINCT item_name
|
||||||
FROM grocery_list
|
FROM grocery_list
|
||||||
WHERE item_name ILIKE $1
|
WHERE item_name ILIKE $1
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
@ -168,14 +126,15 @@ 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(DISTINCT u.name)
|
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT gh.added_by
|
SELECT gh.added_by, gh.added_on,
|
||||||
|
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,49 +24,10 @@ exports.createUser = async (username, hashedPassword, name) => {
|
|||||||
|
|
||||||
|
|
||||||
exports.getAllUsers = async () => {
|
exports.getAllUsers = async () => {
|
||||||
const result = await pool.query("SELECT id, username, name, role, display_name FROM users ORDER BY id ASC");
|
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getUserById = async (id) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
"SELECT id, username, name, role, display_name FROM users WHERE id = $1",
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateUserProfile = async (id, updates) => {
|
|
||||||
const { display_name } = updates;
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE users
|
|
||||||
SET display_name = COALESCE($1, display_name)
|
|
||||||
WHERE id = $2
|
|
||||||
RETURNING id, username, name, role, display_name`,
|
|
||||||
[display_name, id]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateUserPassword = async (id, hashedPassword) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE users
|
|
||||||
SET password = $1
|
|
||||||
WHERE id = $2
|
|
||||||
RETURNING id`,
|
|
||||||
[hashedPassword, id]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.getUserPasswordHash = async (id) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
"SELECT password FROM users WHERE id = $1",
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return result.rows[0]?.password;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
exports.updateUserRole = async (id, role) => {
|
exports.updateUserRole = async (id, role) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
@ -7,9 +7,4 @@ const { ROLES } = require("../models/user.model");
|
|||||||
router.get("/exists", usersController.checkIfUserExists);
|
router.get("/exists", usersController.checkIfUserExists);
|
||||||
router.get("/test", usersController.test);
|
router.get("/test", usersController.test);
|
||||||
|
|
||||||
// Current user profile routes (authenticated)
|
|
||||||
router.get("/me", auth, usersController.getCurrentUser);
|
|
||||||
router.patch("/me", auth, usersController.updateCurrentUser);
|
|
||||||
router.post("/me/change-password", auth, usersController.changePassword);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,473 +0,0 @@
|
|||||||
# Code Cleanup Guide
|
|
||||||
|
|
||||||
This guide documents the cleanup patterns and best practices applied to the codebase, starting with `GroceryList.jsx`. Use this as a reference for maintaining consistent, clean, and readable code across all files.
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
1. [Spacing & Organization](#spacing--organization)
|
|
||||||
2. [Comment Formatting](#comment-formatting)
|
|
||||||
3. [Code Simplification](#code-simplification)
|
|
||||||
4. [React Performance Patterns](#react-performance-patterns)
|
|
||||||
5. [Cleanup Checklist](#cleanup-checklist)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spacing & Organization
|
|
||||||
|
|
||||||
### Two-Line Separation
|
|
||||||
Use **2 blank lines** to separate logical groups and functions.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const handleAdd = async (itemName, quantity) => {
|
|
||||||
// function body
|
|
||||||
};
|
|
||||||
const handleBought = async (id) => {
|
|
||||||
// function body
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const handleAdd = async (itemName, quantity) => {
|
|
||||||
// function body
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleBought = async (id) => {
|
|
||||||
// function body
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logical Grouping
|
|
||||||
Organize code into clear sections:
|
|
||||||
- State declarations
|
|
||||||
- Data loading functions
|
|
||||||
- Computed values (useMemo)
|
|
||||||
- Event handlers grouped by functionality
|
|
||||||
- Helper functions
|
|
||||||
- Render logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comment Formatting
|
|
||||||
|
|
||||||
### Section Headers
|
|
||||||
Use the `=== Section Name ===` format for major sections, followed by 2 blank lines before the next code block.
|
|
||||||
|
|
||||||
**Pattern:**
|
|
||||||
```javascript
|
|
||||||
// === State ===
|
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
|
||||||
const loadItems = async () => {
|
|
||||||
// implementation
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// === Event Handlers ===
|
|
||||||
const handleClick = () => {
|
|
||||||
// implementation
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Section Names
|
|
||||||
- `=== State ===`
|
|
||||||
- `=== Data Loading ===`
|
|
||||||
- `=== Computed Values ===` or `=== Sorted Items Computation ===`
|
|
||||||
- `=== Event Handlers ===` or specific groups like `=== Item Addition Handlers ===`
|
|
||||||
- `=== Helper Functions ===`
|
|
||||||
- `=== Render ===`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Simplification
|
|
||||||
|
|
||||||
### 1. Optional Chaining
|
|
||||||
Replace `&&` null checks with optional chaining when accessing nested properties.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
if (existingItem && existingItem.bought === false) {
|
|
||||||
// do something
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
if (existingItem?.bought === false) {
|
|
||||||
// do something
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Accessing properties on potentially undefined/null objects
|
|
||||||
- Checking nested properties: `user?.profile?.name`
|
|
||||||
- Method calls: `item?.toString?.()`
|
|
||||||
|
|
||||||
**When NOT to use:**
|
|
||||||
- When you need to check if the object exists first (use explicit check)
|
|
||||||
- For boolean coercion: `if (item)` is clearer than `if (item?.)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Ternary Operators
|
|
||||||
Use ternary operators for simple conditional assignments and returns.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
let result;
|
|
||||||
if (condition) {
|
|
||||||
result = "yes";
|
|
||||||
} else {
|
|
||||||
result = "no";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const result = condition ? "yes" : "no";
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Simple conditional assignments
|
|
||||||
- Inline JSX conditionals
|
|
||||||
- Return statements with simple conditions
|
|
||||||
|
|
||||||
**When NOT to use:**
|
|
||||||
- Complex multi-line logic (use if/else for readability)
|
|
||||||
- Nested ternaries (hard to read)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Early Returns
|
|
||||||
Use early returns to reduce nesting.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const handleSuggest = async (text) => {
|
|
||||||
if (text.trim()) {
|
|
||||||
// long implementation
|
|
||||||
} else {
|
|
||||||
setSuggestions([]);
|
|
||||||
setButtonText("Add Item");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const handleSuggest = async (text) => {
|
|
||||||
if (!text.trim()) {
|
|
||||||
setSuggestions([]);
|
|
||||||
setButtonText("Add Item");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// main implementation without nesting
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Destructuring
|
|
||||||
Use destructuring for cleaner variable access.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const username = user.username;
|
|
||||||
const email = user.email;
|
|
||||||
const role = user.role;
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const { username, email, role } = user;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Array Methods Over Loops
|
|
||||||
Prefer array methods (`.map()`, `.filter()`, `.find()`) over traditional loops.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const activeItems = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (!items[i].bought) {
|
|
||||||
activeItems.push(items[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const activeItems = items.filter(item => !item.bought);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React Performance Patterns
|
|
||||||
|
|
||||||
### 1. useCallback for Event Handlers
|
|
||||||
Wrap event handlers in `useCallback` to prevent unnecessary re-renders of child components.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleBought = useCallback(async (id, quantity) => {
|
|
||||||
await markBought(id);
|
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
|
||||||
loadRecentlyBought();
|
|
||||||
}, []); // Add dependencies if needed
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Handler functions passed as props to memoized child components
|
|
||||||
- Functions used as dependencies in other hooks
|
|
||||||
- Functions in frequently re-rendering components
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. useMemo for Expensive Computations
|
|
||||||
Use `useMemo` for computationally expensive operations or large transformations.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
const sorted = [...items];
|
|
||||||
|
|
||||||
if (sortMode === "az") {
|
|
||||||
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
}, [items, sortMode]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- Sorting/filtering large arrays
|
|
||||||
- Complex calculations
|
|
||||||
- Derived state that's expensive to compute
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. React.memo for Components
|
|
||||||
Wrap components with `React.memo` and provide custom comparison functions to prevent unnecessary re-renders.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const GroceryListItem = React.memo(
|
|
||||||
({ item, onClick, onLongPress }) => {
|
|
||||||
// component implementation
|
|
||||||
},
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return (
|
|
||||||
prevProps.id === nextProps.id &&
|
|
||||||
prevProps.item_name === nextProps.item_name &&
|
|
||||||
prevProps.quantity === nextProps.quantity &&
|
|
||||||
prevProps.item_image === nextProps.item_image
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- List item components that render frequently
|
|
||||||
- Components with stable props
|
|
||||||
- Pure components (output depends only on props)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. In-Place State Updates
|
|
||||||
Update specific items in state instead of reloading entire datasets.
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
const handleUpdate = async (id, newData) => {
|
|
||||||
await updateItem(id, newData);
|
|
||||||
loadItems(); // Reloads entire list from server
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
const handleUpdate = useCallback(async (id, newData) => {
|
|
||||||
const response = await updateItem(id, newData);
|
|
||||||
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...response.data } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- Faster updates (no server round-trip for entire list)
|
|
||||||
- Preserves scroll position
|
|
||||||
- Better user experience (no full re-render)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cleanup Checklist
|
|
||||||
|
|
||||||
Use this checklist when cleaning up a file:
|
|
||||||
|
|
||||||
### Structure & Organization
|
|
||||||
- [ ] Group related state variables together
|
|
||||||
- [ ] Use 2-line spacing between logical sections
|
|
||||||
- [ ] Add section comments using `=== Format ===`
|
|
||||||
- [ ] Order sections logically (state → data loading → computed → handlers → helpers → render)
|
|
||||||
|
|
||||||
### Code Simplification
|
|
||||||
- [ ] Replace `&&` null checks with optional chaining where appropriate
|
|
||||||
- [ ] Convert simple if/else to ternary operators
|
|
||||||
- [ ] Use early returns to reduce nesting
|
|
||||||
- [ ] Apply destructuring for cleaner variable access
|
|
||||||
- [ ] Use array methods instead of loops
|
|
||||||
|
|
||||||
### React Performance
|
|
||||||
- [ ] Wrap stable event handlers in `useCallback`
|
|
||||||
- [ ] Use `useMemo` for expensive computations
|
|
||||||
- [ ] Consider `React.memo` for list items or frequently re-rendering components
|
|
||||||
- [ ] Update state in-place instead of reloading from server
|
|
||||||
|
|
||||||
### Consistency
|
|
||||||
- [ ] Check naming conventions (camelCase for functions/variables)
|
|
||||||
- [ ] Ensure consistent spacing and indentation
|
|
||||||
- [ ] Remove unused imports and variables
|
|
||||||
- [ ] Remove console.logs (except intentional debugging aids)
|
|
||||||
|
|
||||||
### Testing After Cleanup
|
|
||||||
- [ ] Verify no functionality broke
|
|
||||||
- [ ] Check that performance improved (using React DevTools Profiler)
|
|
||||||
- [ ] Test all interactive features
|
|
||||||
- [ ] Verify mobile/responsive behavior still works
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example: Before & After
|
|
||||||
|
|
||||||
### Before Cleanup
|
|
||||||
```javascript
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
export default function MyComponent() {
|
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const loadItems = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await getItems();
|
|
||||||
setItems(res.data);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
loadItems();
|
|
||||||
}, []);
|
|
||||||
const handleUpdate = async (id, data) => {
|
|
||||||
await updateItem(id, data);
|
|
||||||
loadItems();
|
|
||||||
};
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
await deleteItem(id);
|
|
||||||
loadItems();
|
|
||||||
};
|
|
||||||
if (loading) return <p>Loading...</p>;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{items.map(item => (
|
|
||||||
<Item key={item.id} item={item} onUpdate={handleUpdate} onDelete={handleDelete} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### After Cleanup
|
|
||||||
```javascript
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
|
|
||||||
export default function MyComponent() {
|
|
||||||
// === State ===
|
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
|
||||||
const loadItems = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await getItems();
|
|
||||||
setItems(res.data);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadItems();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
// === Event Handlers ===
|
|
||||||
const handleUpdate = useCallback(async (id, data) => {
|
|
||||||
const response = await updateItem(id, data);
|
|
||||||
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...response.data } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async (id) => {
|
|
||||||
await deleteItem(id);
|
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
// === Render ===
|
|
||||||
if (loading) return <p>Loading...</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{items.map(item => (
|
|
||||||
<Item
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key improvements:**
|
|
||||||
1. Added section comments for clarity
|
|
||||||
2. Proper 2-line spacing between sections
|
|
||||||
3. Wrapped handlers in `useCallback` for performance
|
|
||||||
4. In-place state updates instead of reloading entire list
|
|
||||||
5. Better import organization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [React Performance Optimization](https://react.dev/learn/render-and-commit)
|
|
||||||
- [useCallback Hook](https://react.dev/reference/react/useCallback)
|
|
||||||
- [useMemo Hook](https://react.dev/reference/react/useMemo)
|
|
||||||
- [React.memo](https://react.dev/reference/react/memo)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Don't over-optimize**: Not every component needs `useCallback`/`useMemo`. Apply these patterns when you have measurable performance issues or when working with large lists.
|
|
||||||
- **Readability first**: If a simplification makes code harder to understand, skip it. Code should be optimized for human reading first.
|
|
||||||
- **Test thoroughly**: Always test after cleanup to ensure no functionality broke.
|
|
||||||
- **Incremental cleanup**: Don't try to clean up everything at once. Focus on one file at a time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: Based on GroceryList.jsx cleanup (January 2026)
|
|
||||||
@ -1,415 +0,0 @@
|
|||||||
# Settings & Dark Mode Implementation
|
|
||||||
|
|
||||||
**Status**: ✅ Phase 1 Complete, Phase 2 Complete
|
|
||||||
**Last Updated**: January 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A comprehensive user settings system with persistent preferences, dark mode support, and customizable list display options. Settings are stored per-user in localStorage and automatically applied across the application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Context Hierarchy
|
|
||||||
```
|
|
||||||
<ConfigProvider> ← Server config (image limits, etc.)
|
|
||||||
<AuthProvider> ← Authentication state
|
|
||||||
<SettingsProvider> ← User preferences (NEW)
|
|
||||||
<App />
|
|
||||||
</SettingsProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
#### SettingsContext ([frontend/src/context/SettingsContext.jsx](frontend/src/context/SettingsContext.jsx))
|
|
||||||
- Manages user preferences with localStorage persistence
|
|
||||||
- Storage key pattern: `user_preferences_${username}`
|
|
||||||
- Automatically applies theme to `document.documentElement`
|
|
||||||
- Listens for system theme changes in auto mode
|
|
||||||
- Provides `updateSettings()` and `resetSettings()` methods
|
|
||||||
|
|
||||||
#### Settings Page ([frontend/src/pages/Settings.jsx](frontend/src/pages/Settings.jsx))
|
|
||||||
- Tabbed interface: Appearance, List Display, Behavior
|
|
||||||
- Real-time preview of setting changes
|
|
||||||
- Reset to defaults functionality
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings Schema
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
// === Appearance ===
|
|
||||||
theme: "light" | "dark" | "auto", // Theme mode
|
|
||||||
compactView: false, // Reduced spacing for denser lists
|
|
||||||
|
|
||||||
// === List Display ===
|
|
||||||
defaultSortMode: "zone", // Default: "zone" | "az" | "za" | "qty-high" | "qty-low"
|
|
||||||
showRecentlyBought: true, // Toggle recently bought section
|
|
||||||
recentlyBoughtCount: 10, // Initial items shown (5-50)
|
|
||||||
recentlyBoughtCollapsed: false, // Start section collapsed
|
|
||||||
|
|
||||||
// === Behavior ===
|
|
||||||
confirmBeforeBuy: true, // Show confirmation modal
|
|
||||||
autoReloadInterval: 0, // Auto-refresh in minutes (0 = disabled)
|
|
||||||
hapticFeedback: true, // Vibration on mobile interactions
|
|
||||||
|
|
||||||
// === Advanced ===
|
|
||||||
debugMode: false // Developer tools (future)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dark Mode Implementation
|
|
||||||
|
|
||||||
### Theme System
|
|
||||||
|
|
||||||
**Three modes**:
|
|
||||||
1. **Light**: Force light theme
|
|
||||||
2. **Dark**: Force dark theme
|
|
||||||
3. **Auto**: Follow system preferences with live updates
|
|
||||||
|
|
||||||
### CSS Variable Architecture
|
|
||||||
|
|
||||||
All colors use CSS custom properties defined in [frontend/src/styles/theme.css](frontend/src/styles/theme.css):
|
|
||||||
|
|
||||||
**Light Mode** (`:root`):
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--color-text-primary: #212529;
|
|
||||||
--color-bg-body: #f8f9fa;
|
|
||||||
--color-bg-surface: #ffffff;
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dark Mode** (`[data-theme="dark"]`):
|
|
||||||
```css
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-text-primary: #f1f5f9;
|
|
||||||
--color-bg-body: #0f172a;
|
|
||||||
--color-bg-surface: #1e293b;
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Theme Application Logic
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In SettingsContext.jsx
|
|
||||||
useEffect(() => {
|
|
||||||
const applyTheme = () => {
|
|
||||||
let theme = settings.theme;
|
|
||||||
|
|
||||||
// Auto mode: check system preference
|
|
||||||
if (theme === "auto") {
|
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
theme = prefersDark ? "dark" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
applyTheme();
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
if (settings.theme === "auto") {
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaQuery.addEventListener("change", applyTheme);
|
|
||||||
return () => mediaQuery.removeEventListener("change", applyTheme);
|
|
||||||
}
|
|
||||||
}, [settings.theme]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration with Existing Features
|
|
||||||
|
|
||||||
### GroceryList Integration
|
|
||||||
|
|
||||||
**Changed**:
|
|
||||||
```javascript
|
|
||||||
// Before
|
|
||||||
const [sortMode, setSortMode] = useState("zone");
|
|
||||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
|
||||||
|
|
||||||
// After
|
|
||||||
const { settings } = useContext(SettingsContext);
|
|
||||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
|
||||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
|
||||||
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Sort mode persists across sessions
|
|
||||||
- Recently bought section respects visibility setting
|
|
||||||
- Collapse state controlled by user preference
|
|
||||||
- Initial display count uses user's preference
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── context/
|
|
||||||
│ └── SettingsContext.jsx ← Settings state management
|
|
||||||
├── pages/
|
|
||||||
│ └── Settings.jsx ← Settings UI
|
|
||||||
├── styles/
|
|
||||||
│ ├── theme.css ← Dark mode CSS variables
|
|
||||||
│ └── pages/
|
|
||||||
│ └── Settings.css ← Settings page styles
|
|
||||||
└── App.jsx ← Settings route & provider
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Access Settings in Component
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useContext } from "react";
|
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { settings, updateSettings } = useContext(SettingsContext);
|
|
||||||
|
|
||||||
// Read setting
|
|
||||||
const isDark = settings.theme === "dark";
|
|
||||||
|
|
||||||
// Update setting
|
|
||||||
const toggleTheme = () => {
|
|
||||||
updateSettings({
|
|
||||||
theme: settings.theme === "dark" ? "light" : "dark"
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return <button onClick={toggleTheme}>Toggle Theme</button>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional Rendering Based on Settings
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{settings.showRecentlyBought && (
|
|
||||||
<RecentlyBoughtSection />
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Theme Colors
|
|
||||||
|
|
||||||
```css
|
|
||||||
.my-component {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## localStorage Structure
|
|
||||||
|
|
||||||
**Key**: `user_preferences_${username}`
|
|
||||||
|
|
||||||
**Example stored value**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"theme": "dark",
|
|
||||||
"compactView": false,
|
|
||||||
"defaultSortMode": "zone",
|
|
||||||
"showRecentlyBought": true,
|
|
||||||
"recentlyBoughtCount": 20,
|
|
||||||
"recentlyBoughtCollapsed": false,
|
|
||||||
"confirmBeforeBuy": true,
|
|
||||||
"autoReloadInterval": 0,
|
|
||||||
"hapticFeedback": true,
|
|
||||||
"debugMode": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### Settings Page
|
|
||||||
- [ ] All three tabs accessible
|
|
||||||
- [ ] Theme toggle works (light/dark/auto)
|
|
||||||
- [ ] Auto mode follows system preference
|
|
||||||
- [ ] Settings persist after logout/login
|
|
||||||
- [ ] Reset button restores defaults
|
|
||||||
|
|
||||||
### Dark Mode
|
|
||||||
- [ ] All pages render correctly in dark mode
|
|
||||||
- [ ] Modals readable in dark mode
|
|
||||||
- [ ] Forms and inputs visible in dark mode
|
|
||||||
- [ ] Navigation and buttons styled correctly
|
|
||||||
- [ ] Images and borders contrast properly
|
|
||||||
|
|
||||||
### GroceryList Integration
|
|
||||||
- [ ] Default sort mode applied on load
|
|
||||||
- [ ] Recently bought visibility respected
|
|
||||||
- [ ] Collapse state persists during session
|
|
||||||
- [ ] Display count uses user preference
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements (Not Implemented)
|
|
||||||
|
|
||||||
### Phase 3: Advanced Preferences
|
|
||||||
- **Compact View**: Reduced padding/font sizes for power users
|
|
||||||
- **Confirm Before Buy**: Toggle for confirmation modal
|
|
||||||
- **Auto-reload**: Refresh list every X minutes for shared lists
|
|
||||||
|
|
||||||
### Phase 4: Account Management
|
|
||||||
- **Change Password**: Security feature (needs backend endpoint)
|
|
||||||
- **Display Name**: Friendly name separate from username
|
|
||||||
|
|
||||||
### Phase 5: Data Management
|
|
||||||
- **Export List**: Download as CSV/JSON
|
|
||||||
- **Clear History**: Remove recently bought items
|
|
||||||
- **Import Items**: Bulk add from file
|
|
||||||
|
|
||||||
### Phase 6: Accessibility
|
|
||||||
- **Font Size**: Adjustable text sizing
|
|
||||||
- **High Contrast Mode**: Increased contrast for visibility
|
|
||||||
- **Reduce Motion**: Disable animations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
**None required** - all settings are client-side only.
|
|
||||||
|
|
||||||
Future backend endpoints may include:
|
|
||||||
- `PATCH /api/users/me` - Update user profile (password, display name)
|
|
||||||
- `GET /api/list/export` - Export grocery list data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
### Theme Detection
|
|
||||||
- Chrome/Edge: ✅ Full support
|
|
||||||
- Firefox: ✅ Full support
|
|
||||||
- Safari: ✅ Full support (iOS 12.2+)
|
|
||||||
- Mobile browsers: ✅ Full support
|
|
||||||
|
|
||||||
### localStorage
|
|
||||||
- All modern browsers: ✅ Supported
|
|
||||||
- Fallback: Settings work but don't persist (rare)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Settings Don't Persist
|
|
||||||
**Issue**: Settings reset after logout
|
|
||||||
**Cause**: Settings tied to username
|
|
||||||
**Solution**: Working as designed - each user has separate preferences
|
|
||||||
|
|
||||||
### Dark Mode Not Applied
|
|
||||||
**Issue**: Page stays light after selecting dark
|
|
||||||
**Cause**: Missing `data-theme` attribute
|
|
||||||
**Solution**: Check SettingsContext is wrapped around App
|
|
||||||
|
|
||||||
### System Theme Not Detected
|
|
||||||
**Issue**: Auto mode doesn't work
|
|
||||||
**Cause**: Browser doesn't support `prefers-color-scheme`
|
|
||||||
**Solution**: Fallback to light mode (handled automatically)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
### Adding New Settings
|
|
||||||
|
|
||||||
1. **Update DEFAULT_SETTINGS** in SettingsContext.jsx:
|
|
||||||
```javascript
|
|
||||||
const DEFAULT_SETTINGS = {
|
|
||||||
// ...existing settings
|
|
||||||
myNewSetting: defaultValue,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Add UI in Settings.jsx**:
|
|
||||||
```javascript
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.myNewSetting}
|
|
||||||
onChange={() => handleToggle("myNewSetting")}
|
|
||||||
/>
|
|
||||||
<span>My New Setting</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">Description here</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Use in components**:
|
|
||||||
```javascript
|
|
||||||
const { settings } = useContext(SettingsContext);
|
|
||||||
if (settings.myNewSetting) {
|
|
||||||
// Do something
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Theme Colors
|
|
||||||
|
|
||||||
1. Define in both light (`:root`) and dark (`[data-theme="dark"]`) modes
|
|
||||||
2. Use descriptive semantic names: `--color-purpose-variant`
|
|
||||||
3. Always provide fallbacks for older code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- Settings load once per user session
|
|
||||||
- Theme changes apply instantly (no page reload)
|
|
||||||
- localStorage writes are debounced by React state updates
|
|
||||||
- No network requests for settings (all client-side)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
- ✅ Keyboard navigation works in Settings page
|
|
||||||
- ✅ Theme buttons have clear active states
|
|
||||||
- ✅ Range sliders show current values
|
|
||||||
- ✅ Color contrast meets WCAG AA in both themes
|
|
||||||
- ⚠️ Screen reader announcements for theme changes (future enhancement)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
**Upgrading from older versions**:
|
|
||||||
- Old settings are preserved (merged with defaults)
|
|
||||||
- Missing settings use default values
|
|
||||||
- Invalid values are reset to defaults
|
|
||||||
- No migration script needed - handled automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Code Cleanup Guide](code-cleanup-guide.md) - Code organization patterns
|
|
||||||
- [Component Structure](component-structure.md) - Component architecture
|
|
||||||
- [Theme Usage Examples](../frontend/src/styles/THEME_USAGE_EXAMPLES.css) - CSS variable usage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Status**: ✅ Complete
|
|
||||||
**Phase 1 (Foundation)**: ✅ Complete
|
|
||||||
**Phase 2 (Dark Mode)**: ✅ Complete
|
|
||||||
**Phase 3 (List Preferences)**: ✅ Complete
|
|
||||||
**Phase 4+ (Future)**: ⏳ Planned
|
|
||||||
@ -2,13 +2,11 @@ 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";
|
||||||
@ -20,7 +18,6 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SettingsProvider>
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
||||||
@ -37,7 +34,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<GroceryList />} />
|
<Route path="/" element={<GroceryList />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
@ -51,7 +47,6 @@ function App() {
|
|||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</SettingsProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
|
|||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
|
export const markBought = (id) => api.post("/list/mark-bought", { id });
|
||||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||||
|
|
||||||
|
|||||||
@ -4,15 +4,3 @@ export const getAllUsers = () => api.get("/admin/users");
|
|||||||
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
|
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
|
||||||
export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
|
export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
|
||||||
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });
|
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });
|
||||||
|
|
||||||
export const getCurrentUser = async () => {
|
|
||||||
return api.get("/users/me");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCurrentUser = async (display_name) => {
|
|
||||||
return api.patch("/users/me", { display_name });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (current_password, new_password) => {
|
|
||||||
return api.post("/users/me/change-password", { current_password, new_password });
|
|
||||||
};
|
|
||||||
@ -2,7 +2,7 @@ import { ROLES } from "../../constants/roles";
|
|||||||
|
|
||||||
export default function UserRoleCard({ user, onRoleChange }) {
|
export default function UserRoleCard({ user, onRoleChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
|
<div className="user-card">
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<strong>{user.name}</strong>
|
<strong>{user.name}</strong>
|
||||||
<span className="user-username">@{user.username}</span>
|
<span className="user-username">@{user.username}</span>
|
||||||
@ -10,8 +10,7 @@ export default function UserRoleCard({ user, onRoleChange }) {
|
|||||||
<select
|
<select
|
||||||
onChange={(e) => onRoleChange(user.id, e.target.value)}
|
onChange={(e) => onRoleChange(user.id, e.target.value)}
|
||||||
value={user.role}
|
value={user.role}
|
||||||
className="form-select"
|
className="role-select"
|
||||||
style={{ fontSize: 'var(--font-size-sm)' }}
|
|
||||||
>
|
>
|
||||||
<option value={ROLES.VIEWER}>Viewer</option>
|
<option value={ROLES.VIEWER}>Viewer</option>
|
||||||
<option value={ROLES.EDITOR}>Editor</option>
|
<option value={ROLES.EDITOR}>Editor</option>
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { memo, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import AddImageModal from "../modals/AddImageModal";
|
import AddImageModal from "../modals/AddImageModal";
|
||||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||||
|
import ImageModal from "../modals/ImageModal";
|
||||||
|
|
||||||
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
|
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||||
const [currentItem, setCurrentItem] = useState(item);
|
|
||||||
|
|
||||||
const longPressTimer = useRef(null);
|
const longPressTimer = useRef(null);
|
||||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
@ -56,14 +57,13 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
setCurrentItem(item);
|
|
||||||
setShowConfirmBuyModal(true);
|
setShowConfirmBuyModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmBuy = (quantity) => {
|
const handleConfirmBuy = (quantity) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(currentItem.id, quantity);
|
onClick(quantity);
|
||||||
}
|
}
|
||||||
setShowConfirmBuyModal(false);
|
setShowConfirmBuyModal(false);
|
||||||
};
|
};
|
||||||
@ -72,16 +72,10 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
setShowConfirmBuyModal(false);
|
setShowConfirmBuyModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigate = (newItem) => {
|
|
||||||
setCurrentItem(newItem);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageClick = (e) => {
|
const handleImageClick = (e) => {
|
||||||
e.stopPropagation(); // Prevent triggering the bought action
|
e.stopPropagation(); // Prevent triggering the bought action
|
||||||
if (item.item_image) {
|
if (item.item_image) {
|
||||||
// Open buy modal which now shows the image
|
setShowModal(true);
|
||||||
setCurrentItem(item);
|
|
||||||
setShowConfirmBuyModal(true);
|
|
||||||
} else {
|
} else {
|
||||||
setShowAddImageModal(true);
|
setShowAddImageModal(true);
|
||||||
}
|
}
|
||||||
@ -120,7 +114,7 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
className={`glist-li ${compact ? 'compact' : ''}`}
|
className="glist-li"
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@ -156,6 +150,14 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ImageModal
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
itemName={item.item_name}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showAddImageModal && (
|
{showAddImageModal && (
|
||||||
<AddImageModal
|
<AddImageModal
|
||||||
itemName={item.item_name}
|
itemName={item.item_name}
|
||||||
@ -166,32 +168,11 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
|
|||||||
|
|
||||||
{showConfirmBuyModal && (
|
{showConfirmBuyModal && (
|
||||||
<ConfirmBuyModal
|
<ConfirmBuyModal
|
||||||
item={currentItem}
|
item={item}
|
||||||
onConfirm={handleConfirmBuy}
|
onConfirm={handleConfirmBuy}
|
||||||
onCancel={handleCancelBuy}
|
onCancel={handleCancelBuy}
|
||||||
allItems={allItems}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoize component to prevent re-renders when props haven't changed
|
|
||||||
export default memo(GroceryListItem, (prevProps, nextProps) => {
|
|
||||||
// Only re-render if the item data or handlers have changed
|
|
||||||
return (
|
|
||||||
prevProps.item.id === nextProps.item.id &&
|
|
||||||
prevProps.item.item_name === nextProps.item.item_name &&
|
|
||||||
prevProps.item.quantity === nextProps.item.quantity &&
|
|
||||||
prevProps.item.item_image === nextProps.item.item_image &&
|
|
||||||
prevProps.item.bought === nextProps.item.bought &&
|
|
||||||
prevProps.item.last_added_on === nextProps.item.last_added_on &&
|
|
||||||
prevProps.item.zone === nextProps.item.zone &&
|
|
||||||
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
|
|
||||||
prevProps.onClick === nextProps.onClick &&
|
|
||||||
prevProps.onImageAdded === nextProps.onImageAdded &&
|
|
||||||
prevProps.onLongPress === nextProps.onLongPress &&
|
|
||||||
prevProps.allItems?.length === nextProps.allItems?.length
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import "../../styles/components/SuggestionList.css";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
@ -9,12 +8,27 @@ export default function SuggestionList({ suggestions, onSelect }: Props) {
|
|||||||
if (!suggestions.length) return null;
|
if (!suggestions.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="suggestion-list">
|
<ul
|
||||||
|
className="suggestion-list"
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
maxHeight: "150px",
|
||||||
|
overflowY: "auto",
|
||||||
|
listStyle: "none",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{suggestions.map((s) => (
|
{suggestions.map((s) => (
|
||||||
<li
|
<li
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => onSelect(s)}
|
onClick={() => onSelect(s)}
|
||||||
className="suggestion-item"
|
style={{
|
||||||
|
padding: "0.5em",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export default function Navbar() {
|
|||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div className="navbar-links">
|
<div className="navbar-links">
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
<Link to="/settings">Settings</Link>
|
|
||||||
|
|
||||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,11 +39,11 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="add-image-modal-overlay" onClick={onClose}>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="add-image-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">Add Image</h2>
|
<h2>Add Image</h2>
|
||||||
<p className="text-center mb-4" style={{ color: 'var(--color-text-secondary)', fontSize: '0.95em' }}>
|
<p className="add-image-subtitle">
|
||||||
There's no image for <strong className="text-primary">"{itemName}"</strong> yet. Add a new image?
|
There's no image for <strong>"{itemName}"</strong> yet. Add a new image?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!imagePreview ? (
|
{!imagePreview ? (
|
||||||
@ -83,12 +83,12 @@ export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="add-image-actions">
|
||||||
<button onClick={onClose} className="btn btn-outline flex-1">
|
<button onClick={onClose} className="add-image-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<button onClick={handleConfirm} className="btn btn-success flex-1">
|
<button onClick={handleConfirm} className="add-image-confirm">
|
||||||
Add Image
|
Add Image
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
import "../../styles/components/ConfirmAddExistingModal.css";
|
|
||||||
|
|
||||||
export default function ConfirmAddExistingModal({
|
|
||||||
itemName,
|
|
||||||
currentQuantity,
|
|
||||||
addingQuantity,
|
|
||||||
onConfirm,
|
|
||||||
onCancel
|
|
||||||
}) {
|
|
||||||
const newQuantity = currentQuantity + addingQuantity;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onCancel}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2 className="text-center text-xl mb-4">
|
|
||||||
<strong className="text-primary font-semibold">{itemName}</strong> is already in your list
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="confirm-add-existing-qty-info">
|
|
||||||
<div className="qty-row">
|
|
||||||
<span className="qty-label">Current quantity:</span>
|
|
||||||
<span className="qty-value">{currentQuantity}</span>
|
|
||||||
</div>
|
|
||||||
<div className="qty-row">
|
|
||||||
<span className="qty-label">Adding:</span>
|
|
||||||
<span className="qty-value">+{addingQuantity}</span>
|
|
||||||
</div>
|
|
||||||
<div className="qty-row qty-total">
|
|
||||||
<span className="qty-label">New total:</span>
|
|
||||||
<span className="qty-value">{newQuantity}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button className="btn btn-outline flex-1" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary flex-1" onClick={onConfirm}>
|
|
||||||
Update Quantity
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,26 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import "../../styles/ConfirmBuyModal.css";
|
import "../../styles/ConfirmBuyModal.css";
|
||||||
|
|
||||||
export default function ConfirmBuyModal({
|
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
||||||
item,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
allItems = [],
|
|
||||||
onNavigate
|
|
||||||
}) {
|
|
||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
|
||||||
// Update quantity when item changes (navigation)
|
|
||||||
useEffect(() => {
|
|
||||||
setQuantity(item.quantity);
|
|
||||||
}, [item.id, item.quantity]);
|
|
||||||
|
|
||||||
// Find current index and check for prev/next
|
|
||||||
const currentIndex = allItems.findIndex(i => i.id === item.id);
|
|
||||||
const hasPrev = currentIndex > 0;
|
|
||||||
const hasNext = currentIndex < allItems.length - 1;
|
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = () => {
|
||||||
if (quantity < maxQuantity) {
|
if (quantity < maxQuantity) {
|
||||||
setQuantity(prev => prev + 1);
|
setQuantity(prev => prev + 1);
|
||||||
@ -37,61 +21,14 @@ export default function ConfirmBuyModal({
|
|||||||
onConfirm(quantity);
|
onConfirm(quantity);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
|
||||||
if (hasPrev && onNavigate) {
|
|
||||||
const prevItem = allItems[currentIndex - 1];
|
|
||||||
onNavigate(prevItem);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (hasNext && onNavigate) {
|
|
||||||
const nextItem = allItems[currentIndex + 1];
|
|
||||||
onNavigate(nextItem);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageUrl = item.item_image && item.image_mime_type
|
|
||||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
||||||
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="confirm-buy-header">
|
<h2>Mark as Bought</h2>
|
||||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
<p className="confirm-buy-item-name">"{item.item_name}"</p>
|
||||||
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="confirm-buy-image-section">
|
|
||||||
<button
|
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
|
||||||
onClick={handlePrev}
|
|
||||||
style={{ visibility: hasPrev ? 'visible' : 'hidden' }}
|
|
||||||
disabled={!hasPrev}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="confirm-buy-image-container">
|
|
||||||
{imageUrl ? (
|
|
||||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
|
||||||
) : (
|
|
||||||
<div className="confirm-buy-image-placeholder">📦</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
|
||||||
onClick={handleNext}
|
|
||||||
style={{ visibility: hasNext ? 'visible' : 'hidden' }}
|
|
||||||
disabled={!hasNext}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="confirm-buy-quantity-section">
|
<div className="confirm-buy-quantity-section">
|
||||||
|
<p className="confirm-buy-label">Quantity to buy:</p>
|
||||||
<div className="confirm-buy-counter">
|
<div className="confirm-buy-counter">
|
||||||
<button
|
<button
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
|
||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import AddImageModal from "./AddImageModal";
|
import ClassificationSection from "../forms/ClassificationSection";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||||
const [itemName, setItemName] = useState(item.item_name || "");
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
const [itemGroup, setItemGroup] = useState("");
|
const [itemGroup, setItemGroup] = useState("");
|
||||||
const [zone, setZone] = useState("");
|
const [zone, setZone] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
|
||||||
|
|
||||||
// Load existing classification
|
// Load existing classification
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -60,131 +58,44 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (imageFile) => {
|
|
||||||
if (onImageUpdate) {
|
|
||||||
try {
|
|
||||||
await onImageUpdate(item.id, itemName, quantity, imageFile);
|
|
||||||
setShowImageModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to upload image:", error);
|
|
||||||
alert("Failed to upload image");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const incrementQuantity = () => {
|
|
||||||
setQuantity(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decrementQuantity = () => {
|
|
||||||
setQuantity(prev => Math.max(1, prev - 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="edit-modal-title">Edit Item</h2>
|
<h2 className="edit-modal-title">Edit Item</h2>
|
||||||
|
|
||||||
{/* Item Name - no label */}
|
<div className="edit-modal-field">
|
||||||
|
<label>Item Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={itemName}
|
value={itemName}
|
||||||
onChange={(e) => setItemName(e.target.value)}
|
onChange={(e) => setItemName(e.target.value)}
|
||||||
className="edit-modal-input"
|
className="edit-modal-input"
|
||||||
placeholder="Item name"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quantity Control - like AddItemForm */}
|
<div className="edit-modal-field">
|
||||||
<div className="edit-modal-quantity-control">
|
<label>Quantity</label>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="quantity-btn quantity-btn-minus"
|
|
||||||
onClick={decrementQuantity}
|
|
||||||
disabled={quantity <= 1}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
className="edit-modal-quantity-input"
|
|
||||||
value={quantity}
|
value={quantity}
|
||||||
readOnly
|
onChange={(e) => setQuantity(parseInt(e.target.value))}
|
||||||
|
className="edit-modal-input"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="quantity-btn quantity-btn-plus"
|
|
||||||
onClick={incrementQuantity}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
{/* Inline Classification Fields */}
|
<ClassificationSection
|
||||||
<div className="edit-modal-inline-field">
|
itemType={itemType}
|
||||||
<label>Type</label>
|
itemGroup={itemGroup}
|
||||||
<select
|
zone={zone}
|
||||||
value={itemType}
|
onItemTypeChange={handleItemTypeChange}
|
||||||
onChange={(e) => handleItemTypeChange(e.target.value)}
|
onItemGroupChange={setItemGroup}
|
||||||
className="edit-modal-select"
|
onZoneChange={setZone}
|
||||||
>
|
fieldClass="edit-modal-field"
|
||||||
<option value="">-- Select Type --</option>
|
selectClass="edit-modal-select"
|
||||||
{Object.values(ITEM_TYPES).map((type) => (
|
/>
|
||||||
<option key={type} value={type}>
|
|
||||||
{getItemTypeLabel(type)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemType && (
|
|
||||||
<div className="edit-modal-inline-field">
|
|
||||||
<label>Group</label>
|
|
||||||
<select
|
|
||||||
value={itemGroup}
|
|
||||||
onChange={(e) => setItemGroup(e.target.value)}
|
|
||||||
className="edit-modal-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Group --</option>
|
|
||||||
{availableGroups.map((group) => (
|
|
||||||
<option key={group} value={group}>
|
|
||||||
{group}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="edit-modal-inline-field">
|
|
||||||
<label>Zone</label>
|
|
||||||
<select
|
|
||||||
value={zone}
|
|
||||||
onChange={(e) => setZone(e.target.value)}
|
|
||||||
className="edit-modal-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Zone --</option>
|
|
||||||
{getZoneValues().map((z) => (
|
|
||||||
<option key={z} value={z}>
|
|
||||||
{z}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="edit-modal-divider" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="edit-modal-btn edit-modal-btn-image"
|
|
||||||
onClick={() => setShowImageModal(true)}
|
|
||||||
disabled={loading}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="edit-modal-actions">
|
<div className="edit-modal-actions">
|
||||||
<button
|
<button
|
||||||
@ -203,14 +114,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showImageModal && (
|
|
||||||
<AddImageModal
|
|
||||||
itemName={itemName}
|
|
||||||
onClose={() => setShowImageModal(false)}
|
|
||||||
onAddImage={handleImageUpload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function ImageModal({ imageUrl, itemName, onClose }) {
|
|||||||
<div className="image-modal-overlay" onClick={onClose}>
|
<div className="image-modal-overlay" onClick={onClose}>
|
||||||
<div className="image-modal-content" onClick={onClose}>
|
<div className="image-modal-content" onClick={onClose}>
|
||||||
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
<img src={imageUrl} alt={itemName} className="image-modal-img" />
|
||||||
<p className="text-center mt-3 text-lg font-semibold">{itemName}</p>
|
<p className="image-modal-caption">{itemName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,22 +2,25 @@ import "../../styles/SimilarItemModal.css";
|
|||||||
|
|
||||||
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onCancel}>
|
<div className="similar-item-modal-overlay" onClick={onCancel}>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="similar-item-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">Similar Item Found</h2>
|
<h2>Similar Item Found</h2>
|
||||||
<p className="text-center text-lg mb-4">
|
<p className="similar-item-question">
|
||||||
Instead of <strong className="similar-item-original">"{originalName.toLowerCase()}"</strong>, use <strong className="similar-item-suggested">"{suggestedName}"</strong>?
|
Do you mean <strong>"{suggestedName}"</strong>?
|
||||||
|
</p>
|
||||||
|
<p className="similar-item-clarification">
|
||||||
|
You entered: "{originalName}"
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="similar-modal-actions">
|
<div className="similar-item-actions">
|
||||||
<button onClick={onYes} className="btn btn-success">
|
<button onClick={onCancel} className="similar-item-cancel">
|
||||||
Yes, Use Suggestion
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onNo} className="btn btn-primary">
|
<button onClick={onNo} className="similar-item-no">
|
||||||
No, Create New
|
No, Create New
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onCancel} className="btn btn-danger">
|
<button onClick={onYes} className="similar-item-yes">
|
||||||
Cancel
|
Yes, Use Suggestion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import { AuthContext } from "./AuthContext";
|
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
|
||||||
// Appearance
|
|
||||||
theme: "auto", // "light" | "dark" | "auto"
|
|
||||||
compactView: false,
|
|
||||||
|
|
||||||
// List Display
|
|
||||||
defaultSortMode: "zone",
|
|
||||||
showRecentlyBought: true,
|
|
||||||
recentlyBoughtCount: 10,
|
|
||||||
recentlyBoughtCollapsed: false,
|
|
||||||
|
|
||||||
// Behavior
|
|
||||||
confirmBeforeBuy: true,
|
|
||||||
autoReloadInterval: 0, // 0 = disabled, else minutes
|
|
||||||
hapticFeedback: true,
|
|
||||||
|
|
||||||
// Advanced
|
|
||||||
debugMode: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const SettingsContext = createContext({
|
|
||||||
settings: DEFAULT_SETTINGS,
|
|
||||||
updateSettings: () => { },
|
|
||||||
resetSettings: () => { },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const SettingsProvider = ({ children }) => {
|
|
||||||
const { username } = useContext(AuthContext);
|
|
||||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
|
||||||
|
|
||||||
|
|
||||||
// Load settings from localStorage when user changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!username) {
|
|
||||||
setSettings(DEFAULT_SETTINGS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageKey = `user_preferences_${username}`;
|
|
||||||
const savedSettings = localStorage.getItem(storageKey);
|
|
||||||
|
|
||||||
if (savedSettings) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedSettings);
|
|
||||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse settings:", error);
|
|
||||||
setSettings(DEFAULT_SETTINGS);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSettings(DEFAULT_SETTINGS);
|
|
||||||
}
|
|
||||||
}, [username]);
|
|
||||||
|
|
||||||
|
|
||||||
// Apply theme to document
|
|
||||||
useEffect(() => {
|
|
||||||
const applyTheme = () => {
|
|
||||||
let theme = settings.theme;
|
|
||||||
|
|
||||||
if (theme === "auto") {
|
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
theme = prefersDark ? "dark" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
applyTheme();
|
|
||||||
|
|
||||||
// Listen for system theme changes if in auto mode
|
|
||||||
if (settings.theme === "auto") {
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const handler = () => applyTheme();
|
|
||||||
mediaQuery.addEventListener("change", handler);
|
|
||||||
return () => mediaQuery.removeEventListener("change", handler);
|
|
||||||
}
|
|
||||||
}, [settings.theme]);
|
|
||||||
|
|
||||||
|
|
||||||
// Save settings to localStorage
|
|
||||||
const updateSettings = (newSettings) => {
|
|
||||||
if (!username) return;
|
|
||||||
|
|
||||||
const updated = { ...settings, ...newSettings };
|
|
||||||
setSettings(updated);
|
|
||||||
|
|
||||||
const storageKey = `user_preferences_${username}`;
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(updated));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Reset to defaults
|
|
||||||
const resetSettings = () => {
|
|
||||||
if (!username) return;
|
|
||||||
|
|
||||||
setSettings(DEFAULT_SETTINGS);
|
|
||||||
|
|
||||||
const storageKey = `user_preferences_${username}`;
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(DEFAULT_SETTINGS));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
resetSettings,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -84,14 +84,9 @@ body {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-body);
|
background: var(--color-bg-body);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: var(--spacing-md);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@ -99,6 +94,11 @@ body {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
padding: var(--container-padding);
|
padding: var(--container-padding);
|
||||||
}
|
}
|
||||||
|
background: white;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import './styles/theme.css'
|
import './styles/theme.css'
|
||||||
import './styles/utilities.css'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { getAllUsers, updateRole } from "../api/users";
|
import { getAllUsers, updateRole } from "../api/users";
|
||||||
import UserRoleCard from "../components/common/UserRoleCard";
|
import UserRoleCard from "../components/common/UserRoleCard";
|
||||||
import "../styles/UserRoleCard.css";
|
import "../styles/UserRoleCard.css";
|
||||||
import "../styles/pages/AdminPanel.css";
|
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@ -23,10 +22,9 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4" style={{ minHeight: '100vh' }}>
|
<div style={{ padding: "2rem" }}>
|
||||||
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
<h1>Admin Panel</h1>
|
||||||
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
|
<div style={{ marginTop: "2rem" }}>
|
||||||
<div className="mt-4">
|
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<UserRoleCard
|
<UserRoleCard
|
||||||
key={user.id}
|
key={user.id}
|
||||||
@ -36,6 +34,5 @@ export default function AdminPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,40 +1,25 @@
|
|||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import {
|
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||||
addItem,
|
|
||||||
getClassification,
|
|
||||||
getItemByName,
|
|
||||||
getList,
|
|
||||||
getRecentlyBought,
|
|
||||||
getSuggestions,
|
|
||||||
markBought,
|
|
||||||
updateItemImage,
|
|
||||||
updateItemWithClassification
|
|
||||||
} from "../api/list";
|
|
||||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
import { ZONE_FLOW } from "../constants/classifications";
|
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { role } = useContext(AuthContext);
|
const { role } = useContext(AuthContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
|
||||||
|
|
||||||
// === State === //
|
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
||||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
const [sortedItems, setSortedItems] = useState([]);
|
||||||
|
const [sortMode, setSortMode] = useState("zone");
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(true);
|
const [showAddForm, setShowAddForm] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -45,13 +30,7 @@ export default function GroceryList() {
|
|||||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
|
||||||
const [collapsedZones, setCollapsedZones] = useState({});
|
|
||||||
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
|
||||||
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getList();
|
const res = await getList();
|
||||||
@ -60,7 +39,6 @@ export default function GroceryList() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const loadRecentlyBought = async () => {
|
const loadRecentlyBought = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRecentlyBought();
|
const res = await getRecentlyBought();
|
||||||
@ -71,24 +49,13 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems();
|
loadItems();
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// === Zone Collapse Handler ===
|
let sorted = [...items];
|
||||||
const toggleZoneCollapse = (zone) => {
|
|
||||||
setCollapsedZones(prev => ({
|
|
||||||
...prev,
|
|
||||||
[zone]: !prev[zone]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Sorted Items Computation ===
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
const sorted = [...items];
|
|
||||||
|
|
||||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||||
@ -97,22 +64,11 @@ export default function GroceryList() {
|
|||||||
if (sortMode === "zone") {
|
if (sortMode === "zone") {
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
// Items without classification go to the end
|
// Items without classification go to the end
|
||||||
if (!a.zone && b.zone) return 1;
|
if (!a.item_type && b.item_type) return 1;
|
||||||
if (a.zone && !b.zone) return -1;
|
if (a.item_type && !b.item_type) return -1;
|
||||||
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
||||||
|
|
||||||
// Sort by ZONE_FLOW order
|
// Sort by item_type
|
||||||
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
|
||||||
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
|
||||||
|
|
||||||
// If zone not in ZONE_FLOW, put at end
|
|
||||||
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
|
||||||
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
|
||||||
|
|
||||||
const zoneCompare = aIndex - bIndex;
|
|
||||||
if (zoneCompare !== 0) return zoneCompare;
|
|
||||||
|
|
||||||
// Then by item_type
|
|
||||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||||
if (typeCompare !== 0) return typeCompare;
|
if (typeCompare !== 0) return typeCompare;
|
||||||
|
|
||||||
@ -120,16 +76,18 @@ export default function GroceryList() {
|
|||||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||||
if (groupCompare !== 0) return groupCompare;
|
if (groupCompare !== 0) return groupCompare;
|
||||||
|
|
||||||
|
// Then by zone
|
||||||
|
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
||||||
|
if (zoneCompare !== 0) return zoneCompare;
|
||||||
|
|
||||||
// Finally by name
|
// Finally by name
|
||||||
return a.item_name.localeCompare(b.item_name);
|
return a.item_name.localeCompare(b.item_name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return sorted;
|
setSortedItems(sorted);
|
||||||
}, [items, sortMode]);
|
}, [items, sortMode]);
|
||||||
|
|
||||||
|
|
||||||
// === Suggestion Handler ===
|
|
||||||
const handleSuggest = async (text) => {
|
const handleSuggest = async (text) => {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
@ -137,27 +95,34 @@ export default function GroceryList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combine both unbought and recently bought items for similarity checking
|
||||||
|
const allItems = [...items, ...recentlyBoughtItems];
|
||||||
|
|
||||||
|
// Check if exact match exists (case-insensitive)
|
||||||
const lowerText = text.toLowerCase().trim();
|
const lowerText = text.toLowerCase().trim();
|
||||||
|
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
setButtonText("Add");
|
||||||
|
} else {
|
||||||
|
setButtonText("Create + Add");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getSuggestions(text);
|
let suggestions = await getSuggestions(text);
|
||||||
const suggestionList = response.data.map(s => s.item_name);
|
suggestions = suggestions.data.map(s => s.item_name);
|
||||||
setSuggestions(suggestionList);
|
setSuggestions(suggestions);
|
||||||
|
|
||||||
// All suggestions are now lowercase from DB, direct comparison
|
|
||||||
const exactMatch = suggestionList.includes(lowerText);
|
|
||||||
setButtonText(exactMatch ? "Add" : "Create + Add");
|
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Create + Add");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (itemName, quantity) => {
|
||||||
// === Item Addition Handlers ===
|
|
||||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
|
|
||||||
|
const lowerItemName = itemName.toLowerCase().trim();
|
||||||
|
|
||||||
|
// First check if exact item exists in database (case-insensitive)
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(itemName);
|
||||||
@ -166,27 +131,29 @@ export default function GroceryList() {
|
|||||||
existingItem = null;
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If exact item exists, skip similarity check and process directly
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
await processItemAddition(itemName, quantity);
|
await processItemAddition(itemName, quantity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setItems(prevItems => {
|
// Only check for similar items if exact item doesn't exist
|
||||||
const allItems = [...prevItems, ...recentlyBoughtItems];
|
const allItems = [...items, ...recentlyBoughtItems];
|
||||||
const similar = findSimilarItems(itemName, allItems, 70);
|
const similar = findSimilarItems(itemName, allItems, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
processItemAddition(itemName, quantity);
|
// Continue with normal flow for new items
|
||||||
return prevItems;
|
await processItemAddition(itemName, quantity);
|
||||||
});
|
};
|
||||||
}, [recentlyBoughtItems]);
|
|
||||||
|
|
||||||
|
const processItemAddition = async (itemName, quantity) => {
|
||||||
|
|
||||||
const processItemAddition = useCallback(async (itemName, quantity) => {
|
// Check if item exists in database (case-insensitive)
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(itemName);
|
||||||
@ -195,205 +162,126 @@ export default function GroceryList() {
|
|||||||
existingItem = null;
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem?.bought === false) {
|
if (existingItem && existingItem.bought === false) {
|
||||||
|
// Item exists and is unbought - update quantity
|
||||||
const currentQuantity = existingItem.quantity;
|
const currentQuantity = existingItem.quantity;
|
||||||
const newQuantity = currentQuantity + quantity;
|
const newQuantity = currentQuantity + quantity;
|
||||||
|
const yes = window.confirm(
|
||||||
|
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
||||||
|
);
|
||||||
|
if (!yes) return;
|
||||||
|
|
||||||
// Show modal instead of window.confirm
|
await addItem(itemName, newQuantity, null);
|
||||||
setConfirmAddExistingData({
|
setSuggestions([]);
|
||||||
itemName,
|
setButtonText("Add Item");
|
||||||
currentQuantity,
|
loadItems();
|
||||||
addingQuantity: quantity,
|
|
||||||
newQuantity,
|
|
||||||
existingItem
|
|
||||||
});
|
|
||||||
setShowConfirmAddExisting(true);
|
|
||||||
} else if (existingItem) {
|
} else if (existingItem) {
|
||||||
|
// Item exists in database (was previously bought) - just add quantity
|
||||||
await addItem(itemName, quantity, null);
|
await addItem(itemName, quantity, null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
loadItems();
|
||||||
// Reload lists to reflect the changes
|
|
||||||
await loadItems();
|
|
||||||
await loadRecentlyBought();
|
|
||||||
} else {
|
} else {
|
||||||
|
// NEW ITEM - show combined add details modal
|
||||||
setPendingItem({ itemName, quantity });
|
setPendingItem({ itemName, quantity });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleSimilarCancel = () => {
|
||||||
// === Similar Item Modal Handlers ===
|
|
||||||
const handleSimilarCancel = useCallback(() => {
|
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleSimilarNo = async () => {
|
||||||
const handleSimilarNo = useCallback(async () => {
|
|
||||||
if (!similarItemSuggestion) return;
|
if (!similarItemSuggestion) return;
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
|
// Create new item with original name
|
||||||
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
}, [similarItemSuggestion, processItemAddition]);
|
};
|
||||||
|
|
||||||
|
const handleSimilarYes = async () => {
|
||||||
const handleSimilarYes = useCallback(async () => {
|
|
||||||
if (!similarItemSuggestion) return;
|
if (!similarItemSuggestion) return;
|
||||||
setShowSimilarModal(false);
|
setShowSimilarModal(false);
|
||||||
|
// Use suggested item name
|
||||||
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
}, [similarItemSuggestion, processItemAddition]);
|
};
|
||||||
|
|
||||||
|
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
||||||
// === Confirm Add Existing Modal Handlers ===
|
|
||||||
const handleConfirmAddExisting = useCallback(async () => {
|
|
||||||
if (!confirmAddExistingData) return;
|
|
||||||
|
|
||||||
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
|
||||||
|
|
||||||
setShowConfirmAddExisting(false);
|
|
||||||
setConfirmAddExistingData(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update the item
|
|
||||||
await addItem(itemName, newQuantity, null);
|
|
||||||
|
|
||||||
// Fetch the updated item with properly formatted data
|
|
||||||
const response = await getItemByName(itemName);
|
|
||||||
const updatedItem = response.data;
|
|
||||||
|
|
||||||
// Update state with the full item data
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === existingItem.id ? updatedItem : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
setSuggestions([]);
|
|
||||||
setButtonText("Add Item");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update item:", error);
|
|
||||||
// Fallback to full reload on error
|
|
||||||
await loadItems();
|
|
||||||
}
|
|
||||||
}, [confirmAddExistingData, loadItems]);
|
|
||||||
|
|
||||||
const handleCancelAddExisting = useCallback(() => {
|
|
||||||
setShowConfirmAddExisting(false);
|
|
||||||
setConfirmAddExistingData(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
// === Add Details Modal Handlers ===
|
|
||||||
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
|
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
// Add item to grocery_list with image
|
||||||
let newItem = addResponse.data;
|
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||||
|
|
||||||
|
// If classification provided, add it
|
||||||
if (classification) {
|
if (classification) {
|
||||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||||
const itemId = itemResponse.data.id;
|
const itemId = itemResponse.data.id;
|
||||||
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
|
await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||||
newItem = { ...newItem, ...updateResponse.data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
loadItems();
|
||||||
if (newItem) {
|
|
||||||
setItems(prevItems => [...prevItems, newItem]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
}, [pendingItem]);
|
};
|
||||||
|
|
||||||
|
const handleAddDetailsSkip = async () => {
|
||||||
const handleAddDetailsSkip = useCallback(async () => {
|
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
// Add item without image or classification
|
||||||
|
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
loadItems();
|
||||||
if (response.data) {
|
|
||||||
setItems(prevItems => [...prevItems, response.data]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
}, [pendingItem]);
|
};
|
||||||
|
|
||||||
|
const handleAddDetailsCancel = () => {
|
||||||
const handleAddDetailsCancel = useCallback(() => {
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
|
||||||
// === Item Action Handlers ===
|
|
||||||
const handleBought = useCallback(async (id, quantity) => {
|
|
||||||
const item = items.find(i => i.id === id);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
await markBought(id, quantity);
|
|
||||||
|
|
||||||
// If buying full quantity, remove from list
|
|
||||||
if (quantity >= item.quantity) {
|
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
|
||||||
} else {
|
|
||||||
// If partial, update quantity
|
|
||||||
const response = await getItemByName(item.item_name);
|
|
||||||
if (response.data) {
|
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item => item.id === id ? response.data : item)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleBought = async (id, quantity) => {
|
||||||
|
await markBought(id);
|
||||||
|
loadItems();
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [items]);
|
};
|
||||||
|
|
||||||
|
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
|
||||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
await updateItemImage(id, itemName, quantity, imageFile);
|
||||||
|
loadItems(); // Reload to show new image
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...response.data } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
setRecentlyBoughtItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...response.data } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add image:", error);
|
console.error("Failed to add image:", error);
|
||||||
alert("Failed to add image. Please try again.");
|
alert("Failed to add image. Please try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleLongPress = async (item) => {
|
||||||
const handleLongPress = useCallback(async (item) => {
|
|
||||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch existing classification
|
||||||
const classificationResponse = await getClassification(item.id);
|
const classificationResponse = await getClassification(item.id);
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...item,
|
...item,
|
||||||
@ -405,42 +293,27 @@ export default function GroceryList() {
|
|||||||
setEditingItem({ ...item, classification: null });
|
setEditingItem({ ...item, classification: null });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}
|
}
|
||||||
}, [role]);
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (id, itemName, quantity, classification) => {
|
||||||
// === Edit Modal Handlers ===
|
|
||||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await updateItemWithClassification(id, itemName, quantity, classification);
|
await updateItemWithClassification(id, itemName, quantity, classification);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
|
loadItems();
|
||||||
const updatedItem = response.data;
|
loadRecentlyBought();
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...updatedItem } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
setRecentlyBoughtItems(prevItems =>
|
|
||||||
prevItems.map(item =>
|
|
||||||
item.id === id ? { ...item, ...updatedItem } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
throw error;
|
throw error; // Re-throw to let modal handle it
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
const handleEditCancel = useCallback(() => {
|
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
|
// Group items by zone for classification view
|
||||||
// === Helper Functions ===
|
|
||||||
const groupItemsByZone = (items) => {
|
const groupItemsByZone = (items) => {
|
||||||
const groups = {};
|
const groups = {};
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@ -453,10 +326,8 @@ export default function GroceryList() {
|
|||||||
return groups;
|
return groups;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <p>Loading...</p>;
|
if (loading) return <p>Loading...</p>;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
@ -475,35 +346,21 @@ export default function GroceryList() {
|
|||||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||||
|
|
||||||
{sortMode === "zone" ? (
|
{sortMode === "zone" ? (
|
||||||
|
// Grouped view by zone
|
||||||
(() => {
|
(() => {
|
||||||
const grouped = groupItemsByZone(sortedItems);
|
const grouped = groupItemsByZone(sortedItems);
|
||||||
return Object.keys(grouped).map(zone => {
|
return Object.keys(grouped).map(zone => (
|
||||||
const isCollapsed = collapsedZones[zone];
|
|
||||||
const itemCount = grouped[zone].length;
|
|
||||||
return (
|
|
||||||
<div key={zone} className="glist-classification-group">
|
<div key={zone} className="glist-classification-group">
|
||||||
<h3
|
<h3 className="glist-classification-header">
|
||||||
className="glist-classification-header clickable"
|
|
||||||
onClick={() => toggleZoneCollapse(zone)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||||
<span className="glist-zone-count"> ({itemCount})</span>
|
|
||||||
</span>
|
|
||||||
<span className="glist-zone-indicator">
|
|
||||||
{isCollapsed ? "▼" : "▲"}
|
|
||||||
</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
{!isCollapsed && (
|
<ul className="glist-ul">
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
|
||||||
{grouped[zone].map((item) => (
|
{grouped[zone].map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={sortedItems}
|
onClick={(quantity) =>
|
||||||
compact={settings.compactView}
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||||
onClick={(id, quantity) =>
|
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
|
||||||
}
|
}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
@ -514,21 +371,18 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
// Regular flat list view
|
||||||
|
<ul className="glist-ul">
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={sortedItems}
|
onClick={(quantity) =>
|
||||||
compact={settings.compactView}
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||||
onClick={(id, quantity) =>
|
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
|
||||||
}
|
}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
@ -541,27 +395,14 @@ export default function GroceryList() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
{recentlyBoughtItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2
|
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||||
className="glist-section-title clickable"
|
<ul className="glist-ul">
|
||||||
onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
|
|
||||||
>
|
|
||||||
<span>Recently Bought (24HR)</span>
|
|
||||||
<span className="glist-section-indicator">
|
|
||||||
{recentlyBoughtCollapsed ? "▼" : "▲"}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{!recentlyBoughtCollapsed && (
|
|
||||||
<>
|
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
|
||||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allItems={recentlyBoughtItems}
|
|
||||||
compact={settings.compactView}
|
|
||||||
onClick={null}
|
onClick={null}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
@ -584,8 +425,6 @@ export default function GroceryList() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||||
@ -619,17 +458,6 @@ export default function GroceryList() {
|
|||||||
item={editingItem}
|
item={editingItem}
|
||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
onImageUpdate={handleImageAdded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfirmAddExisting && confirmAddExistingData && (
|
|
||||||
<ConfirmAddExistingModal
|
|
||||||
itemName={confirmAddExistingData.itemName}
|
|
||||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
|
||||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
|
||||||
onConfirm={handleConfirmAddExisting}
|
|
||||||
onCancel={handleCancelAddExisting}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,16 +27,16 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-center" style={{ minHeight: '100vh', padding: '1em', background: '#f8f9fa' }}>
|
<div className="login-wrapper">
|
||||||
<div className="card card-elevated" style={{ width: '100%', maxWidth: '360px' }}>
|
<div className="login-box">
|
||||||
<h1 className="text-center text-2xl mb-3">Login</h1>
|
<h1 className="login-title">Login</h1>
|
||||||
|
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input my-2"
|
className="login-input"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -44,7 +44,7 @@ export default function Login() {
|
|||||||
<div className="login-password-wrapper">
|
<div className="login-password-wrapper">
|
||||||
<FormInput
|
<FormInput
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="form-input"
|
className="login-input"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -58,11 +58,11 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary btn-block mt-2">Login</button>
|
<button type="submit" className="login-button">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center mt-3">
|
<p className="login-register">
|
||||||
Need an account? <Link to="/register" className="text-primary">Register here</Link>
|
Need an account? <Link to="/register">Register here</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function Register() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="register-container">
|
<div className="register-container">
|
||||||
<h1 className="text-center mb-4 text-2xl font-bold">Register</h1>
|
<h1>Register</h1>
|
||||||
|
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
<ErrorMessage message={success} type="success" />
|
<ErrorMessage message={success} type="success" />
|
||||||
@ -67,7 +67,6 @@ export default function Register() {
|
|||||||
<form className="register-form" onSubmit={submit}>
|
<form className="register-form" onSubmit={submit}>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -75,7 +74,6 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onKeyUp={(e) => setUsername(e.target.value)}
|
onKeyUp={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -83,7 +81,6 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
className="form-input"
|
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -91,19 +88,18 @@ export default function Register() {
|
|||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password"
|
||||||
className="form-input"
|
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button disabled={error !== ""} type="submit" className="btn btn-primary btn-block mt-2">
|
<button disabled={error !== ""} type="submit">
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center mt-3">
|
<p className="register-link">
|
||||||
Already have an account? <Link to="/login" className="text-primary font-semibold">Login here</Link>
|
Already have an account? <Link to="/login">Login here</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,426 +0,0 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
|
||||||
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
|
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
|
||||||
import "../styles/pages/Settings.css";
|
|
||||||
|
|
||||||
|
|
||||||
export default function Settings() {
|
|
||||||
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
|
|
||||||
const [activeTab, setActiveTab] = useState("appearance");
|
|
||||||
|
|
||||||
// Account management state
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [accountMessage, setAccountMessage] = useState({ type: "", text: "" });
|
|
||||||
const [loadingProfile, setLoadingProfile] = useState(false);
|
|
||||||
const [loadingPassword, setLoadingPassword] = useState(false);
|
|
||||||
|
|
||||||
// Load user profile
|
|
||||||
useEffect(() => {
|
|
||||||
const loadProfile = async () => {
|
|
||||||
try {
|
|
||||||
const response = await getCurrentUser();
|
|
||||||
setDisplayName(response.data.display_name || response.data.name || "");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load profile:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadProfile();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
const handleThemeChange = (theme) => {
|
|
||||||
updateSettings({ theme });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateDisplayName = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoadingProfile(true);
|
|
||||||
setAccountMessage({ type: "", text: "" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateCurrentUser(displayName);
|
|
||||||
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
|
|
||||||
} catch (error) {
|
|
||||||
setAccountMessage({
|
|
||||||
type: "error",
|
|
||||||
text: error.response?.data?.error || "Failed to update display name"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingProfile(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoadingPassword(true);
|
|
||||||
setAccountMessage({ type: "", text: "" });
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setAccountMessage({ type: "error", text: "New passwords don't match" });
|
|
||||||
setLoadingPassword(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
setAccountMessage({ type: "error", text: "Password must be at least 6 characters" });
|
|
||||||
setLoadingPassword(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await changePassword(currentPassword, newPassword);
|
|
||||||
setAccountMessage({ type: "success", text: "Password changed successfully!" });
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
} catch (error) {
|
|
||||||
setAccountMessage({
|
|
||||||
type: "error",
|
|
||||||
text: error.response?.data?.error || "Failed to change password"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingPassword(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleToggle = (key) => {
|
|
||||||
updateSettings({ [key]: !settings[key] });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleNumberChange = (key, value) => {
|
|
||||||
updateSettings({ [key]: parseInt(value, 10) });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleSelectChange = (key, value) => {
|
|
||||||
updateSettings({ [key]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (window.confirm("Reset all settings to defaults?")) {
|
|
||||||
resetSettings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-page">
|
|
||||||
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
|
||||||
<h1 className="text-2xl font-semibold mb-4">Settings</h1>
|
|
||||||
|
|
||||||
<div className="settings-tabs">
|
|
||||||
<button
|
|
||||||
className={`settings-tab ${activeTab === "appearance" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("appearance")}
|
|
||||||
>
|
|
||||||
Appearance
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`settings-tab ${activeTab === "list" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("list")}
|
|
||||||
>
|
|
||||||
List Display
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`settings-tab ${activeTab === "behavior" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("behavior")}
|
|
||||||
>
|
|
||||||
Behavior
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`settings-tab ${activeTab === "account" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("account")}
|
|
||||||
>
|
|
||||||
Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-content">
|
|
||||||
{/* Appearance Tab */}
|
|
||||||
{activeTab === "appearance" && (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Appearance</h2>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">Theme</label>
|
|
||||||
<div className="settings-theme-options">
|
|
||||||
<button
|
|
||||||
className={`settings-theme-btn ${settings.theme === "light" ? "active" : ""}`}
|
|
||||||
onClick={() => handleThemeChange("light")}
|
|
||||||
>
|
|
||||||
☀️ Light
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`settings-theme-btn ${settings.theme === "dark" ? "active" : ""}`}
|
|
||||||
onClick={() => handleThemeChange("dark")}
|
|
||||||
>
|
|
||||||
🌙 Dark
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`settings-theme-btn ${settings.theme === "auto" ? "active" : ""}`}
|
|
||||||
onClick={() => handleThemeChange("auto")}
|
|
||||||
>
|
|
||||||
🔄 Auto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="settings-description">
|
|
||||||
Auto mode follows your system preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.compactView}
|
|
||||||
onChange={() => handleToggle("compactView")}
|
|
||||||
/>
|
|
||||||
<span>Compact View</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">
|
|
||||||
Show more items on screen with reduced spacing
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* List Display Tab */}
|
|
||||||
{activeTab === "list" && (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">List Display</h2>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">Default Sort Mode</label>
|
|
||||||
<select
|
|
||||||
value={settings.defaultSortMode}
|
|
||||||
onChange={(e) => handleSelectChange("defaultSortMode", e.target.value)}
|
|
||||||
className="form-select mt-2"
|
|
||||||
>
|
|
||||||
<option value="zone">By Zone</option>
|
|
||||||
<option value="az">A → Z</option>
|
|
||||||
<option value="za">Z → A</option>
|
|
||||||
<option value="qty-high">Quantity: High → Low</option>
|
|
||||||
<option value="qty-low">Quantity: Low → High</option>
|
|
||||||
</select>
|
|
||||||
<p className="settings-description">
|
|
||||||
Your preferred sorting method when opening the list
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.showRecentlyBought}
|
|
||||||
onChange={() => handleToggle("showRecentlyBought")}
|
|
||||||
/>
|
|
||||||
<span>Show Recently Bought Section</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">
|
|
||||||
Display items bought in the last 24 hours
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settings.showRecentlyBought && (
|
|
||||||
<>
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
Recently Bought Item Count: {settings.recentlyBoughtCount}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="5"
|
|
||||||
max="50"
|
|
||||||
step="5"
|
|
||||||
value={settings.recentlyBoughtCount}
|
|
||||||
onChange={(e) => handleNumberChange("recentlyBoughtCount", e.target.value)}
|
|
||||||
className="settings-range"
|
|
||||||
/>
|
|
||||||
<p className="settings-description">
|
|
||||||
Number of items to show initially (5-50)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.recentlyBoughtCollapsed}
|
|
||||||
onChange={() => handleToggle("recentlyBoughtCollapsed")}
|
|
||||||
/>
|
|
||||||
<span>Collapse Recently Bought by Default</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">
|
|
||||||
Start with the section collapsed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Behavior Tab */}
|
|
||||||
{activeTab === "behavior" && (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Behavior</h2>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.confirmBeforeBuy}
|
|
||||||
onChange={() => handleToggle("confirmBeforeBuy")}
|
|
||||||
/>
|
|
||||||
<span>Confirm Before Buying</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">
|
|
||||||
Show confirmation modal when marking items as bought
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
Auto-reload Interval (minutes): {settings.autoReloadInterval || "Disabled"}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="30"
|
|
||||||
step="5"
|
|
||||||
value={settings.autoReloadInterval}
|
|
||||||
onChange={(e) => handleNumberChange("autoReloadInterval", e.target.value)}
|
|
||||||
className="settings-range"
|
|
||||||
/>
|
|
||||||
<p className="settings-description">
|
|
||||||
Automatically refresh the list every X minutes (0 = disabled)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.hapticFeedback}
|
|
||||||
onChange={() => handleToggle("hapticFeedback")}
|
|
||||||
/>
|
|
||||||
<span>Haptic Feedback (Mobile)</span>
|
|
||||||
</label>
|
|
||||||
<p className="settings-description">
|
|
||||||
Vibrate on long-press and other interactions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account Tab */}
|
|
||||||
{activeTab === "account" && (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Account Management</h2>
|
|
||||||
|
|
||||||
{accountMessage.text && (
|
|
||||||
<div className={`account-message ${accountMessage.type}`}>
|
|
||||||
{accountMessage.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Display Name Section */}
|
|
||||||
<form onSubmit={handleUpdateDisplayName} className="account-form">
|
|
||||||
<h3 className="text-lg font-semibold mb-3">Display Name</h3>
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
Display Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
maxLength={100}
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Your display name"
|
|
||||||
/>
|
|
||||||
<p className="settings-description">
|
|
||||||
{displayName.length}/100 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={loadingProfile}
|
|
||||||
>
|
|
||||||
{loadingProfile ? "Saving..." : "Save Display Name"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr className="my-4" style={{ borderColor: 'var(--border-color)' }} />
|
|
||||||
|
|
||||||
{/* Password Change Section */}
|
|
||||||
<form onSubmit={handleChangePassword} className="account-form">
|
|
||||||
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
Current Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="settings-description">
|
|
||||||
Minimum 6 characters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">
|
|
||||||
Confirm New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={loadingPassword}
|
|
||||||
>
|
|
||||||
{loadingPassword ? "Changing..." : "Change Password"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<button onClick={handleReset} className="btn btn-outline">
|
|
||||||
Reset to Defaults
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,44 @@
|
|||||||
/* AddImageModal - custom styles for unique components */
|
.add-image-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-modal {
|
||||||
|
background: white;
|
||||||
|
padding: 2em;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-modal h2 {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-subtitle {
|
||||||
|
margin: 0 0 1.5em 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-subtitle strong {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
.add-image-options {
|
.add-image-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -8,33 +48,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn {
|
.add-image-option-btn {
|
||||||
padding: var(--spacing-lg);
|
padding: 1.2em;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #ddd;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
background: var(--color-bg-surface);
|
background: white;
|
||||||
font-size: var(--font-size-lg);
|
font-size: 1.1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--spacing-sm);
|
gap: 0.5em;
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn:hover {
|
.add-image-option-btn:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: #007bff;
|
||||||
background: var(--color-bg-hover);
|
background: #f8f9fa;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn.camera {
|
.add-image-option-btn.camera {
|
||||||
color: var(--color-primary);
|
color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-option-btn.gallery {
|
.add-image-option-btn.gallery {
|
||||||
color: var(--color-success);
|
color: #28a745;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-preview-container {
|
.add-image-preview-container {
|
||||||
@ -47,10 +86,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #ddd;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-gray-100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-image-preview img {
|
.add-image-preview img {
|
||||||
@ -81,3 +119,58 @@
|
|||||||
.add-image-remove:hover {
|
.add-image-remove:hover {
|
||||||
background: rgba(255, 0, 0, 1);
|
background: rgba(255, 0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-image-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-cancel,
|
||||||
|
.add-image-confirm {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-cancel {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-cancel:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-confirm {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-image-confirm:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -4,128 +4,68 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--modal-backdrop-bg);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: var(--z-modal);
|
z-index: 1000;
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-modal {
|
.confirm-buy-modal {
|
||||||
background: var(--modal-bg);
|
background: white;
|
||||||
padding: var(--spacing-md);
|
padding: 2em;
|
||||||
border-radius: var(--border-radius-xl);
|
border-radius: 12px;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
animation: slideUp 0.3s ease-out;
|
animation: slideUp 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-header {
|
.confirm-buy-modal h2 {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #333;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-zone {
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-item-name {
|
.confirm-buy-item-name {
|
||||||
margin: 0;
|
margin: 0 0 1.5em 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.1em;
|
||||||
color: var(--color-primary);
|
color: #007bff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
.confirm-buy-image-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.6em;
|
|
||||||
margin: 0.8em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
|
||||||
border-radius: var(--border-radius-full);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: 1.8em;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-nav-btn:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-nav-btn:disabled {
|
|
||||||
border-color: var(--color-border-medium);
|
|
||||||
color: var(--color-text-disabled);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image-container {
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-gray-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image-placeholder {
|
|
||||||
font-size: 4em;
|
|
||||||
color: var(--color-border-medium);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-quantity-section {
|
.confirm-buy-quantity-section {
|
||||||
margin: 0.8em 0;
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-label {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
font-size: 1em;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter {
|
.confirm-buy-counter {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.8em;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn {
|
.confirm-buy-counter-btn {
|
||||||
width: 45px;
|
width: 50px;
|
||||||
height: 45px;
|
height: 50px;
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: 2px solid #007bff;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
background: var(--color-bg-surface);
|
background: white;
|
||||||
color: var(--color-primary);
|
color: #007bff;
|
||||||
font-size: 1.6em;
|
font-size: 1.8em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -134,68 +74,67 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn:hover:not(:disabled) {
|
.confirm-buy-counter-btn:hover:not(:disabled) {
|
||||||
background: var(--color-primary);
|
background: #007bff;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn:disabled {
|
.confirm-buy-counter-btn:disabled {
|
||||||
border-color: var(--color-border-medium);
|
border-color: #ccc;
|
||||||
color: var(--color-text-disabled);
|
color: #ccc;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-display {
|
.confirm-buy-counter-display {
|
||||||
width: 70px;
|
width: 80px;
|
||||||
height: 45px;
|
height: 50px;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #ddd;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.4em;
|
font-size: 1.5em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
background: var(--color-gray-100);
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-display:focus {
|
.confirm-buy-counter-display:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-actions {
|
.confirm-buy-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6em;
|
gap: 1em;
|
||||||
margin-top: 1em;
|
margin-top: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel,
|
.confirm-buy-cancel,
|
||||||
.confirm-buy-confirm {
|
.confirm-buy-confirm {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75em 0.5em;
|
padding: 0.9em;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
font-size: 0.95em;
|
font-size: 1em;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel {
|
.confirm-buy-cancel {
|
||||||
background: var(--color-gray-200);
|
background: #f0f0f0;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel:hover {
|
.confirm-buy-cancel:hover {
|
||||||
background: var(--color-gray-300);
|
background: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-confirm {
|
.confirm-buy-confirm {
|
||||||
background: var(--color-success);
|
background: #28a745;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-confirm:hover {
|
.confirm-buy-confirm:hover {
|
||||||
background: var(--color-success-hover);
|
background: #218838;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@ -217,88 +156,3 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile optimizations */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.confirm-buy-modal {
|
|
||||||
padding: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-header {
|
|
||||||
margin-bottom: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-zone {
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-item-name {
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image-section {
|
|
||||||
gap: 0.5em;
|
|
||||||
margin: 0.6em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-actions {
|
|
||||||
gap: 0.5em;
|
|
||||||
margin-top: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-cancel,
|
|
||||||
.confirm-buy-confirm {
|
|
||||||
padding: 0.7em 0.4em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image-container {
|
|
||||||
width: 220px;
|
|
||||||
height: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-counter-btn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-counter-display {
|
|
||||||
width: 60px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-quantity-section {
|
|
||||||
margin: 0.6em 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
|
||||||
.confirm-buy-modal {
|
|
||||||
padding: 0.7em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-cancel,
|
|
||||||
.confirm-buy-confirm {
|
|
||||||
padding: 0.65em 0.3em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-image-container {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
/* ImageModal - specialized full-screen image viewer */
|
|
||||||
|
|
||||||
.image-modal-overlay {
|
.image-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -46,6 +44,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
right: -15px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
border: 3px solid white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1001;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-close:hover {
|
||||||
|
background: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
@ -54,6 +76,14 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-modal-caption {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.image-modal-overlay {
|
.image-modal-overlay {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@ -62,5 +92,8 @@
|
|||||||
.image-modal-img {
|
.image-modal-img {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
.image-modal-caption {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,24 +1,115 @@
|
|||||||
/* SimilarItemModal - custom styles */
|
.similar-item-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
.similar-item-suggested {
|
top: 0;
|
||||||
color: var(--color-success);
|
left: 0;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item-original {
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-modal-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-modal-actions .btn {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.similar-item-modal {
|
||||||
|
background: white;
|
||||||
|
padding: 2em;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-modal h2 {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-question {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-question strong {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-clarification {
|
||||||
|
margin: 0 0 2em 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-cancel,
|
||||||
|
.similar-item-no,
|
||||||
|
.similar-item-yes {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-cancel {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-cancel:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-no {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-no:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-yes {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-yes:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,40 @@
|
|||||||
/* UserRoleCard - custom styles only */
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xs);
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-username {
|
.user-username {
|
||||||
color: var(--color-text-secondary);
|
color: #666;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-select:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|||||||
@ -29,12 +29,6 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-form-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-input:focus {
|
.add-item-form-input:focus {
|
||||||
@ -113,8 +107,6 @@
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
-moz-appearance: textfield; /* Remove spinner in Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,8 +133,7 @@
|
|||||||
border-radius: var(--button-border-radius);
|
border-radius: var(--button-border-radius);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: var(--button-font-weight);
|
||||||
flex: 1;
|
cursor: pointer;
|
||||||
min-width: 120px
|
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
@ -159,13 +150,12 @@
|
|||||||
|
|
||||||
.add-item-form-submit.disabled,
|
.add-item-form-submit.disabled,
|
||||||
.add-item-form-submit:disabled {
|
.add-item-form-submit:disabled {
|
||||||
background: var(--color-gray-400);
|
background: var(--color-bg-disabled);
|
||||||
color: var(--color-gray-600);
|
color: var(--color-text-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 1;
|
opacity: 0.6;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
border: var(--border-width-thin) solid var(--color-gray-500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
|||||||
@ -4,43 +4,43 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: var(--modal-backdrop-bg);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: var(--z-modal);
|
z-index: 1000;
|
||||||
padding: var(--spacing-md);
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
background: var(--modal-bg);
|
background: white;
|
||||||
border-radius: var(--border-radius-xl);
|
border-radius: 12px;
|
||||||
padding: var(--spacing-xl);
|
padding: 1.5em;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: var(--font-size-xl);
|
font-size: 1.4em;
|
||||||
margin: 0 0 var(--spacing-xs) 0;
|
margin: 0 0 0.3em 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-subtitle {
|
.add-item-details-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-secondary);
|
color: #666;
|
||||||
margin: 0 0 var(--spacing-xl) 0;
|
margin: 0 0 1.5em 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section {
|
.add-item-details-section {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: 1.5em;
|
||||||
padding-bottom: var(--spacing-xl);
|
padding-bottom: 1.5em;
|
||||||
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section:last-of-type {
|
.add-item-details-section:last-of-type {
|
||||||
@ -48,9 +48,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section-title {
|
.add-item-details-section-title {
|
||||||
font-size: var(--font-size-lg);
|
font-size: 1.1em;
|
||||||
margin: 0 0 var(--spacing-md) 0;
|
margin: 0 0 1em 0;
|
||||||
color: var(--color-text-secondary);
|
color: #555;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,27 +68,27 @@
|
|||||||
.add-item-details-image-btn {
|
.add-item-details-image-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: 0.8em;
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: 2px solid #007bff;
|
||||||
background: var(--color-bg-surface);
|
background: white;
|
||||||
color: var(--color-primary);
|
color: #007bff;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: 600;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-btn:hover {
|
.add-item-details-image-btn:hover {
|
||||||
background: var(--color-primary);
|
background: #007bff;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-preview {
|
.add-item-details-image-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-preview img {
|
.add-item-details-image-preview img {
|
||||||
|
|||||||
@ -1,45 +1,44 @@
|
|||||||
/* Classification Section */
|
/* Classification Section */
|
||||||
.classification-section {
|
.classification-section {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-title {
|
.classification-title {
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: 0.8rem;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-field {
|
.classification-field {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-field label {
|
.classification-field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 0.4rem;
|
||||||
color: var(--color-text-secondary);
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select {
|
.classification-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
padding: 0.6rem;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: 1px solid #ccc;
|
||||||
border-radius: var(--input-border-radius);
|
border-radius: 4px;
|
||||||
background: var(--color-bg-surface);
|
background: white;
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select:focus {
|
.classification-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--input-focus-border-color);
|
border-color: #007bff;
|
||||||
box-shadow: var(--input-focus-shadow);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-select:hover {
|
.classification-select:hover {
|
||||||
border-color: var(--color-border-dark);
|
border-color: #999;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
/* ConfirmAddExistingModal - quantity breakdown box */
|
|
||||||
|
|
||||||
.confirm-add-existing-qty-info {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
margin: var(--spacing-md) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-row.qty-total {
|
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
padding-top: var(--spacing-sm);
|
|
||||||
border-top: var(--border-width-medium) solid var(--color-border-medium);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-label {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-value {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-total .qty-label,
|
|
||||||
.qty-total .qty-value {
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -4,141 +4,88 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: var(--modal-backdrop-bg);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: var(--z-modal);
|
z-index: 1000;
|
||||||
padding: var(--spacing-md);
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-content {
|
.edit-modal-content {
|
||||||
background: var(--modal-bg);
|
background: white;
|
||||||
border-radius: var(--border-radius-xl);
|
border-radius: 12px;
|
||||||
padding: var(--spacing-lg);
|
padding: 1.5em;
|
||||||
max-width: 420px;
|
max-width: 480px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-title {
|
.edit-modal-title {
|
||||||
font-size: var(--font-size-xl);
|
font-size: 1.5em;
|
||||||
margin: 0 0 var(--spacing-md) 0;
|
margin: 0 0 1em 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-subtitle {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0.5em 0 0.8em 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-field {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-input,
|
.edit-modal-input,
|
||||||
.edit-modal-select {
|
.edit-modal-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
padding: 0.6em;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: 1px solid #ccc;
|
||||||
border-radius: var(--input-border-radius);
|
border-radius: 6px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: var(--transition-base);
|
transition: border-color 0.2s;
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-input:focus,
|
.edit-modal-input:focus,
|
||||||
.edit-modal-select:focus {
|
.edit-modal-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--input-focus-border-color);
|
border-color: #007bff;
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quantity Control - matching AddItemForm */
|
|
||||||
.edit-modal-quantity-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-quantity-input {
|
|
||||||
width: 60px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-btn {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-btn:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline Classification Fields */
|
|
||||||
.edit-modal-inline-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-inline-field label {
|
|
||||||
min-width: 60px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-inline-field .edit-modal-select {
|
|
||||||
flex: 1;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-divider {
|
.edit-modal-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--color-border-light);
|
background: #e0e0e0;
|
||||||
margin: var(--spacing-md) 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-actions {
|
.edit-modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: 0.8em;
|
||||||
margin-top: var(--spacing-md);
|
margin-top: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn {
|
.edit-modal-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: 0.7em;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--button-border-radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: 600;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn:disabled {
|
.edit-modal-btn:disabled {
|
||||||
@ -147,43 +94,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-cancel {
|
.edit-modal-btn-cancel {
|
||||||
background: var(--color-secondary);
|
background: #6c757d;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-cancel:hover:not(:disabled) {
|
.edit-modal-btn-cancel:hover:not(:disabled) {
|
||||||
background: var(--color-secondary-hover);
|
background: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-save {
|
.edit-modal-btn-save {
|
||||||
background: var(--color-primary);
|
background: #007bff;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-btn-save:hover:not(:disabled) {
|
.edit-modal-btn-save:hover:not(:disabled) {
|
||||||
background: var(--color-primary-hover);
|
background: #0056b3;
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-btn-image {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
border: var(--border-width-medium) solid var(--color-success);
|
|
||||||
border-radius: var(--button-border-radius);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: var(--button-font-weight);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-success);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-btn-image:hover:not(:disabled) {
|
|
||||||
background: var(--color-success);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-btn-image:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
/* Suggestion List Component */
|
|
||||||
.suggestion-list {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border: 2px solid var(--color-border-medium);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
list-style: none;
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
margin: 0;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
position: relative;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-item {
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-item:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-item:hover {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-item:active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/* Admin Panel - uses utility classes */
|
|
||||||
/* Responsive adjustments only */
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-panel-page {
|
|
||||||
padding: var(--spacing-md) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -29,64 +29,6 @@
|
|||||||
color: var(--color-gray-700);
|
color: var(--color-gray-700);
|
||||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||||
padding-top: var(--spacing-md);
|
padding-top: var(--spacing-md);
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-title.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
user-select: none;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-title.clickable:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-top-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-indicator {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-left: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: var(--spacing-xl);
|
|
||||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
|
||||||
padding-top: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-header .glist-section-title {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-collapse-btn {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
|
||||||
cursor: pointer;
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border-radius: var(--button-border-radius);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
font-weight: var(--button-font-weight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-collapse-btn:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Groups */
|
/* Classification Groups */
|
||||||
@ -103,34 +45,6 @@
|
|||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-classification-header.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-classification-header.clickable:hover {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-zone-count {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-normal);
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-zone-indicator {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-left: var(--spacing-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
@ -180,52 +94,49 @@
|
|||||||
|
|
||||||
/* Suggestion dropdown */
|
/* Suggestion dropdown */
|
||||||
.glist-suggest-box {
|
.glist-suggest-box {
|
||||||
background: var(--color-bg-surface);
|
background: #fff;
|
||||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
border: 1px solid #ccc;
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: var(--z-dropdown);
|
z-index: 999;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||||
padding: var(--spacing-md);
|
padding: 1em;
|
||||||
width: calc(100% - 8em);
|
width: calc(100% - 8em);
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-suggest-item {
|
.glist-suggest-item {
|
||||||
padding: var(--spacing-sm);
|
padding: 0.5em;
|
||||||
padding-inline: var(--spacing-xl);
|
padding-inline: 2em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-suggest-item:hover {
|
.glist-suggest-item:hover {
|
||||||
background: var(--color-bg-hover);
|
background: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grocery list items */
|
/* Grocery list items */
|
||||||
.glist-ul {
|
.glist-ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: var(--spacing-md);
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-li {
|
.glist-li {
|
||||||
background: var(--color-bg-surface);
|
background: #fff;
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: 0.8em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-li:hover {
|
.glist-li:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,21 +151,21 @@
|
|||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
min-width: 50px;
|
min-width: 50px;
|
||||||
background: var(--color-gray-100);
|
background: #f5f5f5;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #e0e0e0;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
color: var(--color-border-medium);
|
color: #ccc;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image.has-image {
|
.glist-item-image.has-image {
|
||||||
border-color: var(--color-primary);
|
border-color: #007bff;
|
||||||
background: var(--color-bg-surface);
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-image img {
|
.glist-item-image img {
|
||||||
@ -263,6 +174,11 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-item-image.has-image:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.glist-item-content {
|
.glist-item-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -281,69 +197,37 @@
|
|||||||
.glist-item-name {
|
.glist-item-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-quantity {
|
.glist-item-quantity {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--color-primary);
|
background: rgba(0, 123, 255, 0.9);
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.3em;
|
font-size: 0.3em;
|
||||||
padding: 0.2em 0.4em;
|
padding: 0.2em 0.4em;
|
||||||
border-radius: 0 var(--border-radius-md) 0 var(--border-radius-sm);
|
border-radius: 0 6px 0 4px;
|
||||||
min-width: 20%;
|
min-width: 20%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-users {
|
.glist-item-users {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: var(--color-text-secondary);
|
color: #888;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact View */
|
|
||||||
.glist-ul.compact .glist-li {
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-ul.compact .glist-item-layout {
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-ul.compact .glist-item-image {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-ul.compact .glist-item-quantity {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
padding: 1px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-ul.compact .glist-item-name {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-ul.compact .glist-item-users {
|
|
||||||
font-size: 0.65em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sorting dropdown */
|
/* Sorting dropdown */
|
||||||
.glist-sort {
|
.glist-sort {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: var(--spacing-xs) 0;
|
margin: 0.3em 0;
|
||||||
padding: var(--spacing-sm);
|
padding: 0.5em;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: 4px;
|
||||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image upload */
|
/* Image upload */
|
||||||
@ -353,19 +237,18 @@
|
|||||||
|
|
||||||
.glist-image-label {
|
.glist-image-label {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--spacing-sm);
|
padding: 0.6em;
|
||||||
background: var(--color-gray-100);
|
background: #f0f0f0;
|
||||||
border: var(--border-width-medium) dashed var(--color-border-medium);
|
border: 2px dashed #ccc;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-image-label:hover {
|
.glist-image-label:hover {
|
||||||
background: var(--color-bg-hover);
|
background: #e8e8e8;
|
||||||
border-color: var(--color-primary);
|
border-color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-image-preview {
|
.glist-image-preview {
|
||||||
@ -377,8 +260,8 @@
|
|||||||
.glist-image-preview img {
|
.glist-image-preview img {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
border: var(--border-width-medium) solid var(--color-border-light);
|
border: 2px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-remove-image {
|
.glist-remove-image {
|
||||||
@ -387,10 +270,10 @@
|
|||||||
right: -8px;
|
right: -8px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: 50%;
|
||||||
background: var(--color-danger);
|
background: #ff4444;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
border: var(--border-width-medium) solid var(--color-bg-surface);
|
border: 2px solid white;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -400,7 +283,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glist-remove-image:hover {
|
.glist-remove-image:hover {
|
||||||
background: var(--color-danger-hover);
|
background: #cc0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating Action Button (FAB) */
|
/* Floating Action Button (FAB) */
|
||||||
@ -408,10 +291,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: var(--color-success);
|
background: #28a745;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: 50%;
|
||||||
width: 62px;
|
width: 62px;
|
||||||
height: 62px;
|
height: 62px;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
@ -419,14 +302,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-fab:hover {
|
.glist-fab:hover {
|
||||||
background: var(--color-success-hover);
|
background: #218838;
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile tweaks */
|
/* Mobile tweaks */
|
||||||
|
|||||||
@ -1,4 +1,37 @@
|
|||||||
/* Login page - custom password toggle only */
|
.login-wrapper {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 1em;
|
||||||
|
background: #f8f9fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: white;
|
||||||
|
padding: 1.5em;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.6em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6em;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
font-size: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
.login-password-wrapper {
|
.login-password-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -7,7 +40,7 @@
|
|||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-password-wrapper .form-input {
|
.login-password-wrapper .login-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -34,3 +67,38 @@
|
|||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7em;
|
||||||
|
margin-top: 0.6em;
|
||||||
|
background: #007bff;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: #0068d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: red;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-register {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-register a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-register a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
/* Register page - container only */
|
|
||||||
|
|
||||||
.register-container {
|
.register-container {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--color-bg-primary);
|
background: #ffffff;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.register-form {
|
.register-form {
|
||||||
@ -15,3 +21,64 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.register-form input {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form input:focus {
|
||||||
|
border-color: #0077ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form button {
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
background: #0077ff;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form button:hover:not(:disabled) {
|
||||||
|
background: #005fcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form button:disabled {
|
||||||
|
background: #a8a8a8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
height: 15px;
|
||||||
|
color: red;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: green;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a {
|
||||||
|
color: #0077ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,213 +0,0 @@
|
|||||||
/* Settings Page - custom components only */
|
|
||||||
|
|
||||||
.settings-page {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.settings-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
border-bottom: 2px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab {
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab:hover {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-bottom-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.settings-content {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section {
|
|
||||||
animation: fadeIn 0.2s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-group {
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
padding-bottom: var(--spacing-xl);
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-group:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-label input[type="checkbox"] {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-description {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: var(--spacing-sm) 0 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Theme Buttons */
|
|
||||||
.settings-theme-options {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-theme-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border: 2px solid var(--color-border-light);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-theme-btn:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-theme-btn.active {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Range Slider */
|
|
||||||
.settings-range {
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--color-gray-300);
|
|
||||||
outline: none;
|
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-range::-webkit-slider-thumb {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-range::-webkit-slider-thumb:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-range::-moz-range-thumb {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-range::-moz-range-thumb:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.settings-page {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tabs {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab {
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-theme-options {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Account Management */
|
|
||||||
.account-form {
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-message {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-message.success {
|
|
||||||
background-color: var(--color-success-bg);
|
|
||||||
color: var(--color-success);
|
|
||||||
border: 1px solid var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-message.error {
|
|
||||||
background-color: var(--color-danger-bg);
|
|
||||||
color: var(--color-danger);
|
|
||||||
border: 1px solid var(--color-danger);
|
|
||||||
}
|
|
||||||
@ -189,94 +189,23 @@
|
|||||||
--modal-max-width: 500px;
|
--modal-max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DARK MODE
|
|
||||||
============================================ */
|
|
||||||
[data-theme="dark"] {
|
|
||||||
/* Primary Colors */
|
|
||||||
--color-primary: #4da3ff;
|
|
||||||
--color-primary-hover: #66b3ff;
|
|
||||||
--color-primary-light: #1a3a52;
|
|
||||||
--color-primary-dark: #3d8fdb;
|
|
||||||
|
|
||||||
/* Semantic Colors */
|
|
||||||
--color-success: #4ade80;
|
|
||||||
--color-success-hover: #5fe88d;
|
|
||||||
--color-success-light: #1a3a28;
|
|
||||||
|
|
||||||
--color-danger: #f87171;
|
|
||||||
--color-danger-hover: #fa8585;
|
|
||||||
--color-danger-light: #4a2020;
|
|
||||||
|
|
||||||
--color-warning: #fbbf24;
|
|
||||||
--color-warning-hover: #fcd34d;
|
|
||||||
--color-warning-light: #3a2f0f;
|
|
||||||
|
|
||||||
--color-info: #38bdf8;
|
|
||||||
--color-info-hover: #5dc9fc;
|
|
||||||
--color-info-light: #1a2f3a;
|
|
||||||
|
|
||||||
/* Text Colors */
|
|
||||||
--color-text-primary: #f1f5f9;
|
|
||||||
--color-text-secondary: #94a3b8;
|
|
||||||
--color-text-muted: #64748b;
|
|
||||||
--color-text-inverse: #1e293b;
|
|
||||||
--color-text-disabled: #475569;
|
|
||||||
|
|
||||||
/* Background Colors */
|
|
||||||
--color-bg-body: #0f172a;
|
|
||||||
--color-bg-surface: #1e293b;
|
|
||||||
--color-bg-hover: #334155;
|
|
||||||
--color-bg-disabled: #1e293b;
|
|
||||||
|
|
||||||
/* Border Colors */
|
|
||||||
--color-border-light: #334155;
|
|
||||||
--color-border-medium: #475569;
|
|
||||||
--color-border-dark: #64748b;
|
|
||||||
--color-border-disabled: #334155;
|
|
||||||
|
|
||||||
/* Neutral Colors - Dark adjusted */
|
|
||||||
--color-gray-50: #1e293b;
|
|
||||||
--color-gray-100: #1e293b;
|
|
||||||
--color-gray-200: #334155;
|
|
||||||
--color-gray-300: #475569;
|
|
||||||
--color-gray-400: #64748b;
|
|
||||||
--color-gray-500: #94a3b8;
|
|
||||||
--color-gray-600: #cbd5e1;
|
|
||||||
--color-gray-700: #e2e8f0;
|
|
||||||
--color-gray-800: #f1f5f9;
|
|
||||||
--color-gray-900: #f8fafc;
|
|
||||||
|
|
||||||
/* Shadows - Lighter for dark mode */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
|
||||||
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
/* Modals */
|
|
||||||
--modal-backdrop-bg: rgba(0, 0, 0, 0.8);
|
|
||||||
--modal-bg: var(--color-bg-surface);
|
|
||||||
|
|
||||||
/* Inputs */
|
|
||||||
--input-border-color: var(--color-border-medium);
|
|
||||||
--input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3);
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
--card-bg: var(--color-bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
DARK MODE SUPPORT (Future Implementation)
|
DARK MODE SUPPORT (Future Implementation)
|
||||||
============================================ */
|
============================================ */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
/* Auto mode will use data-theme attribute set by JS */
|
/* Uncomment to enable dark mode
|
||||||
|
:root {
|
||||||
|
--color-text-primary: #f8f9fa;
|
||||||
|
--color-text-secondary: #adb5bd;
|
||||||
|
--color-bg-body: #212529;
|
||||||
|
--color-bg-surface: #343a40;
|
||||||
|
--color-border-light: #495057;
|
||||||
|
--color-border-medium: #6c757d;
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Manual dark mode class override */
|
||||||
/* Manual dark mode class override (deprecated - use data-theme) */
|
|
||||||
.dark-mode {
|
.dark-mode {
|
||||||
--color-text-primary: #f8f9fa;
|
--color-text-primary: #f8f9fa;
|
||||||
--color-text-secondary: #adb5bd;
|
--color-text-secondary: #adb5bd;
|
||||||
|
|||||||
@ -1,570 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reusable Utility Classes
|
|
||||||
*
|
|
||||||
* Common patterns extracted from component styles.
|
|
||||||
* Import this file after theme.css in main.tsx
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LAYOUT UTILITIES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Containers */
|
|
||||||
.container {
|
|
||||||
max-width: var(--container-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--container-padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-full {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Centering */
|
|
||||||
.center-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-center {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-between {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-start {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
CARD COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border-radius: var(--card-border-radius);
|
|
||||||
padding: var(--card-padding);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-elevated {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border-radius: var(--card-border-radius);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
BUTTON COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--button-border-radius);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: var(--button-font-weight);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
text-align: center;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--color-secondary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: var(--color-secondary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-danger);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
background: var(--color-danger-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: var(--color-success);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
|
||||||
background: var(--color-success-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-primary);
|
|
||||||
border: var(--border-width-thin) solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border-color: var(--color-border-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-lg {
|
|
||||||
padding: var(--spacing-md) var(--spacing-xl);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-block {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
FORM COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--input-focus-border-color);
|
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--input-focus-border-color);
|
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MODAL COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--modal-backdrop-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--modal-bg);
|
|
||||||
border-radius: var(--modal-border-radius);
|
|
||||||
padding: var(--modal-padding);
|
|
||||||
max-width: var(--modal-max-width);
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
animation: modalSlideIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalSlideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
margin-top: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions .btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LIST COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.list-unstyled {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border-color: var(--color-border-medium);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
IMAGE COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.image-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border: var(--border-width-medium) dashed var(--color-border-medium);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-thumbnail {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
BADGE COMPONENTS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
border-radius: var(--border-radius-full);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-primary {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-success {
|
|
||||||
background: var(--color-success-light);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-danger {
|
|
||||||
background: var(--color-danger-light);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-warning {
|
|
||||||
background: var(--color-warning-light);
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-secondary {
|
|
||||||
background: var(--color-secondary-light);
|
|
||||||
color: var(--color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DIVIDER
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border: none;
|
|
||||||
border-top: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
margin: var(--spacing-lg) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider-thick {
|
|
||||||
border-top-width: var(--border-width-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SPACING HELPERS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.mt-0 { margin-top: 0 !important; }
|
|
||||||
.mt-1 { margin-top: var(--spacing-xs) !important; }
|
|
||||||
.mt-2 { margin-top: var(--spacing-sm) !important; }
|
|
||||||
.mt-3 { margin-top: var(--spacing-md) !important; }
|
|
||||||
.mt-4 { margin-top: var(--spacing-lg) !important; }
|
|
||||||
|
|
||||||
.mb-0 { margin-bottom: 0 !important; }
|
|
||||||
.mb-1 { margin-bottom: var(--spacing-xs) !important; }
|
|
||||||
.mb-2 { margin-bottom: var(--spacing-sm) !important; }
|
|
||||||
.mb-3 { margin-bottom: var(--spacing-md) !important; }
|
|
||||||
.mb-4 { margin-bottom: var(--spacing-lg) !important; }
|
|
||||||
|
|
||||||
.ml-auto { margin-left: auto !important; }
|
|
||||||
.mr-auto { margin-right: auto !important; }
|
|
||||||
|
|
||||||
.p-0 { padding: 0 !important; }
|
|
||||||
.p-1 { padding: var(--spacing-xs) !important; }
|
|
||||||
.p-2 { padding: var(--spacing-sm) !important; }
|
|
||||||
.p-3 { padding: var(--spacing-md) !important; }
|
|
||||||
.p-4 { padding: var(--spacing-lg) !important; }
|
|
||||||
|
|
||||||
.px-0 { padding-left: 0 !important; padding-right: 0 !important; }
|
|
||||||
.px-1 { padding-left: var(--spacing-xs) !important; padding-right: var(--spacing-xs) !important; }
|
|
||||||
.px-2 { padding-left: var(--spacing-sm) !important; padding-right: var(--spacing-sm) !important; }
|
|
||||||
.px-3 { padding-left: var(--spacing-md) !important; padding-right: var(--spacing-md) !important; }
|
|
||||||
.px-4 { padding-left: var(--spacing-lg) !important; padding-right: var(--spacing-lg) !important; }
|
|
||||||
|
|
||||||
.py-0 { padding-top: 0 !important; padding-bottom: 0 !important; }
|
|
||||||
.py-1 { padding-top: var(--spacing-xs) !important; padding-bottom: var(--spacing-xs) !important; }
|
|
||||||
.py-2 { padding-top: var(--spacing-sm) !important; padding-bottom: var(--spacing-sm) !important; }
|
|
||||||
.py-3 { padding-top: var(--spacing-md) !important; padding-bottom: var(--spacing-md) !important; }
|
|
||||||
.py-4 { padding-top: var(--spacing-lg) !important; padding-bottom: var(--spacing-lg) !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
TEXT UTILITIES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.text-xs { font-size: var(--font-size-xs) !important; }
|
|
||||||
.text-sm { font-size: var(--font-size-sm) !important; }
|
|
||||||
.text-base { font-size: var(--font-size-base) !important; }
|
|
||||||
.text-lg { font-size: var(--font-size-lg) !important; }
|
|
||||||
.text-xl { font-size: var(--font-size-xl) !important; }
|
|
||||||
.text-2xl { font-size: var(--font-size-2xl) !important; }
|
|
||||||
|
|
||||||
.text-center { text-align: center !important; }
|
|
||||||
.text-left { text-align: left !important; }
|
|
||||||
.text-right { text-align: right !important; }
|
|
||||||
|
|
||||||
.text-primary { color: var(--color-primary) !important; }
|
|
||||||
.text-secondary { color: var(--color-text-secondary) !important; }
|
|
||||||
.text-muted { color: var(--color-text-muted) !important; }
|
|
||||||
.text-danger { color: var(--color-danger) !important; }
|
|
||||||
.text-success { color: var(--color-success) !important; }
|
|
||||||
.text-warning { color: var(--color-warning) !important; }
|
|
||||||
|
|
||||||
.font-normal { font-weight: var(--font-weight-normal) !important; }
|
|
||||||
.font-medium { font-weight: var(--font-weight-medium) !important; }
|
|
||||||
.font-semibold { font-weight: var(--font-weight-semibold) !important; }
|
|
||||||
.font-bold { font-weight: var(--font-weight-bold) !important; }
|
|
||||||
|
|
||||||
.text-uppercase { text-transform: uppercase !important; }
|
|
||||||
.text-lowercase { text-transform: lowercase !important; }
|
|
||||||
.text-capitalize { text-transform: capitalize !important; }
|
|
||||||
|
|
||||||
.text-truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DISPLAY & VISIBILITY
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.d-none { display: none !important; }
|
|
||||||
.d-block { display: block !important; }
|
|
||||||
.d-inline { display: inline !important; }
|
|
||||||
.d-inline-block { display: inline-block !important; }
|
|
||||||
.d-flex { display: flex !important; }
|
|
||||||
.d-grid { display: grid !important; }
|
|
||||||
|
|
||||||
.hidden { visibility: hidden !important; }
|
|
||||||
.visible { visibility: visible !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
BORDER UTILITIES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.border { border: var(--border-width-thin) solid var(--color-border-light) !important; }
|
|
||||||
.border-0 { border: none !important; }
|
|
||||||
.border-top { border-top: var(--border-width-thin) solid var(--color-border-light) !important; }
|
|
||||||
.border-bottom { border-bottom: var(--border-width-thin) solid var(--color-border-light) !important; }
|
|
||||||
|
|
||||||
.rounded { border-radius: var(--border-radius-md) !important; }
|
|
||||||
.rounded-sm { border-radius: var(--border-radius-sm) !important; }
|
|
||||||
.rounded-lg { border-radius: var(--border-radius-lg) !important; }
|
|
||||||
.rounded-full { border-radius: var(--border-radius-full) !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SHADOW UTILITIES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.shadow-none { box-shadow: none !important; }
|
|
||||||
.shadow-sm { box-shadow: var(--shadow-sm) !important; }
|
|
||||||
.shadow { box-shadow: var(--shadow-md) !important; }
|
|
||||||
.shadow-lg { box-shadow: var(--shadow-lg) !important; }
|
|
||||||
.shadow-xl { box-shadow: var(--shadow-xl) !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
INTERACTION
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.cursor-pointer { cursor: pointer !important; }
|
|
||||||
.cursor-not-allowed { cursor: not-allowed !important; }
|
|
||||||
.cursor-default { cursor: default !important; }
|
|
||||||
|
|
||||||
.pointer-events-none { pointer-events: none !important; }
|
|
||||||
.user-select-none { user-select: none !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
POSITION
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.position-relative { position: relative !important; }
|
|
||||||
.position-absolute { position: absolute !important; }
|
|
||||||
.position-fixed { position: fixed !important; }
|
|
||||||
.position-sticky { position: sticky !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
OVERFLOW
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.overflow-hidden { overflow: hidden !important; }
|
|
||||||
.overflow-auto { overflow: auto !important; }
|
|
||||||
.overflow-scroll { overflow: scroll !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WIDTH & HEIGHT
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.w-100 { width: 100% !important; }
|
|
||||||
.w-auto { width: auto !important; }
|
|
||||||
.h-100 { height: 100% !important; }
|
|
||||||
.h-auto { height: auto !important; }
|
|
||||||
|
|
||||||
.min-h-screen { min-height: 100vh !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RESPONSIVE UTILITIES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.mobile-hidden { display: none !important; }
|
|
||||||
.mobile-block { display: block !important; }
|
|
||||||
.mobile-text-center { text-align: center !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 481px) {
|
|
||||||
.desktop-hidden { display: none !important; }
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user