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 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 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 }) {
|
||||
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 SuggestionList from "./SuggestionList";
|
||||
import SuggestionList from "../items/SuggestionList";
|
||||
|
||||
export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add Item" }) {
|
||||
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,7 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import AddImageModal from "./AddImageModal";
|
||||
import ConfirmBuyModal from "./ConfirmBuyModal";
|
||||
import ImageModal from "./ImageModal";
|
||||
import AddImageModal from "../modals/AddImageModal";
|
||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||
import ImageModal from "../modals/ImageModal";
|
||||
|
||||
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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,8 +1,8 @@
|
||||
import "../styles/Navbar.css";
|
||||
import "../../styles/components/Navbar.css";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
|
||||
export default function Navbar() {
|
||||
const { role, logout, username } = useContext(AuthContext);
|
||||
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 "../styles/AddImageModal.css";
|
||||
import "../../styles/AddImageModal.css";
|
||||
|
||||
export default function AddImageModal({ itemName, onClose, onAddImage }) {
|
||||
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 "../styles/ConfirmBuyModal.css";
|
||||
import "../../styles/ConfirmBuyModal.css";
|
||||
|
||||
export default function ConfirmBuyModal({ item, onConfirm, onCancel }) {
|
||||
const [quantity, setQuantity] = useState(item.quantity);
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications";
|
||||
import "../styles/EditItemModal.css";
|
||||
import "../../styles/components/EditItemModal.css";
|
||||
import ClassificationSection from "../forms/ClassificationSection";
|
||||
|
||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
const [itemName, setItemName] = useState(item.item_name || "");
|
||||
@ -19,11 +19,9 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const handleItemTypeChange = (e) => {
|
||||
const newType = e.target.value;
|
||||
const handleItemTypeChange = (newType) => {
|
||||
setItemType(newType);
|
||||
// Reset item group when type changes
|
||||
setItemGroup("");
|
||||
setItemGroup(""); // Reset group when type changes
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@ -60,8 +58,6 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
}
|
||||
};
|
||||
|
||||
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||
|
||||
return (
|
||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||
<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" />
|
||||
|
||||
<h3 className="edit-modal-subtitle">Item Classification</h3>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label>Item Type</label>
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={handleItemTypeChange}
|
||||
className="edit-modal-select"
|
||||
>
|
||||
<option value="">-- Select Type --</option>
|
||||
{Object.values(ITEM_TYPES).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getItemTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{itemType && (
|
||||
<div className="edit-modal-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>
|
||||
<ClassificationSection
|
||||
itemType={itemType}
|
||||
itemGroup={itemGroup}
|
||||
zone={zone}
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
fieldClass="edit-modal-field"
|
||||
selectClass="edit-modal-select"
|
||||
/>
|
||||
|
||||
<div className="edit-modal-actions">
|
||||
<button
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import "../styles/ImageModal.css";
|
||||
import "../../styles/ImageModal.css";
|
||||
|
||||
export default function ImageModal({ imageUrl, itemName, onClose }) {
|
||||
useEffect(() => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import "../styles/ImageUploadModal.css";
|
||||
import "../../styles/ImageUploadModal.css";
|
||||
|
||||
export default function ImageUploadModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import "../../styles/ItemClassificationModal.css";
|
||||
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
||||
import "../styles/ItemClassificationModal.css";
|
||||
|
||||
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
|
||||
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 }) {
|
||||
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 {
|
||||
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;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
padding: var(--spacing-md);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
max-width: var(--container-max-width);
|
||||
margin: auto;
|
||||
padding: var(--container-padding);
|
||||
}
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
|
||||
@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import './styles/theme.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<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 { getAllUsers, updateRole } from "../api/users";
|
||||
import UserRoleCard from "../components/UserRoleCard";
|
||||
import UserRoleCard from "../components/common/UserRoleCard";
|
||||
import "../styles/UserRoleCard.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||
import AddItemForm from "../components/AddItemForm";
|
||||
import AddItemWithDetailsModal from "../components/AddItemWithDetailsModal";
|
||||
import EditItemModal from "../components/EditItemModal";
|
||||
import FloatingActionButton from "../components/FloatingActionButton";
|
||||
import GroceryListItem from "../components/GroceryListItem";
|
||||
import SimilarItemModal from "../components/SimilarItemModal";
|
||||
import SortDropdown from "../components/SortDropdown";
|
||||
import FloatingActionButton from "../components/common/FloatingActionButton";
|
||||
import SortDropdown from "../components/common/SortDropdown";
|
||||
import AddItemForm from "../components/forms/AddItemForm";
|
||||
import GroceryListItem from "../components/items/GroceryListItem";
|
||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||
import EditItemModal from "../components/modals/EditItemModal";
|
||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/GroceryList.css";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
export default function GroceryList() {
|
||||
@ -17,6 +17,7 @@ export default function GroceryList() {
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
||||
const [sortedItems, setSortedItems] = useState([]);
|
||||
const [sortMode, setSortMode] = useState("zone");
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
@ -94,15 +95,18 @@ export default function GroceryList() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine both unbought and recently bought items for similarity checking
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
|
||||
// Check if exact match exists (case-insensitive)
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
const exactMatch = items.find(item => item.item_name.toLowerCase() === lowerText);
|
||||
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
||||
|
||||
if (exactMatch) {
|
||||
setButtonText("Add Item");
|
||||
} else {
|
||||
// Check for similar items (80% match)
|
||||
const similar = findSimilarItems(text, items, 80);
|
||||
const similar = findSimilarItems(text, allItems, 80);
|
||||
if (similar.length > 0) {
|
||||
// Show suggestion in button but allow creation
|
||||
setButtonText("Create and Add Item");
|
||||
@ -125,8 +129,11 @@ export default function GroceryList() {
|
||||
|
||||
const lowerItemName = itemName.toLowerCase().trim();
|
||||
|
||||
// Combine both unbought and recently bought items for similarity checking
|
||||
const allItems = [...items, ...recentlyBoughtItems];
|
||||
|
||||
// Check for 80% similar items
|
||||
const similar = findSimilarItems(itemName, items, 80);
|
||||
const similar = findSimilarItems(itemName, allItems, 80);
|
||||
if (similar.length > 0) {
|
||||
// Show modal and wait for user decision
|
||||
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
||||
@ -385,7 +392,7 @@ export default function GroceryList() {
|
||||
<>
|
||||
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||
<ul className="glist-ul">
|
||||
{recentlyBoughtItems.map((item) => (
|
||||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||
<GroceryListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
@ -399,6 +406,16 @@ export default function GroceryList() {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { loginRequest } from "../api/auth";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import FormInput from "../components/FormInput";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/Login.css";
|
||||
import "../styles/pages/Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useContext(AuthContext);
|
||||
|
||||
@ -2,10 +2,10 @@ import { useContext, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { loginRequest, registerRequest } from "../api/auth";
|
||||
import { checkIfUserExists } from "../api/users";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import FormInput from "../components/FormInput";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import "../styles/Register.css";
|
||||
import "../styles/pages/Register.css";
|
||||
|
||||
export default function Register() {
|
||||
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,50 +1,50 @@
|
||||
/* Container */
|
||||
.glist-body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
font-family: var(--font-family-base);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg-body);
|
||||
}
|
||||
|
||||
.glist-container {
|
||||
max-width: 480px;
|
||||
max-width: var(--container-max-width);
|
||||
margin: auto;
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.08);
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.glist-title {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.4em;
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.glist-section-title {
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #495057;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
padding-top: 1em;
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--color-gray-700);
|
||||
border-top: var(--border-width-medium) solid var(--color-border-light);
|
||||
padding-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Classification Groups */
|
||||
.glist-classification-group {
|
||||
margin-bottom: 2em;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.glist-classification-header {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: #007bff;
|
||||
margin: 1em 0 0.5em 0;
|
||||
padding: 0.5em 0.8em;
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-primary);
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-primary-light);
|
||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
@ -58,19 +58,38 @@
|
||||
|
||||
/* Buttons */
|
||||
.glist-btn {
|
||||
font-size: 1em;
|
||||
padding: 0.55em;
|
||||
font-size: var(--font-size-base);
|
||||
padding: var(--button-padding-y);
|
||||
width: 100%;
|
||||
margin-top: 0.4em;
|
||||
margin-top: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--button-border-radius);
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.glist-btn:hover {
|
||||
background: #0067d8;
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.glist-show-more-btn {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
border: var(--border-width-thin) solid var(--color-primary);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--button-border-radius);
|
||||
transition: var(--transition-base);
|
||||
font-weight: var(--button-font-weight);
|
||||
}
|
||||
|
||||
.glist-show-more-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
/* Suggestion dropdown */
|
||||
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