major refactor and restructure
This commit is contained in:
parent
aee9cd3244
commit
aa9e71194c
238
frontend/COMPONENT_STRUCTURE.md
Normal file
238
frontend/COMPONENT_STRUCTURE.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Frontend Component Organization
|
||||||
|
|
||||||
|
This document describes the organized structure of the frontend codebase, implemented to improve maintainability as the application grows.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/ # API client functions
|
||||||
|
│ ├── auth.js # Authentication endpoints
|
||||||
|
│ ├── axios.js # Axios instance with interceptors
|
||||||
|
│ ├── list.js # Grocery list endpoints
|
||||||
|
│ └── users.js # User management endpoints
|
||||||
|
│
|
||||||
|
├── components/ # React components (organized by function)
|
||||||
|
│ ├── common/ # Reusable UI components
|
||||||
|
│ │ ├── ErrorMessage.jsx
|
||||||
|
│ │ ├── FloatingActionButton.jsx
|
||||||
|
│ │ ├── FormInput.jsx
|
||||||
|
│ │ ├── SortDropdown.jsx
|
||||||
|
│ │ ├── UserRoleCard.jsx
|
||||||
|
│ │ └── index.js # Barrel exports
|
||||||
|
│ │
|
||||||
|
│ ├── modals/ # All modal/dialog components
|
||||||
|
│ │ ├── AddImageModal.jsx
|
||||||
|
│ │ ├── AddItemWithDetailsModal.jsx
|
||||||
|
│ │ ├── ConfirmBuyModal.jsx
|
||||||
|
│ │ ├── EditItemModal.jsx
|
||||||
|
│ │ ├── ImageModal.jsx
|
||||||
|
│ │ ├── ImageUploadModal.jsx
|
||||||
|
│ │ ├── ItemClassificationModal.jsx
|
||||||
|
│ │ ├── SimilarItemModal.jsx
|
||||||
|
│ │ └── index.js # Barrel exports
|
||||||
|
│ │
|
||||||
|
│ ├── forms/ # Form components and input sections
|
||||||
|
│ │ ├── AddItemForm.jsx
|
||||||
|
│ │ ├── ClassificationSection.jsx
|
||||||
|
│ │ ├── ImageUploadSection.jsx
|
||||||
|
│ │ └── index.js # Barrel exports
|
||||||
|
│ │
|
||||||
|
│ ├── items/ # Item display and list components
|
||||||
|
│ │ ├── GroceryItem.tsx
|
||||||
|
│ │ ├── GroceryListItem.jsx
|
||||||
|
│ │ ├── SuggestionList.tsx
|
||||||
|
│ │ └── index.js # Barrel exports
|
||||||
|
│ │
|
||||||
|
│ └── layout/ # Layout and navigation components
|
||||||
|
│ ├── AppLayout.jsx
|
||||||
|
│ ├── Navbar.jsx
|
||||||
|
│ └── index.js # Barrel exports
|
||||||
|
│
|
||||||
|
├── constants/ # Application constants
|
||||||
|
│ ├── classifications.js # Item types, groups, zones
|
||||||
|
│ └── roles.js # User roles (viewer, editor, admin)
|
||||||
|
│
|
||||||
|
├── context/ # React context providers
|
||||||
|
│ └── AuthContext.jsx # Authentication context
|
||||||
|
│
|
||||||
|
├── pages/ # Top-level page components
|
||||||
|
│ ├── AdminPanel.jsx # User management dashboard
|
||||||
|
│ ├── GroceryList.jsx # Main grocery list page
|
||||||
|
│ ├── Login.jsx # Login page
|
||||||
|
│ └── Register.jsx # Registration page
|
||||||
|
│
|
||||||
|
├── styles/ # CSS files (organized by type)
|
||||||
|
│ ├── pages/ # Page-specific styles
|
||||||
|
│ │ ├── GroceryList.css
|
||||||
|
│ │ ├── Login.css
|
||||||
|
│ │ └── Register.css
|
||||||
|
│ │
|
||||||
|
│ ├── components/ # Component-specific styles
|
||||||
|
│ │ ├── AddItemWithDetailsModal.css
|
||||||
|
│ │ ├── ClassificationSection.css
|
||||||
|
│ │ ├── EditItemModal.css
|
||||||
|
│ │ ├── ImageUploadSection.css
|
||||||
|
│ │ └── Navbar.css
|
||||||
|
│ │
|
||||||
|
│ ├── theme.css # **GLOBAL THEME VARIABLES** (colors, spacing, typography)
|
||||||
|
│ ├── THEME_USAGE_EXAMPLES.css # Examples of using theme variables
|
||||||
|
│ ├── App.css # Global app styles
|
||||||
|
│ └── index.css # Root styles (uses theme variables)
|
||||||
|
│
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
│ ├── PrivateRoute.jsx # Authentication guard
|
||||||
|
│ ├── RoleGuard.jsx # Role-based access guard
|
||||||
|
│ └── stringSimilarity.js # String matching utilities
|
||||||
|
│
|
||||||
|
├── App.jsx # Root app component
|
||||||
|
├── main.tsx # Application entry point
|
||||||
|
├── config.ts # Configuration (API URL)
|
||||||
|
└── types.ts # TypeScript type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Patterns
|
||||||
|
|
||||||
|
### Using Barrel Exports (Recommended)
|
||||||
|
|
||||||
|
Barrel exports (`index.js` files) allow cleaner imports from component groups:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Clean barrel import
|
||||||
|
import { FloatingActionButton, SortDropdown } from '../components/common';
|
||||||
|
import { EditItemModal, SimilarItemModal } from '../components/modals';
|
||||||
|
import { AddItemForm, ClassificationSection } from '../components/forms';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Imports (Alternative)
|
||||||
|
|
||||||
|
You can also import components directly when needed:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Also valid
|
||||||
|
import FloatingActionButton from '../components/common/FloatingActionButton';
|
||||||
|
import EditItemModal from '../components/modals/EditItemModal';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Categories
|
||||||
|
|
||||||
|
### `common/` - Reusable UI Components
|
||||||
|
- **Purpose**: Generic, reusable components used across multiple pages
|
||||||
|
- **Examples**: Buttons, dropdowns, form inputs, error messages
|
||||||
|
- **Characteristics**: Highly reusable, minimal business logic
|
||||||
|
|
||||||
|
### `modals/` - Dialog Components
|
||||||
|
- **Purpose**: All modal/dialog/popup components
|
||||||
|
- **Examples**: Confirmation dialogs, edit forms, image viewers
|
||||||
|
- **Characteristics**: Overlay UI, typically used for focused interactions
|
||||||
|
|
||||||
|
### `forms/` - Form Sections
|
||||||
|
- **Purpose**: Form-related components and input sections
|
||||||
|
- **Examples**: Multi-step forms, reusable form sections
|
||||||
|
- **Characteristics**: Handle user input, validation, form state
|
||||||
|
|
||||||
|
### `items/` - Item Display Components
|
||||||
|
- **Purpose**: Components specific to displaying grocery items
|
||||||
|
- **Examples**: Item cards, item lists, suggestion lists
|
||||||
|
- **Characteristics**: Domain-specific (grocery items)
|
||||||
|
|
||||||
|
### `layout/` - Layout Components
|
||||||
|
- **Purpose**: Application structure and navigation
|
||||||
|
- **Examples**: Navigation bars, page layouts, wrappers
|
||||||
|
- **Characteristics**: Define page structure, persistent UI elements
|
||||||
|
|
||||||
|
## Style Organization
|
||||||
|
|
||||||
|
### `styles/pages/`
|
||||||
|
Page-specific styles that apply to entire page components.
|
||||||
|
|
||||||
|
### `styles/components/`
|
||||||
|
Component-specific styles for individual reusable components.
|
||||||
|
|
||||||
|
## Benefits of This Structure
|
||||||
|
|
||||||
|
1. **Scalability**: Easy to add new components without cluttering directories
|
||||||
|
2. **Discoverability**: Intuitive naming makes components easy to find
|
||||||
|
3. **Maintainability**: Related code is grouped together
|
||||||
|
4. **Separation of Concerns**: Clear boundaries between different types of components
|
||||||
|
5. **Import Clarity**: Barrel exports reduce import statement complexity
|
||||||
|
6. **Team Collaboration**: Clear conventions for where new code should go
|
||||||
|
|
||||||
|
## Adding New Components
|
||||||
|
|
||||||
|
When adding a new component, ask:
|
||||||
|
|
||||||
|
1. **Is it reusable across pages?** → `common/`
|
||||||
|
2. **Is it a modal/dialog?** → `modals/`
|
||||||
|
3. **Is it form-related?** → `forms/`
|
||||||
|
4. **Is it specific to grocery items?** → `items/`
|
||||||
|
5. **Does it define page structure?** → `layout/`
|
||||||
|
6. **Is it a full page?** → `pages/`
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Create the component in the appropriate subdirectory
|
||||||
|
2. Add the component to the subdirectory's `index.js` barrel export
|
||||||
|
3. Create corresponding CSS file in `styles/pages/` or `styles/components/`
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
This structure was implemented on January 2, 2026 to organize 20+ components and 10+ CSS files that were previously in flat directories. All import paths have been updated to reflect the new structure.
|
||||||
|
|
||||||
|
## Theming System
|
||||||
|
|
||||||
|
The application uses a centralized theming system via CSS custom properties (variables) defined in `styles/theme.css`.
|
||||||
|
|
||||||
|
### Theme Variables
|
||||||
|
|
||||||
|
All design tokens are defined in `theme.css` including:
|
||||||
|
|
||||||
|
- **Colors**: Primary, secondary, semantic (success, danger, warning), neutrals
|
||||||
|
- **Spacing**: Consistent spacing scale (xs, sm, md, lg, xl, 2xl, 3xl)
|
||||||
|
- **Typography**: Font families, sizes, weights, line heights
|
||||||
|
- **Borders**: Widths and radius values
|
||||||
|
- **Shadows**: Box shadow presets
|
||||||
|
- **Transitions**: Timing functions
|
||||||
|
- **Z-index**: Layering system for modals, dropdowns, etc.
|
||||||
|
|
||||||
|
### Using Theme Variables
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Instead of hardcoded values */
|
||||||
|
.button-old {
|
||||||
|
background: #007bff;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use theme variables */
|
||||||
|
.button-new {
|
||||||
|
background: var(--color-primary);
|
||||||
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
|
border-radius: var(--button-border-radius);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **Consistency**: All components use the same design tokens
|
||||||
|
2. **Maintainability**: Change once in `theme.css`, updates everywhere
|
||||||
|
3. **Theme Switching**: Easy to implement dark mode (already scaffolded)
|
||||||
|
4. **Scalability**: Add new tokens without touching component styles
|
||||||
|
5. **Documentation**: Variable names are self-documenting
|
||||||
|
|
||||||
|
### Utility Classes
|
||||||
|
|
||||||
|
The theme file includes utility classes for common patterns:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Spacing -->
|
||||||
|
<div class="mt-3 mb-4 p-2">Content</div>
|
||||||
|
|
||||||
|
<!-- Text styling -->
|
||||||
|
<span class="text-primary font-weight-bold">Important</span>
|
||||||
|
|
||||||
|
<!-- Flexbox -->
|
||||||
|
<div class="d-flex justify-between align-center gap-2">Items</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
See `styles/THEME_USAGE_EXAMPLES.css` for complete examples of refactoring existing CSS to use theme variables.
|
||||||
@ -7,7 +7,7 @@ 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 AppLayout from "./components/AppLayout.jsx";
|
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||||
|
|
||||||
import RoleGuard from "./utils/RoleGuard.jsx";
|
import RoleGuard from "./utils/RoleGuard.jsx";
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
import { useRef, useState } from "react";
|
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications";
|
|
||||||
import "../styles/AddItemWithDetailsModal.css";
|
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
|
||||||
const [itemType, setItemType] = useState("");
|
|
||||||
const [itemGroup, setItemGroup] = useState("");
|
|
||||||
const [zone, setZone] = useState("");
|
|
||||||
|
|
||||||
const cameraInputRef = useRef(null);
|
|
||||||
const galleryInputRef = useRef(null);
|
|
||||||
|
|
||||||
const handleImageChange = (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
setSelectedImage(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
setImagePreview(reader.result);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeImage = () => {
|
|
||||||
setSelectedImage(null);
|
|
||||||
setImagePreview(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleItemTypeChange = (e) => {
|
|
||||||
const newType = e.target.value;
|
|
||||||
setItemType(newType);
|
|
||||||
// Reset item group when type changes
|
|
||||||
setItemGroup("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
// Validate classification if provided
|
|
||||||
if (itemType && !itemGroup) {
|
|
||||||
alert("Please select an item group");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const classification = itemType ? {
|
|
||||||
item_type: itemType,
|
|
||||||
item_group: itemGroup,
|
|
||||||
zone: zone || null
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
onConfirm(selectedImage, classification);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
onSkip();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCameraClick = () => {
|
|
||||||
cameraInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGalleryClick = () => {
|
|
||||||
galleryInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="add-item-details-overlay" onClick={onCancel}>
|
|
||||||
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
|
||||||
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
|
||||||
|
|
||||||
{/* Image Section */}
|
|
||||||
<div className="add-item-details-section">
|
|
||||||
<h3 className="add-item-details-section-title">Item Image (Optional)</h3>
|
|
||||||
<div className="add-item-details-image-content">
|
|
||||||
{!imagePreview ? (
|
|
||||||
<div className="add-item-details-image-options">
|
|
||||||
<button onClick={handleCameraClick} className="add-item-details-image-btn camera">
|
|
||||||
📷 Use Camera
|
|
||||||
</button>
|
|
||||||
<button onClick={handleGalleryClick} className="add-item-details-image-btn gallery">
|
|
||||||
🖼️ Choose from Gallery
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="add-item-details-image-preview">
|
|
||||||
<img src={imagePreview} alt="Preview" />
|
|
||||||
<button type="button" onClick={removeImage} className="add-item-details-remove-image">
|
|
||||||
× Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={cameraInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
onChange={handleImageChange}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={galleryInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageChange}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Classification Section */}
|
|
||||||
<div className="add-item-details-section">
|
|
||||||
<h3 className="add-item-details-section-title">Item Classification (Optional)</h3>
|
|
||||||
|
|
||||||
<div className="add-item-details-field">
|
|
||||||
<label>Item Type</label>
|
|
||||||
<select
|
|
||||||
value={itemType}
|
|
||||||
onChange={handleItemTypeChange}
|
|
||||||
className="add-item-details-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Type --</option>
|
|
||||||
{Object.values(ITEM_TYPES).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{getItemTypeLabel(type)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemType && (
|
|
||||||
<div className="add-item-details-field">
|
|
||||||
<label>Item Group</label>
|
|
||||||
<select
|
|
||||||
value={itemGroup}
|
|
||||||
onChange={(e) => setItemGroup(e.target.value)}
|
|
||||||
className="add-item-details-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Group --</option>
|
|
||||||
{availableGroups.map((group) => (
|
|
||||||
<option key={group} value={group}>
|
|
||||||
{group}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="add-item-details-field">
|
|
||||||
<label>Store Zone</label>
|
|
||||||
<select
|
|
||||||
value={zone}
|
|
||||||
onChange={(e) => setZone(e.target.value)}
|
|
||||||
className="add-item-details-select"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Zone --</option>
|
|
||||||
{getZoneValues().map((z) => (
|
|
||||||
<option key={z} value={z}>
|
|
||||||
{z}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="add-item-details-actions">
|
|
||||||
<button onClick={onCancel} className="add-item-details-btn cancel">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSkip} className="add-item-details-btn skip">
|
|
||||||
Skip All
|
|
||||||
</button>
|
|
||||||
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../../constants/roles";
|
||||||
|
|
||||||
export default function UserRoleCard({ user, onRoleChange }) {
|
export default function UserRoleCard({ user, onRoleChange }) {
|
||||||
return (
|
return (
|
||||||
7
frontend/src/components/common/index.js
Normal file
7
frontend/src/components/common/index.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Barrel export for common components
|
||||||
|
export { default as ErrorMessage } from './ErrorMessage.jsx';
|
||||||
|
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
|
||||||
|
export { default as FormInput } from './FormInput.jsx';
|
||||||
|
export { default as SortDropdown } from './SortDropdown.jsx';
|
||||||
|
export { default as UserRoleCard } from './UserRoleCard.jsx';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SuggestionList from "./SuggestionList";
|
import SuggestionList from "../items/SuggestionList";
|
||||||
|
|
||||||
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) {
|
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) {
|
||||||
const [itemName, setItemName] = useState("");
|
const [itemName, setItemName] = useState("");
|
||||||
91
frontend/src/components/forms/ClassificationSection.jsx
Normal file
91
frontend/src/components/forms/ClassificationSection.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||||
|
import "../../styles/components/ClassificationSection.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable classification component with cascading type/group/zone selects
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.itemType - Selected item type
|
||||||
|
* @param {string} props.itemGroup - Selected item group
|
||||||
|
* @param {string} props.zone - Selected zone
|
||||||
|
* @param {Function} props.onItemTypeChange - Callback for type change (newType)
|
||||||
|
* @param {Function} props.onItemGroupChange - Callback for group change (newGroup)
|
||||||
|
* @param {Function} props.onZoneChange - Callback for zone change (newZone)
|
||||||
|
* @param {string} props.title - Section title (optional)
|
||||||
|
* @param {string} props.fieldClass - CSS class for field containers (optional)
|
||||||
|
* @param {string} props.selectClass - CSS class for select elements (optional)
|
||||||
|
*/
|
||||||
|
export default function ClassificationSection({
|
||||||
|
itemType,
|
||||||
|
itemGroup,
|
||||||
|
zone,
|
||||||
|
onItemTypeChange,
|
||||||
|
onItemGroupChange,
|
||||||
|
onZoneChange,
|
||||||
|
title = "Item Classification (Optional)",
|
||||||
|
fieldClass = "classification-field",
|
||||||
|
selectClass = "classification-select"
|
||||||
|
}) {
|
||||||
|
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||||
|
|
||||||
|
const handleTypeChange = (e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
onItemTypeChange(newType);
|
||||||
|
// Parent should reset group when type changes
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="classification-section">
|
||||||
|
<h3 className="classification-title">{title}</h3>
|
||||||
|
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<label>Item Type</label>
|
||||||
|
<select
|
||||||
|
value={itemType}
|
||||||
|
onChange={handleTypeChange}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Type --</option>
|
||||||
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<label>Item Group</label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => onItemGroupChange(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<label>Store Zone</label>
|
||||||
|
<select
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => onZoneChange(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Zone --</option>
|
||||||
|
{getZoneValues().map((z) => (
|
||||||
|
<option key={z} value={z}>
|
||||||
|
{z}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/forms/ImageUploadSection.jsx
Normal file
77
frontend/src/components/forms/ImageUploadSection.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import "../../styles/components/ImageUploadSection.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable image upload component with camera and gallery options
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.imagePreview - Base64 preview URL or null
|
||||||
|
* @param {Function} props.onImageChange - Callback when image is selected (file)
|
||||||
|
* @param {Function} props.onImageRemove - Callback to remove image
|
||||||
|
* @param {string} props.title - Section title (optional)
|
||||||
|
*/
|
||||||
|
export default function ImageUploadSection({
|
||||||
|
imagePreview,
|
||||||
|
onImageChange,
|
||||||
|
onImageRemove,
|
||||||
|
title = "Item Image (Optional)"
|
||||||
|
}) {
|
||||||
|
const cameraInputRef = useRef(null);
|
||||||
|
const galleryInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
onImageChange(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCameraClick = () => {
|
||||||
|
cameraInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGalleryClick = () => {
|
||||||
|
galleryInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-upload-section">
|
||||||
|
<h3 className="image-upload-title">{title}</h3>
|
||||||
|
<div className="image-upload-content">
|
||||||
|
{!imagePreview ? (
|
||||||
|
<div className="image-upload-options">
|
||||||
|
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button">
|
||||||
|
📷 Use Camera
|
||||||
|
</button>
|
||||||
|
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button">
|
||||||
|
🖼️ Choose from Gallery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="image-upload-preview">
|
||||||
|
<img src={imagePreview} alt="Preview" />
|
||||||
|
<button type="button" onClick={onImageRemove} className="image-upload-remove">
|
||||||
|
× Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/forms/index.js
Normal file
5
frontend/src/components/forms/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Barrel export for form components
|
||||||
|
export { default as AddItemForm } from './AddItemForm.jsx';
|
||||||
|
export { default as ClassificationSection } from './ClassificationSection.jsx';
|
||||||
|
export { default as ImageUploadSection } from './ImageUploadSection.jsx';
|
||||||
|
|
||||||
@ -1,23 +1,23 @@
|
|||||||
import type { GroceryItemType } from "../types";
|
import type { GroceryItemType } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: GroceryItemType;
|
item: GroceryItemType;
|
||||||
onClick: (id: number) => void;
|
onClick: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GroceryItem({ item, onClick }: Props) {
|
export default function GroceryItem({ item, onClick }: Props) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
onClick={() => onClick(item.id)}
|
onClick={() => onClick(item.id)}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.5em",
|
padding: "0.5em",
|
||||||
background: "#e9ecef",
|
background: "#e9ecef",
|
||||||
marginBottom: "0.5em",
|
marginBottom: "0.5em",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.item_name} ({item.quantity})
|
{item.item_name} ({item.quantity})
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "../modals/AddImageModal";
|
||||||
import ConfirmBuyModal from "./ConfirmBuyModal";
|
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||||
import ImageModal from "./ImageModal";
|
import ImageModal from "../modals/ImageModal";
|
||||||
|
|
||||||
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@ -1,40 +1,40 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SuggestionList({ suggestions, onSelect }: Props) {
|
export default function SuggestionList({ suggestions, onSelect }: Props) {
|
||||||
if (!suggestions.length) return null;
|
if (!suggestions.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
border: "1px solid #ccc",
|
border: "1px solid #ccc",
|
||||||
maxHeight: "150px",
|
maxHeight: "150px",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
left: "1em",
|
left: "1em",
|
||||||
right: "1em",
|
right: "1em",
|
||||||
listStyle: "none",
|
listStyle: "none",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{suggestions.map((s) => (
|
{suggestions.map((s) => (
|
||||||
<li
|
<li
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => onSelect(s)}
|
onClick={() => onSelect(s)}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.5em",
|
padding: "0.5em",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
borderBottom: "1px solid #eee",
|
borderBottom: "1px solid #eee",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
5
frontend/src/components/items/index.js
Normal file
5
frontend/src/components/items/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Barrel export for item-related components
|
||||||
|
export { default as GroceryItem } from './GroceryItem.tsx';
|
||||||
|
export { default as GroceryListItem } from './GroceryListItem.jsx';
|
||||||
|
export { default as SuggestionList } from './SuggestionList.tsx';
|
||||||
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import Navbar from "./Navbar";
|
import Navbar from "./Navbar";
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,30 +1,30 @@
|
|||||||
import "../styles/Navbar.css";
|
import "../../styles/components/Navbar.css";
|
||||||
|
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { role, logout, username } = useContext(AuthContext);
|
const { role, logout, username } = useContext(AuthContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div className="navbar-links">
|
<div className="navbar-links">
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
|
|
||||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="navbar-idcard">
|
<div className="navbar-idcard">
|
||||||
<div className="navbar-idinfo">
|
<div className="navbar-idinfo">
|
||||||
<span className="navbar-username">{username}</span>
|
<span className="navbar-username">{username}</span>
|
||||||
<span className="navbar-role">{role}</span>
|
<span className="navbar-role">{role}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="navbar-logout" onClick={logout}>
|
<button className="navbar-logout" onClick={logout}>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
4
frontend/src/components/layout/index.js
Normal file
4
frontend/src/components/layout/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Barrel export for layout components
|
||||||
|
export { default as AppLayout } from './AppLayout.jsx';
|
||||||
|
export { default as Navbar } from './Navbar.jsx';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import "../styles/AddImageModal.css";
|
import "../../styles/AddImageModal.css";
|
||||||
|
|
||||||
export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
96
frontend/src/components/modals/AddItemWithDetailsModal.jsx
Normal file
96
frontend/src/components/modals/AddItemWithDetailsModal.jsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import "../../styles/components/AddItemWithDetailsModal.css";
|
||||||
|
import ClassificationSection from "../forms/ClassificationSection";
|
||||||
|
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||||
|
|
||||||
|
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||||
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
|
const [itemType, setItemType] = useState("");
|
||||||
|
const [itemGroup, setItemGroup] = useState("");
|
||||||
|
const [zone, setZone] = useState("");
|
||||||
|
|
||||||
|
const handleImageChange = (file) => {
|
||||||
|
setSelectedImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageRemove = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemTypeChange = (newType) => {
|
||||||
|
setItemType(newType);
|
||||||
|
setItemGroup(""); // Reset group when type changes
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// Validate classification if provided
|
||||||
|
if (itemType && !itemGroup) {
|
||||||
|
alert("Please select an item group");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification = itemType ? {
|
||||||
|
item_type: itemType,
|
||||||
|
item_group: itemGroup,
|
||||||
|
zone: zone || null
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
onConfirm(selectedImage, classification);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
onSkip();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="add-item-details-overlay" onClick={onCancel}>
|
||||||
|
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
||||||
|
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
||||||
|
|
||||||
|
{/* Image Section */}
|
||||||
|
<div className="add-item-details-section">
|
||||||
|
<ImageUploadSection
|
||||||
|
imagePreview={imagePreview}
|
||||||
|
onImageChange={handleImageChange}
|
||||||
|
onImageRemove={handleImageRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Classification Section */}
|
||||||
|
<div className="add-item-details-section">
|
||||||
|
<ClassificationSection
|
||||||
|
itemType={itemType}
|
||||||
|
itemGroup={itemGroup}
|
||||||
|
zone={zone}
|
||||||
|
onItemTypeChange={handleItemTypeChange}
|
||||||
|
onItemGroupChange={setItemGroup}
|
||||||
|
onZoneChange={setZone}
|
||||||
|
fieldClass="add-item-details-field"
|
||||||
|
selectClass="add-item-details-select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="add-item-details-actions">
|
||||||
|
<button onClick={onCancel} className="add-item-details-btn cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSkip} className="add-item-details-btn skip">
|
||||||
|
Skip All
|
||||||
|
</button>
|
||||||
|
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../styles/ConfirmBuyModal.css";
|
import "../../styles/ConfirmBuyModal.css";
|
||||||
|
|
||||||
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
@ -1,6 +1,6 @@
|
|||||||
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/EditItemModal.css";
|
import ClassificationSection from "../forms/ClassificationSection";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||||
const [itemName, setItemName] = useState(item.item_name || "");
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
@ -19,11 +19,9 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
}
|
}
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const handleItemTypeChange = (e) => {
|
const handleItemTypeChange = (newType) => {
|
||||||
const newType = e.target.value;
|
|
||||||
setItemType(newType);
|
setItemType(newType);
|
||||||
// Reset item group when type changes
|
setItemGroup(""); // Reset group when type changes
|
||||||
setItemGroup("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@ -60,8 +58,6 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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()}>
|
||||||
@ -90,57 +86,16 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
<h3 className="edit-modal-subtitle">Item Classification</h3>
|
<ClassificationSection
|
||||||
|
itemType={itemType}
|
||||||
<div className="edit-modal-field">
|
itemGroup={itemGroup}
|
||||||
<label>Item Type</label>
|
zone={zone}
|
||||||
<select
|
onItemTypeChange={handleItemTypeChange}
|
||||||
value={itemType}
|
onItemGroupChange={setItemGroup}
|
||||||
onChange={handleItemTypeChange}
|
onZoneChange={setZone}
|
||||||
className="edit-modal-select"
|
fieldClass="edit-modal-field"
|
||||||
>
|
selectClass="edit-modal-select"
|
||||||
<option value="">-- Select Type --</option>
|
/>
|
||||||
{Object.values(ITEM_TYPES).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{getItemTypeLabel(type)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemType && (
|
|
||||||
<div className="edit-modal-field">
|
|
||||||
<label>Item 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-field">
|
|
||||||
<label>Zone (Optional)</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-actions">
|
<div className="edit-modal-actions">
|
||||||
<button
|
<button
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import "../styles/ImageModal.css";
|
import "../../styles/ImageModal.css";
|
||||||
|
|
||||||
export default function ImageModal({ imageUrl, itemName, onClose }) {
|
export default function ImageModal({ imageUrl, itemName, onClose }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import "../styles/ImageUploadModal.css";
|
import "../../styles/ImageUploadModal.css";
|
||||||
|
|
||||||
export default function ImageUploadModal({ itemName, onConfirm, onSkip, onCancel }) {
|
export default function ImageUploadModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import "../../styles/ItemClassificationModal.css";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
||||||
import "../styles/ItemClassificationModal.css";
|
|
||||||
|
|
||||||
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
|
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import "../styles/SimilarItemModal.css";
|
import "../../styles/SimilarItemModal.css";
|
||||||
|
|
||||||
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
|
||||||
return (
|
return (
|
||||||
10
frontend/src/components/modals/index.js
Normal file
10
frontend/src/components/modals/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Barrel export for modal components
|
||||||
|
export { default as AddImageModal } from './AddImageModal.jsx';
|
||||||
|
export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx';
|
||||||
|
export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx';
|
||||||
|
export { default as EditItemModal } from './EditItemModal.jsx';
|
||||||
|
export { default as ImageModal } from './ImageModal.jsx';
|
||||||
|
export { default as ImageUploadModal } from './ImageUploadModal.jsx';
|
||||||
|
export { default as ItemClassificationModal } from './ItemClassificationModal.jsx';
|
||||||
|
export { default as SimilarItemModal } from './SimilarItemModal.jsx';
|
||||||
|
|
||||||
@ -68,16 +68,32 @@ button:focus-visible {
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Base Styles
|
||||||
|
* Uses theme variables defined in theme.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: var(--font-family-base);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-body);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
background: #f8f9fa;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 480px;
|
max-width: var(--container-max-width);
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
padding: var(--container-padding);
|
||||||
|
}
|
||||||
background: white;
|
background: white;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './styles/theme.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
34
frontend/src/move-components.sh
Normal file
34
frontend/src/move-components.sh
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Move to common/
|
||||||
|
mv components/FloatingActionButton.jsx components/common/
|
||||||
|
mv components/SortDropdown.jsx components/common/
|
||||||
|
mv components/ErrorMessage.jsx components/common/
|
||||||
|
mv components/FormInput.jsx components/common/
|
||||||
|
mv components/UserRoleCard.jsx components/common/
|
||||||
|
|
||||||
|
# Move to modals/
|
||||||
|
mv components/AddItemWithDetailsModal.jsx components/modals/
|
||||||
|
mv components/EditItemModal.jsx components/modals/
|
||||||
|
mv components/SimilarItemModal.jsx components/modals/
|
||||||
|
mv components/ConfirmBuyModal.jsx components/modals/
|
||||||
|
mv components/ImageModal.jsx components/modals/
|
||||||
|
mv components/AddImageModal.jsx components/modals/
|
||||||
|
mv components/ImageUploadModal.jsx components/modals/
|
||||||
|
mv components/ItemClassificationModal.jsx components/modals/
|
||||||
|
|
||||||
|
# Move to forms/
|
||||||
|
mv components/AddItemForm.jsx components/forms/
|
||||||
|
mv components/ImageUploadSection.jsx components/forms/
|
||||||
|
mv components/ClassificationSection.jsx components/forms/
|
||||||
|
|
||||||
|
# Move to items/
|
||||||
|
mv components/GroceryListItem.jsx components/items/
|
||||||
|
mv components/GroceryItem.tsx components/items/
|
||||||
|
mv components/SuggestionList.tsx components/items/
|
||||||
|
|
||||||
|
# Move to layout/
|
||||||
|
mv components/AppLayout.jsx components/layout/
|
||||||
|
mv components/Navbar.jsx components/layout/
|
||||||
|
|
||||||
|
echo "Components moved successfully!"
|
||||||
15
frontend/src/move-styles.sh
Normal file
15
frontend/src/move-styles.sh
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Move page styles
|
||||||
|
mv styles/GroceryList.css styles/pages/
|
||||||
|
mv styles/Login.css styles/pages/
|
||||||
|
mv styles/Register.css styles/pages/
|
||||||
|
|
||||||
|
# Move component styles
|
||||||
|
mv styles/Navbar.css styles/components/
|
||||||
|
mv styles/AddItemWithDetailsModal.css styles/components/
|
||||||
|
mv styles/EditItemModal.css styles/components/
|
||||||
|
mv styles/ImageUploadSection.css styles/components/
|
||||||
|
mv styles/ClassificationSection.css styles/components/
|
||||||
|
|
||||||
|
echo "Styles moved successfully!"
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getAllUsers, updateRole } from "../api/users";
|
import { getAllUsers, updateRole } from "../api/users";
|
||||||
import UserRoleCard from "../components/UserRoleCard";
|
import UserRoleCard from "../components/common/UserRoleCard";
|
||||||
import "../styles/UserRoleCard.css";
|
import "../styles/UserRoleCard.css";
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||||
import AddItemForm from "../components/AddItemForm";
|
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||||
import AddItemWithDetailsModal from "../components/AddItemWithDetailsModal";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import EditItemModal from "../components/EditItemModal";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import FloatingActionButton from "../components/FloatingActionButton";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import GroceryListItem from "../components/GroceryListItem";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
import SimilarItemModal from "../components/SimilarItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SortDropdown from "../components/SortDropdown";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import "../styles/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() {
|
||||||
@ -17,6 +17,7 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
|
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
||||||
const [sortedItems, setSortedItems] = useState([]);
|
const [sortedItems, setSortedItems] = useState([]);
|
||||||
const [sortMode, setSortMode] = useState("zone");
|
const [sortMode, setSortMode] = useState("zone");
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
@ -94,15 +95,18 @@ 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)
|
// Check if exact match exists (case-insensitive)
|
||||||
const lowerText = text.toLowerCase().trim();
|
const lowerText = text.toLowerCase().trim();
|
||||||
const exactMatch = items.find(item => item.item_name.toLowerCase() === lowerText);
|
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
||||||
|
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
} else {
|
} else {
|
||||||
// Check for similar items (80% match)
|
// Check for similar items (80% match)
|
||||||
const similar = findSimilarItems(text, items, 80);
|
const similar = findSimilarItems(text, allItems, 80);
|
||||||
if (similar.length > 0) {
|
if (similar.length > 0) {
|
||||||
// Show suggestion in button but allow creation
|
// Show suggestion in button but allow creation
|
||||||
setButtonText("Create and Add Item");
|
setButtonText("Create and Add Item");
|
||||||
@ -125,8 +129,11 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
const lowerItemName = itemName.toLowerCase().trim();
|
const lowerItemName = itemName.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Combine both unbought and recently bought items for similarity checking
|
||||||
|
const allItems = [...items, ...recentlyBoughtItems];
|
||||||
|
|
||||||
// Check for 80% similar items
|
// Check for 80% similar items
|
||||||
const similar = findSimilarItems(itemName, items, 80);
|
const similar = findSimilarItems(itemName, allItems, 80);
|
||||||
if (similar.length > 0) {
|
if (similar.length > 0) {
|
||||||
// Show modal and wait for user decision
|
// Show modal and wait for user decision
|
||||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||||
@ -385,7 +392,7 @@ export default function GroceryList() {
|
|||||||
<>
|
<>
|
||||||
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||||
<ul className="glist-ul">
|
<ul className="glist-ul">
|
||||||
{recentlyBoughtItems.map((item) => (
|
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@ -399,6 +406,16 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
className="glist-show-more-btn"
|
||||||
|
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
|
||||||
|
>
|
||||||
|
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { loginRequest } from "../api/auth";
|
import { loginRequest } from "../api/auth";
|
||||||
import ErrorMessage from "../components/ErrorMessage";
|
import ErrorMessage from "../components/common/ErrorMessage";
|
||||||
import FormInput from "../components/FormInput";
|
import FormInput from "../components/common/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import "../styles/Login.css";
|
import "../styles/pages/Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useContext(AuthContext);
|
const { login } = useContext(AuthContext);
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import { useContext, useEffect, useState } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { loginRequest, registerRequest } from "../api/auth";
|
import { loginRequest, registerRequest } from "../api/auth";
|
||||||
import { checkIfUserExists } from "../api/users";
|
import { checkIfUserExists } from "../api/users";
|
||||||
import ErrorMessage from "../components/ErrorMessage";
|
import ErrorMessage from "../components/common/ErrorMessage";
|
||||||
import FormInput from "../components/FormInput";
|
import FormInput from "../components/common/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import "../styles/Register.css";
|
import "../styles/pages/Register.css";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
130
frontend/src/styles/THEME_USAGE_EXAMPLES.css
Normal file
130
frontend/src/styles/THEME_USAGE_EXAMPLES.css
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Theme Variable Usage Examples
|
||||||
|
*
|
||||||
|
* This file demonstrates how to refactor existing CSS to use theme variables.
|
||||||
|
* Copy these patterns when updating component styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BEFORE: Hardcoded values
|
||||||
|
============================================ */
|
||||||
|
.button-old {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-old:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
AFTER: Using theme variables
|
||||||
|
============================================ */
|
||||||
|
.button-new {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
|
border-radius: var(--button-border-radius);
|
||||||
|
border: none;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
transition: var(--transition-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-new:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MORE EXAMPLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Input Field */
|
||||||
|
.input-field {
|
||||||
|
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);
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border-color);
|
||||||
|
box-shadow: var(--input-focus-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Component */
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: var(--card-padding);
|
||||||
|
border-radius: var(--card-border-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
background: var(--modal-backdrop-bg);
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--modal-bg);
|
||||||
|
padding: var(--modal-padding);
|
||||||
|
border-radius: var(--modal-border-radius);
|
||||||
|
max-width: var(--modal-max-width);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Styles */
|
||||||
|
.heading-primary {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing Examples */
|
||||||
|
.section {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border Examples */
|
||||||
|
.divider {
|
||||||
|
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||||
|
margin: var(--spacing-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BENEFITS OF USING THEME VARIABLES
|
||||||
|
============================================
|
||||||
|
|
||||||
|
1. Consistency: All components use the same colors/spacing
|
||||||
|
2. Maintainability: Change once, update everywhere
|
||||||
|
3. Theme switching: Easy to implement dark mode
|
||||||
|
4. Scalability: Add new colors/sizes without touching components
|
||||||
|
5. Documentation: Variable names are self-documenting
|
||||||
|
|
||||||
|
*/
|
||||||
44
frontend/src/styles/components/ClassificationSection.css
Normal file
44
frontend/src/styles/components/ClassificationSection.css
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/* Classification Section */
|
||||||
|
.classification-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-title {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
font-size: 1em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-select:hover {
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
86
frontend/src/styles/components/ImageUploadSection.css
Normal file
86
frontend/src/styles/components/ImageUploadSection.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/* Image Upload Section */
|
||||||
|
.image-upload-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-title {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-content {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-btn {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
font-size: 1em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-btn.camera {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-btn.camera:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-btn.gallery {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-btn.gallery:hover {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-preview {
|
||||||
|
position: relative;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-preview img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-remove:hover {
|
||||||
|
background: rgba(255, 0, 0, 1);
|
||||||
|
}
|
||||||
@ -1,58 +1,58 @@
|
|||||||
.navbar {
|
.navbar {
|
||||||
background: #343a40;
|
background: #343a40;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.6em 1em;
|
padding: 0.6em 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-links a {
|
.navbar-links a {
|
||||||
color: white;
|
color: white;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-links a:hover {
|
.navbar-links a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-logout {
|
.navbar-logout {
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.4em 0.8em;
|
padding: 0.4em 0.8em;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-idcard {
|
.navbar-idcard {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
padding: 0.3em 0.6em;
|
padding: 0.3em 0.6em;
|
||||||
background: #495057;
|
background: #495057;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-idinfo {
|
.navbar-idinfo {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-username {
|
.navbar-username {
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-role {
|
.navbar-role {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@ -1,304 +1,323 @@
|
|||||||
/* Container */
|
/* Container */
|
||||||
.glist-body {
|
.glist-body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: var(--font-family-base);
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
background: #f8f9fa;
|
background: var(--color-bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-container {
|
.glist-container {
|
||||||
max-width: 480px;
|
max-width: var(--container-max-width);
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
padding: 1em;
|
padding: var(--spacing-md);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title */
|
/* Title */
|
||||||
.glist-title {
|
.glist-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5em;
|
font-size: var(--font-size-2xl);
|
||||||
margin-bottom: 0.4em;
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-section-title {
|
.glist-section-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.2em;
|
font-size: var(--font-size-xl);
|
||||||
margin-top: 2em;
|
margin-top: var(--spacing-xl);
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: var(--spacing-sm);
|
||||||
color: #495057;
|
color: var(--color-gray-700);
|
||||||
border-top: 2px solid #e0e0e0;
|
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||||
padding-top: 1em;
|
padding-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Groups */
|
/* Classification Groups */
|
||||||
.glist-classification-group {
|
.glist-classification-group {
|
||||||
margin-bottom: 2em;
|
margin-bottom: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-classification-header {
|
.glist-classification-header {
|
||||||
font-size: 1.1em;
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-semibold);
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
margin: 1em 0 0.5em 0;
|
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||||
padding: 0.5em 0.8em;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background: #e7f3ff;
|
background: var(--color-primary-light);
|
||||||
border-left: 4px solid #007bff;
|
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
.glist-input {
|
.glist-input {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.glist-btn {
|
.glist-btn {
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
padding: 0.55em;
|
padding: var(--button-padding-y);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.4em;
|
margin-top: var(--spacing-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
background: #007bff;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
border-radius: 4px;
|
border-radius: var(--button-border-radius);
|
||||||
}
|
font-weight: var(--button-font-weight);
|
||||||
|
transition: var(--transition-base);
|
||||||
.glist-btn:hover {
|
}
|
||||||
background: #0067d8;
|
|
||||||
}
|
.glist-btn:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
/* Suggestion dropdown */
|
}
|
||||||
.glist-suggest-box {
|
|
||||||
background: #fff;
|
.glist-show-more-btn {
|
||||||
border: 1px solid #ccc;
|
font-size: var(--font-size-sm);
|
||||||
max-height: 150px;
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
overflow-y: auto;
|
cursor: pointer;
|
||||||
position: absolute;
|
border: var(--border-width-thin) solid var(--color-primary);
|
||||||
z-index: 999;
|
background: var(--color-bg-surface);
|
||||||
border-radius: 8px;
|
color: var(--color-primary);
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
border-radius: var(--button-border-radius);
|
||||||
padding: 1em;
|
transition: var(--transition-base);
|
||||||
width: calc(100% - 8em);
|
font-weight: var(--button-font-weight);
|
||||||
max-width: 440px;
|
}
|
||||||
margin: 0 auto;
|
|
||||||
}
|
.glist-show-more-btn:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
.glist-suggest-item {
|
color: var(--color-text-inverse);
|
||||||
padding: 0.5em;
|
}
|
||||||
padding-inline: 2em;
|
|
||||||
cursor: pointer;
|
/* Suggestion dropdown */
|
||||||
}
|
.glist-suggest-box {
|
||||||
|
background: #fff;
|
||||||
.glist-suggest-item:hover {
|
border: 1px solid #ccc;
|
||||||
background: #eee;
|
max-height: 150px;
|
||||||
}
|
overflow-y: auto;
|
||||||
|
position: absolute;
|
||||||
/* Grocery list items */
|
z-index: 999;
|
||||||
.glist-ul {
|
border-radius: 8px;
|
||||||
list-style: none;
|
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||||
padding: 0;
|
padding: 1em;
|
||||||
margin-top: 1em;
|
width: calc(100% - 8em);
|
||||||
}
|
max-width: 440px;
|
||||||
|
margin: 0 auto;
|
||||||
.glist-li {
|
}
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e0e0e0;
|
.glist-suggest-item {
|
||||||
border-radius: 8px;
|
padding: 0.5em;
|
||||||
margin-bottom: 0.8em;
|
padding-inline: 2em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, transform 0.2s;
|
}
|
||||||
overflow: hidden;
|
|
||||||
}
|
.glist-suggest-item:hover {
|
||||||
|
background: #eee;
|
||||||
.glist-li:hover {
|
}
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
transform: translateY(-2px);
|
/* Grocery list items */
|
||||||
}
|
.glist-ul {
|
||||||
|
list-style: none;
|
||||||
.glist-item-layout {
|
padding: 0;
|
||||||
display: flex;
|
margin-top: 1em;
|
||||||
gap: 1em;
|
}
|
||||||
padding: 0em;
|
|
||||||
align-items: center;
|
.glist-li {
|
||||||
}
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
.glist-item-image {
|
border-radius: 8px;
|
||||||
width: 50px;
|
margin-bottom: 0.8em;
|
||||||
height: 50px;
|
cursor: pointer;
|
||||||
min-width: 50px;
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
background: #f5f5f5;
|
overflow: hidden;
|
||||||
border: 2px solid #e0e0e0;
|
}
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
.glist-li:hover {
|
||||||
align-items: center;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
justify-content: center;
|
transform: translateY(-2px);
|
||||||
font-size: 2em;
|
}
|
||||||
color: #ccc;
|
|
||||||
overflow: hidden;
|
.glist-item-layout {
|
||||||
position: relative;
|
display: flex;
|
||||||
}
|
gap: 1em;
|
||||||
|
padding: 0em;
|
||||||
.glist-item-image.has-image {
|
align-items: center;
|
||||||
border-color: #007bff;
|
}
|
||||||
background: #fff;
|
|
||||||
}
|
.glist-item-image {
|
||||||
|
width: 50px;
|
||||||
.glist-item-image img {
|
height: 50px;
|
||||||
width: 100%;
|
min-width: 50px;
|
||||||
height: 100%;
|
background: #f5f5f5;
|
||||||
object-fit: cover;
|
border: 2px solid #e0e0e0;
|
||||||
}
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
.glist-item-image.has-image:hover {
|
align-items: center;
|
||||||
opacity: 0.8;
|
justify-content: center;
|
||||||
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
font-size: 2em;
|
||||||
}
|
color: #ccc;
|
||||||
|
overflow: hidden;
|
||||||
.glist-item-content {
|
position: relative;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4em;
|
.glist-item-image.has-image {
|
||||||
flex: 1;
|
border-color: #007bff;
|
||||||
min-width: 0;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-item-header {
|
.glist-item-image img {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: baseline;
|
height: 100%;
|
||||||
gap: 0.5em;
|
object-fit: cover;
|
||||||
flex-wrap: wrap;
|
}
|
||||||
}
|
|
||||||
|
.glist-item-image.has-image:hover {
|
||||||
.glist-item-name {
|
opacity: 0.8;
|
||||||
font-weight: 800;
|
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
|
||||||
font-size: 0.8em;
|
}
|
||||||
color: #333;
|
|
||||||
}
|
.glist-item-content {
|
||||||
|
display: flex;
|
||||||
.glist-item-quantity {
|
flex-direction: column;
|
||||||
position: absolute;
|
gap: 0.4em;
|
||||||
top: 0;
|
flex: 1;
|
||||||
right: 0;
|
min-width: 0;
|
||||||
background: rgba(0, 123, 255, 0.9);
|
}
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
.glist-item-header {
|
||||||
font-size: 0.3em;
|
display: flex;
|
||||||
padding: 0.2em 0.4em;
|
align-items: baseline;
|
||||||
border-radius: 0 6px 0 4px;
|
gap: 0.5em;
|
||||||
min-width: 20%;
|
flex-wrap: wrap;
|
||||||
text-align: center;
|
}
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
.glist-item-name {
|
||||||
|
font-weight: 800;
|
||||||
.glist-item-users {
|
font-size: 0.8em;
|
||||||
font-size: 0.7em;
|
color: #333;
|
||||||
color: #888;
|
}
|
||||||
font-style: italic;
|
|
||||||
}
|
.glist-item-quantity {
|
||||||
|
position: absolute;
|
||||||
/* Sorting dropdown */
|
top: 0;
|
||||||
.glist-sort {
|
right: 0;
|
||||||
width: 100%;
|
background: rgba(0, 123, 255, 0.9);
|
||||||
margin: 0.3em 0;
|
color: white;
|
||||||
padding: 0.5em;
|
font-weight: 700;
|
||||||
font-size: 1em;
|
font-size: 0.3em;
|
||||||
border-radius: 4px;
|
padding: 0.2em 0.4em;
|
||||||
}
|
border-radius: 0 6px 0 4px;
|
||||||
|
min-width: 20%;
|
||||||
/* Image upload */
|
text-align: center;
|
||||||
.glist-image-upload {
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
margin: 0.5em 0;
|
}
|
||||||
}
|
|
||||||
|
.glist-item-users {
|
||||||
.glist-image-label {
|
font-size: 0.7em;
|
||||||
display: block;
|
color: #888;
|
||||||
padding: 0.6em;
|
font-style: italic;
|
||||||
background: #f0f0f0;
|
}
|
||||||
border: 2px dashed #ccc;
|
|
||||||
border-radius: 4px;
|
/* Sorting dropdown */
|
||||||
text-align: center;
|
.glist-sort {
|
||||||
cursor: pointer;
|
width: 100%;
|
||||||
transition: all 0.2s;
|
margin: 0.3em 0;
|
||||||
}
|
padding: 0.5em;
|
||||||
|
font-size: 1em;
|
||||||
.glist-image-label:hover {
|
border-radius: 4px;
|
||||||
background: #e8e8e8;
|
}
|
||||||
border-color: #007bff;
|
|
||||||
}
|
/* Image upload */
|
||||||
|
.glist-image-upload {
|
||||||
.glist-image-preview {
|
margin: 0.5em 0;
|
||||||
position: relative;
|
}
|
||||||
margin-top: 0.5em;
|
|
||||||
display: inline-block;
|
.glist-image-label {
|
||||||
}
|
display: block;
|
||||||
|
padding: 0.6em;
|
||||||
.glist-image-preview img {
|
background: #f0f0f0;
|
||||||
max-width: 150px;
|
border: 2px dashed #ccc;
|
||||||
max-height: 150px;
|
border-radius: 4px;
|
||||||
border-radius: 8px;
|
text-align: center;
|
||||||
border: 2px solid #ddd;
|
cursor: pointer;
|
||||||
}
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
.glist-remove-image {
|
|
||||||
position: absolute;
|
.glist-image-label:hover {
|
||||||
top: -8px;
|
background: #e8e8e8;
|
||||||
right: -8px;
|
border-color: #007bff;
|
||||||
width: 28px;
|
}
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
.glist-image-preview {
|
||||||
background: #ff4444;
|
position: relative;
|
||||||
color: white;
|
margin-top: 0.5em;
|
||||||
border: 2px solid white;
|
display: inline-block;
|
||||||
font-size: 1.2rem;
|
}
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
.glist-image-preview img {
|
||||||
display: flex;
|
max-width: 150px;
|
||||||
align-items: center;
|
max-height: 150px;
|
||||||
justify-content: center;
|
border-radius: 8px;
|
||||||
}
|
border: 2px solid #ddd;
|
||||||
|
}
|
||||||
.glist-remove-image:hover {
|
|
||||||
background: #cc0000;
|
.glist-remove-image {
|
||||||
}
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
/* Floating Action Button (FAB) */
|
right: -8px;
|
||||||
.glist-fab {
|
width: 28px;
|
||||||
position: fixed;
|
height: 28px;
|
||||||
bottom: 20px;
|
border-radius: 50%;
|
||||||
right: 20px;
|
background: #ff4444;
|
||||||
background: #28a745;
|
color: white;
|
||||||
color: white;
|
border: 2px solid white;
|
||||||
border: none;
|
font-size: 1.2rem;
|
||||||
border-radius: 50%;
|
line-height: 1;
|
||||||
width: 62px;
|
cursor: pointer;
|
||||||
height: 62px;
|
display: flex;
|
||||||
font-size: 2em;
|
align-items: center;
|
||||||
line-height: 0;
|
justify-content: center;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
.glist-remove-image:hover {
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
background: #cc0000;
|
||||||
cursor: pointer;
|
}
|
||||||
}
|
|
||||||
|
/* Floating Action Button (FAB) */
|
||||||
.glist-fab:hover {
|
.glist-fab {
|
||||||
background: #218838;
|
position: fixed;
|
||||||
}
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
/* Mobile tweaks */
|
background: #28a745;
|
||||||
@media (max-width: 480px) {
|
color: white;
|
||||||
.glist-container {
|
border: none;
|
||||||
padding: 1em 0.8em;
|
border-radius: 50%;
|
||||||
}
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
.glist-fab {
|
font-size: 2em;
|
||||||
bottom: 16px;
|
line-height: 0;
|
||||||
right: 16px;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-fab:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile tweaks */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.glist-container {
|
||||||
|
padding: 1em 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-fab {
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
268
frontend/src/styles/theme.css
Normal file
268
frontend/src/styles/theme.css
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Global Theme Variables
|
||||||
|
*
|
||||||
|
* This file defines the design system for the entire application.
|
||||||
|
* All colors, spacing, typography, and other design tokens are centralized here.
|
||||||
|
*
|
||||||
|
* Usage: var(--variable-name)
|
||||||
|
* Example: color: var(--color-primary);
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ============================================
|
||||||
|
COLOR PALETTE
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Primary Colors */
|
||||||
|
--color-primary: #007bff;
|
||||||
|
--color-primary-hover: #0056b3;
|
||||||
|
--color-primary-light: #e7f3ff;
|
||||||
|
--color-primary-dark: #0067d8;
|
||||||
|
|
||||||
|
/* Secondary Colors */
|
||||||
|
--color-secondary: #6c757d;
|
||||||
|
--color-secondary-hover: #545b62;
|
||||||
|
--color-secondary-light: #f8f9fa;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-success: #28a745;
|
||||||
|
--color-success-hover: #218838;
|
||||||
|
--color-success-light: #d4edda;
|
||||||
|
|
||||||
|
--color-danger: #dc3545;
|
||||||
|
--color-danger-hover: #c82333;
|
||||||
|
--color-danger-light: #f8d7da;
|
||||||
|
|
||||||
|
--color-warning: #ffc107;
|
||||||
|
--color-warning-hover: #e0a800;
|
||||||
|
--color-warning-light: #fff3cd;
|
||||||
|
|
||||||
|
--color-info: #17a2b8;
|
||||||
|
--color-info-hover: #138496;
|
||||||
|
--color-info-light: #d1ecf1;
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #000000;
|
||||||
|
--color-gray-50: #f9f9f9;
|
||||||
|
--color-gray-100: #f8f9fa;
|
||||||
|
--color-gray-200: #e9ecef;
|
||||||
|
--color-gray-300: #dee2e6;
|
||||||
|
--color-gray-400: #ced4da;
|
||||||
|
--color-gray-500: #adb5bd;
|
||||||
|
--color-gray-600: #6c757d;
|
||||||
|
--color-gray-700: #495057;
|
||||||
|
--color-gray-800: #343a40;
|
||||||
|
--color-gray-900: #212529;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text-primary: #212529;
|
||||||
|
--color-text-secondary: #6c757d;
|
||||||
|
--color-text-muted: #adb5bd;
|
||||||
|
--color-text-inverse: #ffffff;
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
--color-bg-body: #f8f9fa;
|
||||||
|
--color-bg-surface: #ffffff;
|
||||||
|
--color-bg-hover: #f5f5f5;
|
||||||
|
--color-bg-disabled: #e9ecef;
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--color-border-light: #e0e0e0;
|
||||||
|
--color-border-medium: #ccc;
|
||||||
|
--color-border-dark: #999;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SPACING
|
||||||
|
============================================ */
|
||||||
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
|
--spacing-md: 1rem; /* 16px */
|
||||||
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
|
--spacing-xl: 2rem; /* 32px */
|
||||||
|
--spacing-2xl: 3rem; /* 48px */
|
||||||
|
--spacing-3xl: 4rem; /* 64px */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
============================================ */
|
||||||
|
--font-family-base: Arial, sans-serif;
|
||||||
|
--font-family-heading: Arial, sans-serif;
|
||||||
|
--font-family-mono: 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--font-size-xs: 0.75rem; /* 12px */
|
||||||
|
--font-size-sm: 0.875rem; /* 14px */
|
||||||
|
--font-size-base: 1rem; /* 16px */
|
||||||
|
--font-size-lg: 1.125rem; /* 18px */
|
||||||
|
--font-size-xl: 1.25rem; /* 20px */
|
||||||
|
--font-size-2xl: 1.5rem; /* 24px */
|
||||||
|
--font-size-3xl: 2rem; /* 32px */
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
/* Line Heights */
|
||||||
|
--line-height-tight: 1.2;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BORDERS & RADIUS
|
||||||
|
============================================ */
|
||||||
|
--border-width-thin: 1px;
|
||||||
|
--border-width-medium: 2px;
|
||||||
|
--border-width-thick: 4px;
|
||||||
|
|
||||||
|
--border-radius-sm: 4px;
|
||||||
|
--border-radius-md: 6px;
|
||||||
|
--border-radius-lg: 8px;
|
||||||
|
--border-radius-xl: 12px;
|
||||||
|
--border-radius-full: 50%;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SHADOWS
|
||||||
|
============================================ */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TRANSITIONS
|
||||||
|
============================================ */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-base: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Z-INDEX LAYERS
|
||||||
|
============================================ */
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-fixed: 300;
|
||||||
|
--z-modal-backdrop: 900;
|
||||||
|
--z-modal: 1000;
|
||||||
|
--z-tooltip: 1100;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LAYOUT
|
||||||
|
============================================ */
|
||||||
|
--container-max-width: 480px;
|
||||||
|
--container-padding: var(--spacing-md);
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
COMPONENT-SPECIFIC
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
--button-padding-y: 0.6rem;
|
||||||
|
--button-padding-x: 1.5rem;
|
||||||
|
--button-border-radius: var(--border-radius-sm);
|
||||||
|
--button-font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-padding-y: 0.6rem;
|
||||||
|
--input-padding-x: 0.75rem;
|
||||||
|
--input-border-color: var(--color-border-medium);
|
||||||
|
--input-border-radius: var(--border-radius-sm);
|
||||||
|
--input-focus-border-color: var(--color-primary);
|
||||||
|
--input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
--card-bg: var(--color-bg-surface);
|
||||||
|
--card-padding: var(--spacing-md);
|
||||||
|
--card-border-radius: var(--border-radius-lg);
|
||||||
|
--card-shadow: var(--shadow-card);
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||||
|
--modal-bg: var(--color-white);
|
||||||
|
--modal-border-radius: var(--border-radius-lg);
|
||||||
|
--modal-padding: var(--spacing-lg);
|
||||||
|
--modal-max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK MODE SUPPORT (Future Implementation)
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* Uncomment to enable dark mode
|
||||||
|
:root {
|
||||||
|
--color-text-primary: #f8f9fa;
|
||||||
|
--color-text-secondary: #adb5bd;
|
||||||
|
--color-bg-body: #212529;
|
||||||
|
--color-bg-surface: #343a40;
|
||||||
|
--color-border-light: #495057;
|
||||||
|
--color-border-medium: #6c757d;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual dark mode class override */
|
||||||
|
.dark-mode {
|
||||||
|
--color-text-primary: #f8f9fa;
|
||||||
|
--color-text-secondary: #adb5bd;
|
||||||
|
--color-bg-body: #212529;
|
||||||
|
--color-bg-surface: #343a40;
|
||||||
|
--color-border-light: #495057;
|
||||||
|
--color-border-medium: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Spacing Utilities */
|
||||||
|
.m-0 { margin: 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-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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Text Utilities */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.font-weight-normal { font-weight: var(--font-weight-normal) !important; }
|
||||||
|
.font-weight-medium { font-weight: var(--font-weight-medium) !important; }
|
||||||
|
.font-weight-semibold { font-weight: var(--font-weight-semibold) !important; }
|
||||||
|
.font-weight-bold { font-weight: var(--font-weight-bold) !important; }
|
||||||
|
|
||||||
|
/* Display Utilities */
|
||||||
|
.d-none { display: none !important; }
|
||||||
|
.d-block { display: block !important; }
|
||||||
|
.d-flex { display: flex !important; }
|
||||||
|
.d-inline-block { display: inline-block !important; }
|
||||||
|
|
||||||
|
/* Flex Utilities */
|
||||||
|
.flex-column { flex-direction: column !important; }
|
||||||
|
.flex-row { flex-direction: row !important; }
|
||||||
|
.justify-center { justify-content: center !important; }
|
||||||
|
.justify-between { justify-content: space-between !important; }
|
||||||
|
.align-center { align-items: center !important; }
|
||||||
|
.gap-1 { gap: var(--spacing-xs) !important; }
|
||||||
|
.gap-2 { gap: var(--spacing-sm) !important; }
|
||||||
|
.gap-3 { gap: var(--spacing-md) !important; }
|
||||||
Loading…
Reference in New Issue
Block a user