dev #1

Merged
nalalangan merged 7 commits from dev into main 2026-01-02 15:12:31 -10:00
47 changed files with 1677 additions and 745 deletions
Showing only changes of commit aa9e71194c - Show all commits

View 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.

View File

@ -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";

View File

@ -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>
);
}

View File

@ -1,4 +1,4 @@
import { ROLES } from "../constants/roles";
import { ROLES } from "../../constants/roles";
export default function UserRoleCard({ user, onRoleChange }) {
return (

View 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';

View File

@ -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("");

View 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>
);
}

View 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>
);
}

View 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';

View File

@ -1,23 +1,23 @@
import type { GroceryItemType } from "../types";
interface Props {
item: GroceryItemType;
onClick: (id: number) => void;
}
export default function GroceryItem({ item, onClick }: Props) {
return (
<li
onClick={() => onClick(item.id)}
style={{
padding: "0.5em",
background: "#e9ecef",
marginBottom: "0.5em",
borderRadius: "4px",
cursor: "pointer",
}}
>
{item.item_name} ({item.quantity})
</li>
);
}
import type { GroceryItemType } from "../types";
interface Props {
item: GroceryItemType;
onClick: (id: number) => void;
}
export default function GroceryItem({ item, onClick }: Props) {
return (
<li
onClick={() => onClick(item.id)}
style={{
padding: "0.5em",
background: "#e9ecef",
marginBottom: "0.5em",
borderRadius: "4px",
cursor: "pointer",
}}
>
{item.item_name} ({item.quantity})
</li>
);
}

View File

@ -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);

View File

@ -1,40 +1,40 @@
interface Props {
suggestions: string[];
onSelect: (value: string) => void;
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null;
return (
<ul
style={{
background: "#fff",
border: "1px solid #ccc",
maxHeight: "150px",
overflowY: "auto",
position: "absolute",
zIndex: 1000,
left: "1em",
right: "1em",
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{suggestions.map((s) => (
<li
key={s}
onClick={() => onSelect(s)}
style={{
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{s}
</li>
))}
</ul>
);
}
interface Props {
suggestions: string[];
onSelect: (value: string) => void;
}
export default function SuggestionList({ suggestions, onSelect }: Props) {
if (!suggestions.length) return null;
return (
<ul
style={{
background: "#fff",
border: "1px solid #ccc",
maxHeight: "150px",
overflowY: "auto",
position: "absolute",
zIndex: 1000,
left: "1em",
right: "1em",
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{suggestions.map((s) => (
<li
key={s}
onClick={() => onSelect(s)}
style={{
padding: "0.5em",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
>
{s}
</li>
))}
</ul>
);
}

View 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';

View File

@ -1,11 +1,11 @@
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
export default function AppLayout() {
return (
<div>
<Navbar />
<Outlet />
</div>
);
}
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
export default function AppLayout() {
return (
<div>
<Navbar />
<Outlet />
</div>
);
}

View File

@ -1,30 +1,30 @@
import "../styles/Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../context/AuthContext";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
return (
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}
</div>
<div className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span>
</div>
</div>
<button className="navbar-logout" onClick={logout}>
Logout
</button>
</nav>
);
import "../../styles/components/Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../../context/AuthContext";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
return (
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}
</div>
<div className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span>
</div>
</div>
<button className="navbar-logout" onClick={logout}>
Logout
</button>
</nav>
);
}

View File

@ -0,0 +1,4 @@
// Barrel export for layout components
export { default as AppLayout } from './AppLayout.jsx';
export { default as Navbar } from './Navbar.jsx';

View File

@ -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);

View 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>
);
}

View File

@ -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);

View File

@ -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

View File

@ -1,5 +1,5 @@
import { useEffect } from "react";
import "../styles/ImageModal.css";
import "../../styles/ImageModal.css";
export default function ImageModal({ imageUrl, itemName, onClose }) {
useEffect(() => {

View File

@ -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);

View File

@ -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("");

View File

@ -1,4 +1,4 @@
import "../styles/SimilarItemModal.css";
import "../../styles/SimilarItemModal.css";
export default function SimilarItemModal({ originalName, suggestedName, onCancel, onNo, onYes }) {
return (

View 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';

View File

@ -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;

View File

@ -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>

View 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!"

View 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!"

View File

@ -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() {

View File

@ -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>

View File

@ -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);

View File

@ -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();

View 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
*/

View 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;
}

View 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);
}

View File

@ -1,58 +1,58 @@
.navbar {
background: #343a40;
color: white;
padding: 0.6em 1em;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
margin-bottom: 1em;
}
.navbar-links a {
color: white;
margin-right: 1em;
text-decoration: none;
font-size: 1.1em;
}
.navbar-links a:hover {
text-decoration: underline;
}
.navbar-logout {
background: #dc3545;
color: white;
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
width: 100px;
}
.navbar-idcard {
display: flex;
align-items: center;
align-content: center;
margin-right: 1em;
padding: 0.3em 0.6em;
background: #495057;
border-radius: 4px;
color: white;
}
.navbar-idinfo {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.navbar-username {
font-size: 0.95em;
font-weight: bold;
}
.navbar-role {
font-size: 0.75em;
opacity: 0.8;
}
.navbar {
background: #343a40;
color: white;
padding: 0.6em 1em;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
margin-bottom: 1em;
}
.navbar-links a {
color: white;
margin-right: 1em;
text-decoration: none;
font-size: 1.1em;
}
.navbar-links a:hover {
text-decoration: underline;
}
.navbar-logout {
background: #dc3545;
color: white;
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
width: 100px;
}
.navbar-idcard {
display: flex;
align-items: center;
align-content: center;
margin-right: 1em;
padding: 0.3em 0.6em;
background: #495057;
border-radius: 4px;
color: white;
}
.navbar-idinfo {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.navbar-username {
font-size: 0.95em;
font-weight: bold;
}
.navbar-role {
font-size: 0.75em;
opacity: 0.8;
}

View File

@ -1,304 +1,323 @@
/* Container */
.glist-body {
font-family: Arial, sans-serif;
padding: 1em;
background: #f8f9fa;
}
.glist-container {
max-width: 480px;
margin: auto;
background: white;
padding: 1em;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
}
/* Title */
.glist-title {
text-align: center;
font-size: 1.5em;
margin-bottom: 0.4em;
}
.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;
}
/* Classification Groups */
.glist-classification-group {
margin-bottom: 2em;
}
.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;
}
/* Inputs */
.glist-input {
font-size: 1em;
padding: 0.5em;
margin: 0.3em 0;
width: 100%;
box-sizing: border-box;
}
/* Buttons */
.glist-btn {
font-size: 1em;
padding: 0.55em;
width: 100%;
margin-top: 0.4em;
cursor: pointer;
border: none;
background: #007bff;
color: white;
border-radius: 4px;
}
.glist-btn:hover {
background: #0067d8;
}
/* Suggestion dropdown */
.glist-suggest-box {
background: #fff;
border: 1px solid #ccc;
max-height: 150px;
overflow-y: auto;
position: absolute;
z-index: 999;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: 1em;
width: calc(100% - 8em);
max-width: 440px;
margin: 0 auto;
}
.glist-suggest-item {
padding: 0.5em;
padding-inline: 2em;
cursor: pointer;
}
.glist-suggest-item:hover {
background: #eee;
}
/* Grocery list items */
.glist-ul {
list-style: none;
padding: 0;
margin-top: 1em;
}
.glist-li {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 0.8em;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
overflow: hidden;
}
.glist-li:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.glist-item-layout {
display: flex;
gap: 1em;
padding: 0em;
align-items: center;
}
.glist-item-image {
width: 50px;
height: 50px;
min-width: 50px;
background: #f5f5f5;
border: 2px solid #e0e0e0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
color: #ccc;
overflow: hidden;
position: relative;
}
.glist-item-image.has-image {
border-color: #007bff;
background: #fff;
}
.glist-item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.glist-item-image.has-image:hover {
opacity: 0.8;
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
}
.glist-item-content {
display: flex;
flex-direction: column;
gap: 0.4em;
flex: 1;
min-width: 0;
}
.glist-item-header {
display: flex;
align-items: baseline;
gap: 0.5em;
flex-wrap: wrap;
}
.glist-item-name {
font-weight: 800;
font-size: 0.8em;
color: #333;
}
.glist-item-quantity {
position: absolute;
top: 0;
right: 0;
background: rgba(0, 123, 255, 0.9);
color: white;
font-weight: 700;
font-size: 0.3em;
padding: 0.2em 0.4em;
border-radius: 0 6px 0 4px;
min-width: 20%;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.glist-item-users {
font-size: 0.7em;
color: #888;
font-style: italic;
}
/* Sorting dropdown */
.glist-sort {
width: 100%;
margin: 0.3em 0;
padding: 0.5em;
font-size: 1em;
border-radius: 4px;
}
/* Image upload */
.glist-image-upload {
margin: 0.5em 0;
}
.glist-image-label {
display: block;
padding: 0.6em;
background: #f0f0f0;
border: 2px dashed #ccc;
border-radius: 4px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.glist-image-label:hover {
background: #e8e8e8;
border-color: #007bff;
}
.glist-image-preview {
position: relative;
margin-top: 0.5em;
display: inline-block;
}
.glist-image-preview img {
max-width: 150px;
max-height: 150px;
border-radius: 8px;
border: 2px solid #ddd;
}
.glist-remove-image {
position: absolute;
top: -8px;
right: -8px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #ff4444;
color: white;
border: 2px solid white;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.glist-remove-image:hover {
background: #cc0000;
}
/* Floating Action Button (FAB) */
.glist-fab {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 50%;
width: 62px;
height: 62px;
font-size: 2em;
line-height: 0;
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;
}
}
/* Container */
.glist-body {
font-family: var(--font-family-base);
padding: var(--spacing-md);
background: var(--color-bg-body);
}
.glist-container {
max-width: var(--container-max-width);
margin: auto;
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: var(--font-size-2xl);
margin-bottom: var(--spacing-sm);
}
.glist-section-title {
text-align: center;
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: var(--spacing-xl);
}
.glist-classification-header {
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 */
.glist-input {
font-size: 1em;
padding: 0.5em;
margin: 0.3em 0;
width: 100%;
box-sizing: border-box;
}
/* Buttons */
.glist-btn {
font-size: var(--font-size-base);
padding: var(--button-padding-y);
width: 100%;
margin-top: var(--spacing-sm);
cursor: pointer;
border: none;
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: 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 */
.glist-suggest-box {
background: #fff;
border: 1px solid #ccc;
max-height: 150px;
overflow-y: auto;
position: absolute;
z-index: 999;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: 1em;
width: calc(100% - 8em);
max-width: 440px;
margin: 0 auto;
}
.glist-suggest-item {
padding: 0.5em;
padding-inline: 2em;
cursor: pointer;
}
.glist-suggest-item:hover {
background: #eee;
}
/* Grocery list items */
.glist-ul {
list-style: none;
padding: 0;
margin-top: 1em;
}
.glist-li {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 0.8em;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
overflow: hidden;
}
.glist-li:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.glist-item-layout {
display: flex;
gap: 1em;
padding: 0em;
align-items: center;
}
.glist-item-image {
width: 50px;
height: 50px;
min-width: 50px;
background: #f5f5f5;
border: 2px solid #e0e0e0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
color: #ccc;
overflow: hidden;
position: relative;
}
.glist-item-image.has-image {
border-color: #007bff;
background: #fff;
}
.glist-item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.glist-item-image.has-image:hover {
opacity: 0.8;
box-shadow: 0 0 8px rgba(0, 123, 255, 0.3);
}
.glist-item-content {
display: flex;
flex-direction: column;
gap: 0.4em;
flex: 1;
min-width: 0;
}
.glist-item-header {
display: flex;
align-items: baseline;
gap: 0.5em;
flex-wrap: wrap;
}
.glist-item-name {
font-weight: 800;
font-size: 0.8em;
color: #333;
}
.glist-item-quantity {
position: absolute;
top: 0;
right: 0;
background: rgba(0, 123, 255, 0.9);
color: white;
font-weight: 700;
font-size: 0.3em;
padding: 0.2em 0.4em;
border-radius: 0 6px 0 4px;
min-width: 20%;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.glist-item-users {
font-size: 0.7em;
color: #888;
font-style: italic;
}
/* Sorting dropdown */
.glist-sort {
width: 100%;
margin: 0.3em 0;
padding: 0.5em;
font-size: 1em;
border-radius: 4px;
}
/* Image upload */
.glist-image-upload {
margin: 0.5em 0;
}
.glist-image-label {
display: block;
padding: 0.6em;
background: #f0f0f0;
border: 2px dashed #ccc;
border-radius: 4px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.glist-image-label:hover {
background: #e8e8e8;
border-color: #007bff;
}
.glist-image-preview {
position: relative;
margin-top: 0.5em;
display: inline-block;
}
.glist-image-preview img {
max-width: 150px;
max-height: 150px;
border-radius: 8px;
border: 2px solid #ddd;
}
.glist-remove-image {
position: absolute;
top: -8px;
right: -8px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #ff4444;
color: white;
border: 2px solid white;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.glist-remove-image:hover {
background: #cc0000;
}
/* Floating Action Button (FAB) */
.glist-fab {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 50%;
width: 62px;
height: 62px;
font-size: 2em;
line-height: 0;
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;
}
}

View 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; }