Compare commits

...

3 Commits

25 changed files with 4552 additions and 1036 deletions

556
README.md
View File

@@ -1,353 +1,313 @@
# Webpage Playground
# Pantry Management Frontend
A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.
React + Vite frontend for the ASP.NET Core pantry manager API.
## Overview
This app is no longer a static demo. It now uses the backend API for authentication, profile data, inventory items, locations, search, and barcode-based item lookup.
Webpage Playground is a lightweight, responsive web application for managing and tracking inventory across multiple storage locations (Pantry, Fridge, Freezer). The app includes:
## What Changed
- **Dynamic Navigation** — Easy page switching with a responsive navbar
- **Inventory Demo** — Display items using reusable item components
- **Advanced Search** — Filter inventory by name, location, quantity range, and expiry date
- **Barcode Scanner** — Scan barcodes using camera or keyboard (hardware scanner support)
- **Data Visualization** — Pie chart showing inventory distribution
- **Responsive Design** — Works on desktop, tablet, and mobile devices
- Replaced local demo inventory data with live API calls.
- Added a shared API client with JWT handling, refresh-token retry, and normalized error messages.
- Added shared auth/session state with `localStorage` persistence.
- Updated the dashboard to support login and live inventory summary data.
- Added a dedicated inventory management page for CRUD operations.
- Updated search to call the backend search endpoints.
- Updated barcode scanning so scans look up matching inventory items through the API.
- Removed the register panel and the old `Connected API` section from the login view.
## Project Structure
## Routes
### Pages
- `index.html` — Homepage with item component demo and inventory pie chart
- `search.html` — Search and filter inventory by name, location, quantity, and expiry date
- `barcode.html` — Barcode scanner with camera and keyboard input modes
- `pantry.html` — Pantry inventory container
- `fridge.html` — Fridge inventory container
- `freezer.html` — Freezer inventory container
| Route | Purpose |
| --- | --- |
| `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. |
| `/admin` | Household administration page for listing, creating, editing, inviting, and leaving households. |
| `/inventory` | Manage locations and inventory items. |
| `/search` | Search items and locations, then filter results further in the UI. |
| `/barcode` | Scan a barcode and search the inventory API for matches. |
| `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. |
### Core Modules
- `navbar.js` — Dynamic navigation bar system loaded by all pages
- `main.css` — Global styles and responsive design
- `item-component.js` — Reusable ES6 module for displaying inventory items in a grid
- `search.js` — Search and filtering logic with 20 sample inventory items including expiry dates
- `barcode-scanner.js` — Barcode capture and console logging module
- `piechart.js` — Inventory distribution pie chart visualization
Routing is defined in `src/App.jsx`.
### Documentation
- `ITEM_COMPONENT.md` — Detailed documentation for the item component factory
- `README.md` — This file
## API Endpoint Map
## Features Overview
### Dashboard (`src/pages/HomePage/HomePage.jsx`)
### 🏠 Homepage (`index.html`)
The landing page showcasing the project with:
- Interactive item component grid (3 sample items)
- Inventory breakdown pie chart showing distribution across storage locations
- Responsive grid layout that adapts to screen size
When signed out:
**Sample Data:**
- Pantry: 104 items
- Fridge: 30 items
- Freezer: 87 items
- `POST /api/auth/login`
### 🔍 Search Page (`search.html`)
Advanced inventory search and filtering interface.
When signed in:
**Features:**
- Search by item name (case-insensitive)
- Filter by storage location (All, Pantry, Fridge, Freezer)
- Filter by quantity range (min/max values)
- Filter by expiry date range (start date and end date)
- Real-time result display with item cards
- Visual expiry status indicators (Fresh, Expiring Soon, Expired)
- **Pagination** — Results displayed with configurable items per page (15, 30, or 60)
- Reset button to clear all filters
- Keyboard support (press Enter to search)
- Date range validation (start date ≤ end date)
- `GET /api/locations`
- `GET /api/inventoryitems`
**Pagination Features:**
- **Items Per Page Dropdown** — Choose between 15, 30, or 60 items per page
- **Previous/Next Navigation** — Browse through pages of results
- **Page Information** — Shows current page and total pages (e.g., "Page 2 of 5")
- **Result Summary** — Displays showing item count (e.g., "Found 47 items (16-30)")
- **Smart Boundaries** — Previous button disabled on page 1, Next disabled on last page
- **Auto-Reset** — Returns to page 1 when search filters change
- **Dynamic Page Count** — Automatically updates total pages when page size changes
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`)
**Expiry Date Features:**
- Each inventory item includes an expiry date
- Visual badges show expiry status:
- 🟢 **Fresh** — Items expiring in more than 7 days
- 🟠 **Expiring Soon** — Items expiring within 7 days
- 🔴 **Expired** — Items past expiry date
- Formatted expiry dates displayed on item cards
- Filter by start and end date to find items expiring in a specific timeframe
Locations:
**Sample Usage:**
```
Search for items expiring between March 15 and April 15
Results show all items with expiry dates in that range
Items display formatted dates and expiry status badges
- `GET /api/locations`
- `POST /api/locations`
- `PUT /api/locations/{id}`
- `DELETE /api/locations/{id}`
Inventory items:
- `GET /api/inventoryitems`
- `GET /api/inventoryitems/{id}`
- `POST /api/inventoryitems`
- `PUT /api/inventoryitems/{id}`
- `DELETE /api/inventoryitems/{id}`
### Admin (`src/pages/AdminPage/AdminPage.jsx`)
Households:
- `GET /api/households`
- `POST /api/households`
- `PUT /api/households/{id}`
- `POST /api/households/{id}/invite`
- `DELETE /api/households/{id}/leave`
Notes:
- Household creation is limited to users with the backend `Admin` role.
- Household editing and member invites are available when the current user is a household admin or site admin.
### Search (`src/pages/SearchPage/SearchPage.jsx`)
Initial page data:
- `GET /api/locations`
- `GET /api/inventoryitems`
Search actions:
- `GET /api/search/items?q={query}`
- `GET /api/search/locations?q={query}`
Note:
- The backend search API only supports the `q` query parameter.
- Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns.
### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`)
- `GET /api/search/items?q={barcode}`
The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value.
### Users (`src/pages/UsersPage/UsersPage.jsx`)
- `GET /api/users`
Notes:
- The users page is intentionally minimal and is ready to consume a future users endpoint.
- The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`.
### Shared Auth Support (`src/api/client.js`)
The shared API client also supports:
- `POST /api/auth/register`
- `POST /api/auth/refresh-token`
Notes:
- `register` is still implemented in the client helper, but there is currently no registration form in the UI.
- If you need to create a user, use the backend Swagger UI or another client to call `POST /api/auth/register`.
## API Configuration
The API base URL is configured in `src/api/client.js`.
Current setup:
```js
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000').trim().replace(/\/$/, '')
```
### 📱 Barcode Scanner (`barcode.html`)
Dual-mode barcode scanning interface with keyboard input and camera scanning.
That means:
**Features:**
- If `VITE_API_BASE_URL` is set, the frontend uses that value.
- Otherwise it defaults to `http://localhost:5000` to match the C# API repo's `appsettings.json`.
#### Keyboard Input Mode
- Type or paste barcode values
- Hardware barcode scanner device support
- Real-time console logging
- Auto-focus field for seamless entry
- Enter key to scan
### Recommended Override
#### Camera Scanning Mode (**NEW**)
- **Real-time barcode detection** using device camera
- **Supported formats:** UPC-A, UPC-E, EAN-13, EAN-8, Code-128, Code-39, and more
- **Browser-based detection** (uses Quagga2 library - local version)
- **Mode toggle** between keyboard and camera input
- **Camera controls** with start/stop buttons
- **Real-time feedback** showing detected barcodes
- **Error handling** with user-friendly messages
- **Mobile support** — Works on iOS and Android devices with camera access
- **Console logging** of all detections with metadata
Create a `.env.local` file in the project root:
**Camera Usage:**
1. Switch to "Camera Scan" mode by clicking the button
2. Click "Start Camera" to begin
3. Browser will request camera permission (grant access)
4. Position barcode in front of camera
5. Barcode is detected automatically in real-time
6. Detected barcodes logged to console (press F12)
7. Click "Stop Camera" to end scanning
**Keyboard Usage:**
1. Stay in "Keyboard Input" mode
2. Type barcode value or paste from clipboard
3. Press Enter to complete scan
4. Barcode logged to console
**Console Output Format:**
```
[Barcode Scanned] 14:07:32.456 | Barcode: 5901234123457 | Input: keyboard
[Barcode Scanned] 14:07:45.123 | Barcode: 123456789012 | Input: camera
```env
VITE_API_BASE_URL=http://localhost:5000
```
**Browser Support:**
- Chrome 53+, Firefox 55+, Safari 11+, Edge 79+
- Camera access requires HTTPS or localhost
- Mobile browsers support camera scanning
Example for a different backend host:
**Testing Instructions:**
1. Navigate to the Barcode Scanner page via navbar
2. Try keyboard input mode: Type a barcode and press Enter
3. Try camera mode: Switch to "Camera Scan" and point at a barcode
4. Press F12 to open DevTools Console
5. View logged barcodes with timestamps and input type
```env
VITE_API_BASE_URL=https://your-api-host.example.com
```
### 📦 Storage Location Pages
- `pantry.html` — Pantry inventory (expandable for displaying specific items)
- `fridge.html` — Fridge inventory
- `freezer.html` — Freezer inventory
After changing the env file, restart the Vite dev server.
These pages are currently placeholder containers ready for future development (e.g., displaying items specific to each location).
## Auth and Session Behavior
## Quick Start
Auth/session logic lives in:
The project is a static site. For best results, serve it over HTTP (ES module imports can be blocked when opened via the `file://` protocol in some browsers).
- `src/api/client.js`
- `src/context/AuthContext.jsx`
### Python 3 (Recommended)
Behavior:
- Access token and refresh token are stored in `localStorage`.
- Storage key: `pantry-management-session`
- Authenticated requests automatically send `Authorization: Bearer <token>`.
- If a request returns `401`, the client attempts `POST /api/auth/refresh-token` once and retries the original request.
- Logout clears local session state even if the API logout request fails.
## Local Development
### 1. Install dependencies
```bash
# From the project root
python -m http.server 8000
# Then open http://localhost:8000 in your browser
npm install
```
### Alternative Options
### 2. Make sure the backend API is running
- **VS Code Live Server Extension** — Right-click `index.html` → "Open with Live Server"
- **Node.js http-server** — `npx http-server`
- **Any static file server**
Default expected API URL:
## Usage
### Navigation
The navbar appears at the top of every page and provides links to:
- Homepage — Main demo page with pie chart
- Search — Advanced inventory search and filtering with expiry date support
- Barcode Scanner — Barcode capture with camera and keyboard modes
- Pantry, Fridge, Freezer — Individual storage location pages
### Using the Search Page
1. Open the Search page from the navbar
2. Enter search criteria:
- Item name (optional)
- Storage location (optional, defaults to "All")
- Quantity range (optional, defaults to 0-999)
- Expiry date range (optional) — Leave blank to ignore
3. Click "Search" or press Enter
4. Results display as item cards using the item component with expiry status badges
5. Click "Reset" to clear all filters
**Expiry Date Search Examples:**
- Find items expiring soon: Set start date to today, end date to 7 days from today
- Find expired items: Set start date to 100 days ago, end date to today
- Find fresh items: Set start date to tomorrow, end date to 90 days from today
### Using the Barcode Scanner
#### Keyboard Mode
1. Open the Barcode Scanner page from the navbar
2. Make sure "Keyboard Input" mode is selected
3. Click in the input field (auto-focused)
4. Enter a barcode:
- Type manually and press Enter
- Scan with a barcode scanner device
- Paste a value (Ctrl/Cmd+V)
5. Press F12 to open Developer Tools Console
6. View scanned barcodes in the Console tab with metadata
#### Camera Mode
1. Open the Barcode Scanner page from the navbar
2. Click "Camera Scan" to switch to camera mode
3. Click "Start Camera"
4. Browser will request camera permission (grant access)
5. Position barcode in front of camera lens
6. Barcode is detected automatically and displayed
7. Press F12 to view console logs
8. Click "Stop Camera" to end
**Tips:**
- Ensure good lighting for better barcode detection
- Hold barcode steady and clearly in frame
- Device must have a working camera
- Camera mode requires HTTPS or localhost
### The Item Component
The `item-component` is a reusable UI building block used throughout the app. See `ITEM_COMPONENT.md` for detailed documentation on using it in your own pages.
**Quick Example:**
```html
<script type="module">
import createItemComponent from './item-component.js';
const item = createItemComponent({
imgSrc: 'https://picsum.photos/seed/1/200/200',
imgAlt: 'Milk',
text1: 'Milk',
text2: 'Fridge — 1 carton',
text3: 'Expires Mar 15, 2026'
});
document.getElementById('container').appendChild(item);
</script>
```text
http://localhost:5000
```
## Customizing and Extending
If you use a different URL, set `VITE_API_BASE_URL` in `.env.local`.
### Adding New Items to Search
Edit `search.js` and add items to the `inventoryData` array:
```javascript
export const inventoryData = [
{
id: 21,
name: 'Coffee',
location: 'Pantry',
quantity: 2,
unit: 'bags',
expiryDate: '2026-06-15',
img: 'https://picsum.photos/seed/coffee/200/200'
},
// ... more items
];
### 3. Start the frontend
```bash
npm run dev
```
**Date Format:** Use ISO 8601 format (YYYY-MM-DD) for expiry dates.
### 4. Build for production
### Styling
- Override styles in `main.css` for global changes
- Page-specific styles are included in `<style>` tags within each HTML file
- The item component injects responsive grid CSS automatically
- Expiry badge styles can be customized via `.expiry-fresh`, `.expiry-soon`, `.expiry-expired` classes
### Creating New Pages
1. Create a new `.html` file following the template:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="main.css">
<title>Page Title</title>
</head>
<body>
<div id="navbar-placeholder"></div>
<div id="page-content">
<h2>Page Title</h2>
<!-- Your content here -->
</div>
<script src="navbar.js"></script>
</body>
</html>
```bash
npm run build
```
2. Update `navbar.js` to add a link to your new page:
```javascript
<li><a href="yourpage.html" data-route="yourpage.html">Your Page</a></li>
### 5. Preview the production build
```bash
npm run preview
```
### Converting to Production
- Replace placeholder images with real inventory photos
- Implement backend storage (currently using static data)
- Add user authentication
- Integrate with real barcode/UPC database
- Deploy camera barcode detection to server (for performance optimization)
- Consider a frontend framework (React, Vue, etc.) for scale
- Add unit/integration tests
- Set up CI/CD pipeline
- Implement database for persistent storage and inventory tracking
## Using the App
## Technical Notes
### Login
### Barcode Scanning Library
- **Library:** Quagga2 (local version v1.12.1)
- **Location:** `./quagga2/quagga2-1.12.1/docs/examples/dist/quagga.min.js` (153 KB)
- **Why Quagga2:** Improved barcode detection accuracy and performance compared to original Quagga
- **Supported Formats:** UPC-A, UPC-E, EAN-13, EAN-8, Code-128, Code-39
- **API:** Backward compatible with original Quagga.js; no code changes needed
- **Initialization:** See `barcode.html` lines 268-290 for camera initialization logic
1. Open `/`
2. Enter an existing user email and password
3. Sign in to unlock the protected routes and API-backed data
### Camera Access
- Requires browser permission (user must grant access)
- Works over HTTPS or localhost only (security requirement)
- Uses HTML5 MediaDevices API (`getUserMedia`)
- Video constraints: 800x600 resolution, environment-facing camera
- Real-time frame processing for barcode detection
There is no registration form in the UI at the moment.
### Performance Considerations
- Camera scanning is CPU-intensive on older devices
- Frame processing happens in real-time on main thread
- Consider WebWorkers for production optimization
- Network-free operation: all detection happens client-side
### Inventory Page
## Contributing
Use `/inventory` to:
Contributions are welcome! Suggested improvements:
- Create, edit, and delete locations
- Create, edit, and delete inventory items
- Load a single inventory item before editing
- [ ] Add unit and visual tests
- [ ] Replace placeholder images with real inventory photos
- [ ] Implement barcode scanner backend integration (save to database)
- [ ] Add user accounts and authentication
- [ ] Implement item editing/deletion on storage pages
- [ ] Add barcode/UPC lookup for real products
- [ ] Convert item-component to custom element (`<item-component>`)
- [ ] Optimize camera barcode detection for performance
- [ ] Add more barcode format support
- [ ] Add email/notification alerts for items expiring soon
- [ ] Implement CSV import/export for inventory
Important update behavior:
## License
- Blank text fields on item update are sent as empty strings.
- Blank amount, date, and location fields are omitted from the update payload because the backend only updates fields that are provided.
Use as you like; no license file is included by default.
### Admin Page
Use `/admin` to:
- Review all households returned for the current user
- Create a new household when the signed-in user has the site admin role
- Edit households the signed-in user administers
- Invite members by email to the selected household
- Leave a household from the same page
### Search Page
Use `/search` to:
- Search items with the backend `items` search endpoint
- Search locations with the backend `locations` search endpoint
- Narrow item results further by location, amount, and expiry date in the frontend
- Page through large result sets
### Barcode Page
Use `/barcode` to:
- Scan via keyboard input
- Scan via device camera
- Automatically search inventory after a scan
- Re-run search for the last scanned barcode
Camera scanning works best on HTTPS or localhost with a trusted dev certificate.
### Users Page
Use `/users` to:
- Refresh the users dataset from the backend once `GET /api/users` exists
- Verify a future users endpoint without adding UI complexity first
## Key Files
| File | Purpose |
| --- | --- |
| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
| `src/context/AuthContext.jsx` | Shared auth/session state for the React app |
| `src/App.jsx` | Route definitions |
| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI |
| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI |
| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI |
| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI |
| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI |
| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view |
## Troubleshooting
### The frontend cannot reach the API
Check:
- The backend is running
- `VITE_API_BASE_URL` is correct
- The backend HTTPS certificate is trusted
- CORS is enabled on the API
### Login works but protected pages fail later
Check:
- The backend refresh token flow is working
- The stored session in browser `localStorage` is valid
- The API still matches the endpoint shapes described in the backend README
### The users page shows a placeholder instead of data
This is expected until the backend adds a users endpoint.
- The page assumes `GET /api/users`
- The current C# repo does not expose that route yet
- Once the backend endpoint exists, the page will start rendering results without additional routing work
### Search filters do not match backend behavior exactly
This is expected for some filters.
- The backend only exposes `q` search for items and locations.
- Additional amount, location, and expiry filters are applied in the frontend after the API returns results.
## Notes
- The barcode page uses `@ericblade/quagga2` for camera scanning.
- The frontend was verified with `npm run build` after these API integration changes.

View File

@@ -1,3 +1,125 @@
:root {
color-scheme: light;
--color-bg: #f4f7fb;
--color-surface: #ffffff;
--color-surface-muted: #f8fafc;
--color-surface-subtle: #fbfcfe;
--color-text: #1e293b;
--color-text-strong: #0f172a;
--color-text-soft: #475569;
--color-text-muted: #64748b;
--color-border: #dde5ef;
--color-border-strong: #cbd5e1;
--color-border-muted: #dbe4f0;
--color-border-subtle: #d9e2ef;
--color-rule: #5b5b5b;
--color-focus: #2563eb;
--color-focus-ring: rgba(37, 99, 235, 0.12);
--color-focus-ring-strong: rgba(37, 99, 235, 0.14);
--color-chart-track: #f2f2f2;
--color-video-bg: #000000;
--color-input-bg: #ffffff;
--color-input-focus-bg: #fffef0;
--shadow-panel: 0 16px 32px rgba(15, 23, 42, 0.04);
--shadow-soft: 0 2px 4px rgba(0, 0, 0, 0.1);
--nav-bg: #333333;
--nav-bg-hover: #111111;
--nav-text: #ffffff;
--nav-button-border: rgba(255, 255, 255, 0.35);
--nav-button-hover: rgba(255, 255, 255, 0.14);
--nav-divider: rgba(255, 255, 255, 0.12);
--button-primary-bg: #4caf50;
--button-primary-hover: #45a049;
--button-primary-text: #ffffff;
--button-primary-ring: rgba(76, 175, 80, 0.3);
--button-secondary-bg: #dddddd;
--button-secondary-hover: #bbbbbb;
--button-secondary-text: #333333;
--button-danger-bg: #f44336;
--button-danger-hover: #da190b;
--button-danger-text: #ffffff;
--button-accent-bg: #2196f3;
--button-accent-hover: #0b7dda;
--button-accent-text: #ffffff;
--status-error-bg: #fff1f2;
--status-error-border: #fecdd3;
--status-error-text: #be123c;
--status-success-bg: #ecfdf5;
--status-success-border: #bbf7d0;
--status-success-text: #15803d;
--status-success-bg-strong: #c8e6c9;
--status-info-bg: #eff6ff;
--status-info-border: #bfdbfe;
--status-info-text: #1d4ed8;
--status-warning-bg: #fff3e0;
--status-warning-border: #ffe0b2;
--status-warning-text: #e65100;
--status-warning-text-strong: #d84315;
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #0f172a;
--color-surface: #172033;
--color-surface-muted: #1e293b;
--color-surface-subtle: #22304a;
--color-text: #e2e8f0;
--color-text-strong: #f8fafc;
--color-text-soft: #cbd5e1;
--color-text-muted: #94a3b8;
--color-border: #334155;
--color-border-strong: #475569;
--color-border-muted: #42526b;
--color-border-subtle: #3f4f68;
--color-rule: #475569;
--color-focus: #60a5fa;
--color-focus-ring: rgba(96, 165, 250, 0.22);
--color-focus-ring-strong: rgba(96, 165, 250, 0.28);
--color-chart-track: #334155;
--color-video-bg: #020617;
--color-input-bg: #0b1220;
--color-input-focus-bg: #111c31;
--shadow-panel: 0 18px 36px rgba(2, 6, 23, 0.45);
--shadow-soft: 0 10px 24px rgba(2, 6, 23, 0.28);
--nav-bg: #020617;
--nav-bg-hover: #1e293b;
--nav-text: #e2e8f0;
--nav-button-border: rgba(148, 163, 184, 0.35);
--nav-button-hover: rgba(148, 163, 184, 0.16);
--nav-divider: rgba(148, 163, 184, 0.2);
--button-primary-bg: #22c55e;
--button-primary-hover: #16a34a;
--button-primary-text: #f8fafc;
--button-primary-ring: rgba(34, 197, 94, 0.28);
--button-secondary-bg: #334155;
--button-secondary-hover: #475569;
--button-secondary-text: #e2e8f0;
--button-danger-bg: #ef4444;
--button-danger-hover: #dc2626;
--button-danger-text: #f8fafc;
--button-accent-bg: #3b82f6;
--button-accent-hover: #2563eb;
--button-accent-text: #eff6ff;
--status-error-bg: #3b0d19;
--status-error-border: #7f1d1d;
--status-error-text: #fecdd3;
--status-success-bg: #052e1a;
--status-success-border: #166534;
--status-success-text: #bbf7d0;
--status-success-bg-strong: #14532d;
--status-info-bg: #0c2447;
--status-info-border: #1d4ed8;
--status-info-text: #bfdbfe;
--status-warning-bg: #422006;
--status-warning-border: #c2410c;
--status-warning-text: #fed7aa;
--status-warning-text-strong: #fdba74;
}
html {
background: var(--color-bg);
}
/* Dynamically Changing the content padding depending on the width of the screen */
@media screen and (min-width: 1921px) and (max-width: 2560px) {
body {
@@ -13,10 +135,32 @@
}
}
/* General Styling */
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding-top: 12px;
background: var(--color-bg);
color: var(--color-text);
}
#root {
min-height: 100vh;
}
body,
hr,
.btn,
.panel,
.auth-required,
.empty-state,
.field-group input,
.field-group select,
.field-group textarea {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
#page-content {
padding-bottom: 32px;
}
h2 {
@@ -32,45 +176,189 @@ h3 {
hr {
border: 0;
height: 2px;
background: #5b5b5b;
background: var(--color-rule);
margin: 20px 0;
width: 100%;
}
/* Shared button styles */
.btn {
padding: 12px 20px;
border: none;
border-radius: 4px;
border-radius: 10px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: #4CAF50;
color: white;
background-color: var(--button-primary-bg);
color: var(--button-primary-text);
}
.btn-primary:hover {
background-color: #45a049;
background-color: var(--button-primary-hover);
}
.btn-secondary {
background-color: #ddd;
color: #333;
background-color: var(--button-secondary-bg);
color: var(--button-secondary-text);
}
.btn-secondary:hover {
background-color: #bbb;
background-color: var(--button-secondary-hover);
}
.btn-danger {
background-color: #f44336;
color: white;
background-color: var(--button-danger-bg);
color: var(--button-danger-text);
}
.btn-danger:hover {
background-color: #da190b;
background-color: var(--button-danger-hover);
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 24px;
box-shadow: var(--shadow-panel);
}
.status-banner {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 16px;
border: 1px solid transparent;
}
.status-banner--error {
background: var(--status-error-bg);
border-color: var(--status-error-border);
color: var(--status-error-text);
}
.status-banner--success {
background: var(--status-success-bg);
border-color: var(--status-success-border);
color: var(--status-success-text);
}
.status-banner--info {
background: var(--status-info-bg);
border-color: var(--status-info-border);
color: var(--status-info-text);
}
.auth-required,
.empty-state {
padding: 24px;
border-radius: 16px;
background: var(--color-surface);
border: 1px dashed var(--color-border-strong);
text-align: center;
color: var(--color-text-muted);
}
.field-group {
display: grid;
gap: 8px;
}
.field-group label {
font-weight: 700;
color: var(--color-text-soft);
}
.field-group input,
.field-group select,
.field-group textarea {
width: 100%;
box-sizing: border-box;
padding: 11px 12px;
border: 1px solid var(--color-border-strong);
border-radius: 10px;
font: inherit;
background: var(--color-input-bg);
color: inherit;
}
.field-group input::placeholder,
.field-group select::placeholder,
.field-group textarea::placeholder {
color: var(--color-text-muted);
}
.field-group textarea {
resize: vertical;
}
.field-group input:focus,
.field-group select:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring);
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.section-heading h3 {
margin: 0;
text-align: left;
}
.subtle-text {
color: var(--color-text-muted);
font-size: 14px;
}
code {
font-family: Consolas, 'Courier New', monospace;
font-size: 0.95em;
}
@media (max-width: 720px) {
body {
padding-left: 16px !important;
padding-right: 16px !important;
}
.panel {
padding: 18px;
}
.form-grid {
grid-template-columns: 1fr;
}
.button-row,
.section-heading {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -1,19 +1,75 @@
import { useEffect, useState } from 'react'
import { Routes, Route } from 'react-router-dom'
import Navbar from './components/Navbar/Navbar.jsx'
import AdminPage from './pages/AdminPage/AdminPage.jsx'
import HomePage from './pages/HomePage/HomePage.jsx'
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
import SearchPage from './pages/SearchPage/SearchPage.jsx'
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
import UsersPage from './pages/UsersPage/UsersPage.jsx'
import { useAuth } from './context/AuthContext.jsx'
const THEME_STORAGE_KEY = 'pantry-theme'
function resolveInitialTheme() {
if (typeof window === 'undefined') {
return 'light'
}
try {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
if (storedTheme === 'light' || storedTheme === 'dark') {
return storedTheme
}
} catch {
// Ignore storage failures and fall back to system preference.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const initialTheme = resolveInitialTheme()
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = initialTheme
document.documentElement.style.colorScheme = initialTheme
}
function App() {
const { isAuthenticated } = useAuth()
const [theme, setTheme] = useState(initialTheme)
useEffect(() => {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Ignore storage failures and keep the in-memory preference.
}
}, [theme])
function toggleTheme() {
setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light')
}
return (
<>
<Navbar />
<Navbar theme={theme} onToggleTheme={toggleTheme} />
<div id="page-content">
{isAuthenticated ? (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/barcode" element={<BarcodePage />} />
<Route path="/users" element={<UsersPage />} />
</Routes>
) : (
<HomePage />
)}
</div>
</>
)

401
src/api/client.js Normal file
View File

@@ -0,0 +1,401 @@
const SESSION_STORAGE_KEY = 'pantry-management-session'
const SESSION_CHANGE_EVENT = 'pantry-management-session-change'
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '')
let refreshPromise = null
export class ApiError extends Error {
constructor(message, status = 0, data = null) {
super(message)
this.name = 'ApiError'
this.status = status
this.data = data
}
}
function buildUrl(path) {
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
}
function parseStoredSession(rawValue) {
if (!rawValue) return null
try {
return JSON.parse(rawValue)
} catch {
return null
}
}
function dispatchSessionChange(session) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
detail: session,
}))
}
export function getStoredSession() {
if (typeof window === 'undefined') return null
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
}
export function saveSession(session) {
if (typeof window === 'undefined') return session
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
dispatchSessionChange(session)
return session
}
export function clearSession() {
if (typeof window === 'undefined') return
window.localStorage.removeItem(SESSION_STORAGE_KEY)
dispatchSessionChange(null)
}
export function subscribeToSessionChanges(listener) {
if (typeof window === 'undefined') return () => {}
function handleCustomEvent(event) {
listener(event.detail ?? null)
}
function handleStorageEvent(event) {
if (event.key !== SESSION_STORAGE_KEY) return
listener(parseStoredSession(event.newValue))
}
window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
window.addEventListener('storage', handleStorageEvent)
return () => {
window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
window.removeEventListener('storage', handleStorageEvent)
}
}
async function readResponse(response) {
const text = await response.text()
if (!text) return null
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
try {
return JSON.parse(text)
} catch {
return text
}
}
function extractErrorMessage(data, fallbackMessage) {
if (!data) return fallbackMessage
if (typeof data === 'string' && data.trim()) {
return data
}
if (typeof data.message === 'string' && data.message.trim()) {
return data.message
}
if (typeof data.Message === 'string' && data.Message.trim()) {
return data.Message
}
if (typeof data.error === 'string' && data.error.trim()) {
return data.error
}
if (data.errors && typeof data.errors === 'object') {
const messages = Object.values(data.errors)
.flatMap(value => Array.isArray(value) ? value : [value])
.filter(Boolean)
if (messages.length > 0) {
return messages.join(' ')
}
}
if (typeof data.title === 'string' && data.title.trim()) {
return data.title
}
return fallbackMessage
}
async function performRefresh(session) {
if (!session?.refreshToken) {
clearSession()
throw new ApiError('Your session expired. Sign in again.', 401)
}
let response
try {
response = await fetch(buildUrl('/api/auth/refresh-token'), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: session.accessToken,
refreshToken: session.refreshToken,
}),
})
} catch (error) {
throw new ApiError(
'Unable to refresh your session. Make sure the API is running and reachable.',
0,
error,
)
}
const data = await readResponse(response)
if (!response.ok) {
clearSession()
throw new ApiError(
extractErrorMessage(data, 'Your session expired. Sign in again.'),
response.status,
data,
)
}
return saveSession({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user ?? session.user ?? null,
})
}
async function refreshSession() {
if (!refreshPromise) {
refreshPromise = performRefresh(getStoredSession())
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
async function requestJson(path, options = {}) {
const {
method = 'GET',
body,
headers = {},
skipAuth = false,
retryOnAuthFailure = true,
} = options
const session = getStoredSession()
const requestHeaders = {
Accept: 'application/json',
...headers,
}
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json'
}
if (!skipAuth && session?.accessToken) {
requestHeaders.Authorization = `Bearer ${session.accessToken}`
}
let response
try {
response = await fetch(buildUrl(path), {
method,
headers: requestHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch (error) {
throw new ApiError(
'Unable to reach the API. Make sure the backend is running and the certificate is trusted.',
0,
error,
)
}
const data = await readResponse(response)
if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) {
await refreshSession()
return requestJson(path, { ...options, retryOnAuthFailure: false })
}
if (!response.ok) {
throw new ApiError(
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
response.status,
data,
)
}
return data
}
export const authApi = {
async register(payload) {
return requestJson('/api/auth/register', {
method: 'POST',
body: payload,
skipAuth: true,
})
},
async login(payload) {
const data = await requestJson('/api/auth/login', {
method: 'POST',
body: payload,
skipAuth: true,
})
saveSession({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user ?? null,
})
return data
},
async logout() {
try {
await requestJson('/api/auth/logout', {
method: 'POST',
})
} finally {
clearSession()
}
},
}
export const profileApi = {
getProfile() {
return requestJson('/api/profile')
},
getProtectedData() {
return requestJson('/api/profile/data')
},
}
export const locationsApi = {
getLocations() {
return requestJson('/api/locations')
},
createLocation(payload) {
return requestJson('/api/locations', {
method: 'POST',
body: payload,
})
},
updateLocation(id, payload) {
return requestJson(`/api/locations/${id}`, {
method: 'PUT',
body: payload,
})
},
deleteLocation(id) {
return requestJson(`/api/locations/${id}`, {
method: 'DELETE',
})
},
}
export const inventoryApi = {
getInventoryItems() {
return requestJson('/api/inventoryitems')
},
getInventoryItem(id) {
return requestJson(`/api/inventoryitems/${id}`)
},
createInventoryItem(payload) {
return requestJson('/api/inventoryitems', {
method: 'POST',
body: payload,
})
},
updateInventoryItem(id, payload) {
return requestJson(`/api/inventoryitems/${id}`, {
method: 'PUT',
body: payload,
})
},
deleteInventoryItem(id) {
return requestJson(`/api/inventoryitems/${id}`, {
method: 'DELETE',
})
},
}
export const searchApi = {
searchLocations(query) {
return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`)
},
searchItems(query) {
return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`)
},
}
export const householdsApi = {
getHouseholds() {
return requestJson('/api/households')
},
createHousehold(payload) {
return requestJson('/api/households', {
method: 'POST',
body: payload,
})
},
updateHousehold(id, payload) {
return requestJson(`/api/households/${id}`, {
method: 'PUT',
body: payload,
})
},
inviteHouseholdMember(id, payload) {
return requestJson(`/api/households/${id}/invite`, {
method: 'POST',
body: payload,
})
},
leaveHousehold(id) {
return requestJson(`/api/households/${id}/leave`, {
method: 'DELETE',
})
},
}
export const usersApi = {
getUsers() {
return requestJson('/api/users')
},
}

View File

@@ -11,9 +11,9 @@
gap: 12px;
font-family: inherit;
padding: 10px;
border: 1px solid #e6e6e6;
border: 1px solid var(--color-border);
border-radius: 8px;
background: #fff;
background: var(--color-surface);
}
.item-component__img {
@@ -38,7 +38,7 @@
.item-component__line {
margin: 0;
padding: 0;
color: #222;
color: var(--color-text-strong);
}
.item-component__line--title {
@@ -46,12 +46,12 @@
}
.item-component__line--subtitle {
color: #555;
color: var(--color-text-soft);
font-size: 0.95em;
}
.item-component__line--desc {
color: #666;
color: var(--color-text-muted);
font-size: 0.9em;
}
@@ -60,8 +60,16 @@
box-sizing: border-box;
padding: 6px;
font: inherit;
border: 1px solid #ccc;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
background: var(--color-input-bg);
color: var(--color-text);
}
.item-component__textarea:focus {
outline: none;
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring);
}
@media (max-width: 900px) {

View File

@@ -1,25 +1,211 @@
nav ul {
.navbar {
margin-bottom: 12px;
}
.nav-list {
list-style-type: none;
margin: 0;
padding: 0;
background-color: #333333;
background-color: var(--nav-bg);
display: flex;
justify-content: center;
border-radius: 8px;
align-items: center;
flex-wrap: wrap;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-soft);
}
nav ul li a {
.nav-menu-toggle {
display: none;
}
.nav-list li a {
display: block;
color: white;
padding: 14px 16px;
color: var(--nav-text);
padding: 10px 16px;
margin: 4px;
border-radius: 8px;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease;
}
nav ul li a:hover {
background-color: #111111;
.nav-list li a:hover {
background-color: var(--nav-bg-hover);
}
nav ul li a.active {
background-color: #111111;
.nav-list li a.active {
background-color: var(--nav-bg-hover);
border-radius: 8px;
}
.nav-status {
margin-left: auto;
color: var(--nav-text);
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
font-size: 14px;
}
.nav-status__text {
white-space: nowrap;
}
.nav-button {
border: 1px solid var(--nav-button-border);
background: transparent;
color: var(--nav-text);
border-radius: 999px;
padding: 8px 12px;
cursor: pointer;
font: inherit;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.nav-button:hover {
background-color: var(--nav-button-hover);
}
.nav-theme-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px;
font-weight: 700;
}
.nav-theme-toggle__icon {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
opacity: 0.72;
transition: background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.nav-theme-toggle__icon.is-active {
background-color: var(--nav-button-hover);
opacity: 1;
}
.nav-theme-toggle__icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (max-width: 900px) {
.nav-status {
width: 100%;
justify-content: center;
margin-left: 0;
border-top: 1px solid var(--nav-divider);
}
}
@media (max-width: 760px) {
.nav-menu-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 0;
background: var(--nav-bg);
color: var(--nav-text);
border-radius: 12px;
padding: 14px 16px;
font: inherit;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-soft);
}
.nav-menu-toggle__label {
letter-spacing: 0.02em;
}
.nav-menu-toggle__icon {
width: 24px;
height: 18px;
display: inline-flex;
flex-direction: column;
justify-content: space-between;
flex-shrink: 0;
}
.nav-menu-toggle__icon span {
width: 100%;
height: 2px;
background: currentColor;
border-radius: 999px;
transition: transform 0.2s ease, opacity 0.2s ease;
transform-origin: center;
}
.nav-menu-toggle__icon.is-open span:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
.nav-menu-toggle__icon.is-open span:nth-child(2) {
opacity: 0;
}
.nav-menu-toggle__icon.is-open span:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
.nav-list {
max-height: 0;
opacity: 0;
margin-top: 10px;
flex-direction: column;
align-items: stretch;
pointer-events: none;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.nav-list--open {
max-height: 640px;
opacity: 1;
pointer-events: auto;
}
.nav-list li {
width: 100%;
}
.nav-list li + li {
border-top: 1px solid var(--nav-divider);
}
.nav-list li a {
margin: 0;
padding: 14px 18px;
border-radius: 0;
}
.nav-list li a.active {
border-radius: 0;
}
.nav-status {
padding: 16px 18px;
justify-content: flex-start;
flex-wrap: wrap;
gap: 12px;
border-top: 1px solid var(--nav-divider);
}
.nav-status__text {
width: 100%;
}
}

View File

@@ -1,13 +1,114 @@
import { NavLink } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../../context/AuthContext.jsx'
import './Navbar.css'
function Navbar() {
const MOBILE_NAV_BREAKPOINT = 760
function SunIcon() {
return (
<nav>
<ul>
<li><NavLink to="/">Homepage</NavLink></li>
<li><NavLink to="/search">Search</NavLink></li>
<li><NavLink to="/barcode">Barcode Scanner</NavLink></li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5V5M12 19V21.5M4.93 4.93L6.7 6.7M17.3 17.3l1.77 1.77M2.5 12H5M19 12h2.5M4.93 19.07L6.7 17.3M17.3 6.7l1.77-1.77" />
</svg>
)
}
function MoonIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 9 9 0 1 0 20 14.5Z" />
</svg>
)
}
function Navbar({ theme, onToggleTheme }) {
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
const location = useLocation()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const nextThemeLabel = theme === 'dark' ? 'Switch to light' : 'Switch to dark'
useEffect(() => {
setIsMenuOpen(false)
}, [location.pathname, isAuthenticated])
useEffect(() => {
if (typeof window === 'undefined') {
return undefined
}
function handleResize() {
if (window.innerWidth > MOBILE_NAV_BREAKPOINT) {
setIsMenuOpen(false)
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
function closeMenu() {
setIsMenuOpen(false)
}
return (
<nav className="navbar" aria-label="Primary navigation">
<button
type="button"
className="nav-menu-toggle"
aria-expanded={isMenuOpen}
aria-controls="primary-navigation"
onClick={() => setIsMenuOpen(currentValue => !currentValue)}
>
<span className="nav-menu-toggle__label">Menu</span>
<span className={`nav-menu-toggle__icon ${isMenuOpen ? 'is-open' : ''}`} aria-hidden="true">
<span />
<span />
<span />
</span>
</button>
<ul id="primary-navigation" className={`nav-list ${isMenuOpen ? 'nav-list--open' : ''}`}>
{isAuthenticated ? (
<>
<li><NavLink to="/" onClick={closeMenu}>Homepage</NavLink></li>
<li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li>
<li><NavLink to="/inventory" onClick={closeMenu}>Inventory</NavLink></li>
<li><NavLink to="/search" onClick={closeMenu}>Search</NavLink></li>
<li><NavLink to="/barcode" onClick={closeMenu}>Barcode Scanner</NavLink></li>
{isSiteAdmin && <li><NavLink to="/users" onClick={closeMenu}>Users</NavLink></li>}
</>
) : (
<li><NavLink to="/" onClick={closeMenu}>Login</NavLink></li>
)}
<li className="nav-status">
<button
type="button"
className="nav-button nav-theme-toggle"
onClick={onToggleTheme}
aria-pressed={theme === 'dark'}
aria-label={`${nextThemeLabel} mode`}
title={`${nextThemeLabel} mode`}
>
<span className={`nav-theme-toggle__icon ${theme === 'light' ? 'is-active' : ''}`}>
<SunIcon />
</span>
<span className={`nav-theme-toggle__icon ${theme === 'dark' ? 'is-active' : ''}`}>
<MoonIcon />
</span>
</button>
<span className="nav-status__text">{isAuthenticated ? user?.email ?? 'Signed in' : 'Signed out'}</span>
{isAuthenticated && (
<button type="button" className="nav-button" onClick={() => {
closeMenu()
logout()
}}>
Sign out
</button>
)}
</li>
</ul>
</nav>
)

View File

@@ -2,7 +2,8 @@
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
font-family: inherit;
color: var(--color-text);
gap: 20px;
}
@@ -13,7 +14,7 @@ circle {
}
.legend {
font-family: sans-serif;
font-family: inherit;
display: flex;
flex-direction: column;
gap: 8px;

View File

@@ -20,7 +20,7 @@ function PieChart({ segments }) {
return (
<div className="chart-container">
<svg width="350" height="350" viewBox="0 0 42 42">
<circle cx="21" cy="21" r={RADIUS} fill="transparent" stroke="#f2f2f2" strokeWidth="20" />
<circle cx="21" cy="21" r={RADIUS} fill="transparent" stroke="var(--color-chart-track)" strokeWidth="20" />
{computedSegments.map((seg, i) => (
<circle
key={i}

View File

@@ -0,0 +1,81 @@
import { createContext, useContext, useEffect, useState } from 'react'
import {
authApi,
getStoredSession,
profileApi,
saveSession,
subscribeToSessionChanges,
} from '../api/client.js'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [session, setSession] = useState(() => getStoredSession())
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
useEffect(() => subscribeToSessionChanges(setSession), [])
useEffect(() => {
let cancelled = false
async function syncStoredSession() {
const existingSession = getStoredSession()
if (!existingSession) {
if (!cancelled) {
setInitializing(false)
}
return
}
try {
const profile = await profileApi.getProfile()
if (!cancelled) {
saveSession({
...existingSession,
user: profile,
})
}
} catch {
// The API client already clears invalid sessions after a failed refresh.
} finally {
if (!cancelled) {
setInitializing(false)
}
}
}
syncStoredSession()
return () => {
cancelled = true
}
}, [])
const user = session?.user ?? null
const userRoles = Array.isArray(user?.roles) ? user.roles : []
const value = {
session,
user,
isAuthenticated: Boolean(session?.accessToken),
isSiteAdmin: userRoles.includes('Admin'),
initializing,
login: authApi.login,
register: authApi.register,
logout: authApi.logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const value = useContext(AuthContext)
if (!value) {
throw new Error('useAuth must be used inside an AuthProvider.')
}
return value
}

View File

@@ -2,12 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './context/AuthContext.jsx'
import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,86 @@
.admin-summary-panel {
margin-bottom: 20px;
}
.admin-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.admin-grid {
align-items: start;
}
.admin-list-panel {
margin-top: 20px;
}
.admin-household-row {
align-items: stretch;
}
.admin-household-copy {
flex: 1;
}
.admin-household-header {
display: grid;
gap: 8px;
}
.admin-badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-badge,
.member-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.admin-badge {
background: var(--status-info-bg);
color: var(--status-info-text);
border: 1px solid var(--status-info-border);
}
.member-badge {
background: var(--color-surface-muted);
color: var(--color-text-soft);
border: 1px solid var(--color-border-muted);
}
.member-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.member-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
}
.member-copy {
display: grid;
gap: 4px;
min-width: 0;
}
@media (max-width: 700px) {
.member-row {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,427 @@
import { useEffect, useState } from 'react'
import { householdsApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import { formatDate } from '../../utils/searchUtils.js'
import './AdminPage.css'
const EMPTY_HOUSEHOLD_FORM = {
name: '',
description: '',
}
function formatMemberName(member) {
const fullName = [member.firstName, member.lastName]
.filter(Boolean)
.join(' ')
return fullName || member.email || 'Unnamed member'
}
function AdminPage() {
const { isAuthenticated, isSiteAdmin } = useAuth()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [households, setHouseholds] = useState([])
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
const [editingHouseholdId, setEditingHouseholdId] = useState('')
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
const [inviteEmail, setInviteEmail] = useState('')
async function loadHouseholds(preferredSelectionId = '') {
const response = await householdsApi.getHouseholds()
const nextHouseholds = Array.isArray(response) ? response : []
setHouseholds(nextHouseholds)
setSelectedHouseholdId(currentSelectionId => {
const targetSelectionId = preferredSelectionId || currentSelectionId
if (nextHouseholds.some(household => household.id === targetSelectionId)) {
return targetSelectionId
}
return nextHouseholds[0]?.id ?? ''
})
setEditingHouseholdId(currentEditingId => (
nextHouseholds.some(household => household.id === currentEditingId)
? currentEditingId
: ''
))
}
useEffect(() => {
let cancelled = false
async function loadInitialHouseholds() {
if (!isAuthenticated) {
setHouseholds([])
setSelectedHouseholdId('')
setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
setInviteEmail('')
setErrorMessage('')
setStatusMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const response = await householdsApi.getHouseholds()
if (cancelled) return
const nextHouseholds = Array.isArray(response) ? response : []
setHouseholds(nextHouseholds)
setSelectedHouseholdId(nextHouseholds[0]?.id ?? '')
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialHouseholds()
return () => {
cancelled = true
}
}, [isAuthenticated])
const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null
const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null
const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
const canSubmitHouseholdForm = editingHouseholdId
? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin))
: isSiteAdmin
function resetHouseholdEditor() {
setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
}
async function handleHouseholdSubmit(event) {
event.preventDefault()
const name = householdForm.name.trim()
if (!name) {
setErrorMessage('Household name is required.')
return
}
if (!canSubmitHouseholdForm) {
setErrorMessage(editingHouseholdId
? 'You can only edit households you administer.'
: 'Only site admins can create households.')
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const payload = {
name,
description: householdForm.description.trim() || null,
}
const result = editingHouseholdId
? await householdsApi.updateHousehold(editingHouseholdId, payload)
: await householdsApi.createHousehold(payload)
await loadHouseholds(result?.id ?? editingHouseholdId)
resetHouseholdEditor()
setStatusMessage(editingHouseholdId ? 'Household updated.' : 'Household created.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleInviteSubmit(event) {
event.preventDefault()
const email = inviteEmail.trim()
if (!selectedHouseholdId) {
setErrorMessage('Select a household before sending an invite.')
return
}
if (!email) {
setErrorMessage('Invite email is required.')
return
}
if (!canManageSelectedHousehold) {
setErrorMessage('You can only invite users to households you administer.')
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await householdsApi.inviteHouseholdMember(selectedHouseholdId, { email })
await loadHouseholds(selectedHouseholdId)
setInviteEmail('')
setStatusMessage('Invitation sent.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleLeaveHousehold(householdId) {
const confirmed = window.confirm('Leave this household?')
if (!confirmed) return
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await householdsApi.leaveHousehold(householdId)
await loadHouseholds(selectedHouseholdId === householdId ? '' : selectedHouseholdId)
if (selectedHouseholdId === householdId) {
setInviteEmail('')
resetHouseholdEditor()
}
setStatusMessage('Household left.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
return (
<>
<h2>Admin</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before using the household administration endpoints.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Syncing household data...</div>}
<section className="panel admin-summary-panel">
<h3>Household Summary</h3>
<div className="stats-grid admin-stats-grid">
<div className="stat-card">
<span className="stat-label">Households</span>
<strong>{households.length}</strong>
</div>
<div className="stat-card">
<span className="stat-label">Managed Households</span>
<strong>{households.filter(household => household.isCurrentUserHouseholdAdmin || isSiteAdmin).length}</strong>
</div>
<div className="stat-card">
<span className="stat-label">Members</span>
<strong>{households.reduce((count, household) => count + (household.members?.length ?? 0), 0)}</strong>
</div>
</div>
</section>
<div className="management-grid admin-grid">
<section className="panel">
<div className="section-heading">
<h3>{editingHouseholdId ? 'Edit Household' : 'Create Household'}</h3>
{editingHouseholdId && (
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
New household
</button>
)}
</div>
{!isSiteAdmin && !editingHouseholdId && (
<p className="form-note">
Only site admins can create households. Household admins can still edit an existing household from the list.
</p>
)}
<form className="editor-form" onSubmit={handleHouseholdSubmit}>
<div className="field-group">
<label htmlFor="household-name">Name</label>
<input
id="household-name"
type="text"
value={householdForm.name}
onChange={event => setHouseholdForm(current => ({ ...current, name: event.target.value }))}
placeholder="Main Household"
required
/>
</div>
<div className="field-group">
<label htmlFor="household-description">Description</label>
<textarea
id="household-description"
rows="3"
value={householdForm.description}
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
placeholder="Shared pantry and fridge access"
/>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary" disabled={!canSubmitHouseholdForm}>
{editingHouseholdId ? 'Update household' : 'Create household'}
</button>
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
Clear
</button>
</div>
</form>
</section>
<section className="panel">
<div className="section-heading">
<h3>Invite Member</h3>
<span className="subtle-text">{selectedHousehold?.name || 'No household selected'}</span>
</div>
{!selectedHousehold ? (
<div className="empty-state compact-empty-state">Select a household to invite a member.</div>
) : (
<>
{!canManageSelectedHousehold && (
<p className="form-note">
You can only invite users to households you administer.
</p>
)}
<form className="editor-form" onSubmit={handleInviteSubmit}>
<div className="field-group">
<label htmlFor="invite-email">Email</label>
<input
id="invite-email"
type="email"
value={inviteEmail}
onChange={event => setInviteEmail(event.target.value)}
placeholder="member@example.com"
disabled={!canManageSelectedHousehold}
required
/>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary" disabled={!canManageSelectedHousehold}>
Send invite
</button>
<button type="button" className="btn btn-secondary" onClick={() => setInviteEmail('')}>
Clear
</button>
</div>
</form>
</>
)}
</section>
</div>
<section className="panel admin-list-panel">
<div className="section-heading">
<h3>Households</h3>
<span className="subtle-text">{households.length} total</span>
</div>
{households.length === 0 ? (
<div className="empty-state compact-empty-state">No households were returned for this account yet.</div>
) : (
<div className="entity-list">
{households.map(household => {
const canManageHousehold = isSiteAdmin || household.isCurrentUserHouseholdAdmin
return (
<article
className={`entity-row admin-household-row ${selectedHouseholdId === household.id ? 'is-selected' : ''}`}
key={household.id}
>
<div className="entity-copy admin-household-copy">
<div className="admin-household-header">
<strong>{household.name}</strong>
<div className="admin-badge-row">
{household.isCurrentUserHouseholdAdmin && <span className="admin-badge">Household admin</span>}
{household.adminEmail && <span className="member-badge">Owner: {household.adminEmail}</span>}
</div>
</div>
<div className="entity-meta">{household.description || 'No description provided.'}</div>
<div className="entity-meta">Created: {formatDate(household.createdAt) || 'Not available'}</div>
<div className="member-list">
{(household.members ?? []).map(member => (
<div className="member-row" key={member.userId}>
<div className="member-copy">
<strong>{formatMemberName(member)}</strong>
<span className="entity-meta">{member.email}</span>
</div>
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>}
</div>
))}
</div>
</div>
<div className="entity-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => setSelectedHouseholdId(household.id)}
>
Select
</button>
{canManageHousehold && (
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setSelectedHouseholdId(household.id)
setEditingHouseholdId(household.id)
setHouseholdForm({
name: household.name ?? '',
description: household.description ?? '',
})
}}
>
Edit
</button>
)}
<button
type="button"
className="btn btn-danger"
onClick={() => handleLeaveHousehold(household.id)}
>
Leave
</button>
</div>
</article>
)
})}
</div>
)}
</section>
</>
)}
</>
)
}
export default AdminPage

View File

@@ -1,260 +1,353 @@
.scanner-container {
background: #f9f9f9;
padding: 30px;
border-radius: 8px;
max-width: 800px;
margin: 30px auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.barcode-page {
display: grid;
gap: 20px;
}
.barcode-workspace,
.barcode-management-grid {
display: grid;
gap: 20px;
}
.barcode-workspace {
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
align-items: start;
}
.barcode-management-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.scanner-panel,
.scan-summary-panel,
.editor-panel,
.match-panel {
display: grid;
gap: 16px;
}
.scanner-header {
text-align: center;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.scanner-header h3 {
color: #333;
margin-top: 0;
margin: 0;
text-align: left;
}
.scanner-header p {
color: #666;
font-size: 14px;
margin: 10px 0 0 0;
.scanner-header p,
.scan-summary-copy p,
.manual-entry-note,
.editor-empty-state p {
margin: 0;
color: var(--color-text-muted);
line-height: 1.6;
}
.mode-toggle {
display: flex;
display: inline-flex;
gap: 10px;
justify-content: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.mode-btn {
padding: 10px 20px;
border: 2px solid #ddd;
border-radius: 4px;
background: white;
padding: 10px 18px;
border: 1px solid var(--color-border-strong);
border-radius: 999px;
background: var(--color-input-bg);
color: var(--color-text);
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s;
font: inherit;
font-weight: 700;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.mode-btn.active {
background-color: #4CAF50;
color: white;
border-color: #4CAF50;
background: var(--button-primary-bg);
border-color: var(--button-primary-bg);
color: var(--button-primary-text);
}
.mode-btn:hover:not(.active) {
border-color: #4CAF50;
.camera-frame {
position: relative;
min-height: 380px;
border-radius: 24px;
overflow: hidden;
border: 1px solid var(--color-border);
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 35%),
linear-gradient(135deg, var(--color-surface-subtle), var(--color-surface-muted));
}
.camera-section {
display: none;
.camera-frame.is-active {
background: var(--color-video-bg);
}
.camera-section.active {
display: block;
}
.keyboard-section {
display: block;
}
#video {
.camera-target,
.camera-target video,
.camera-target canvas {
position: absolute;
inset: 0;
width: 100%;
border-radius: 4px;
background: #000;
margin-bottom: 15px;
height: 100%;
}
.camera-controls {
display: flex;
.camera-target video,
.camera-target canvas {
object-fit: cover;
}
.camera-placeholder {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 10px;
margin-bottom: 15px;
padding: 24px;
text-align: center;
color: var(--color-text-soft);
}
.camera-placeholder strong {
color: var(--color-text-strong);
font-size: 24px;
}
.camera-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.05));
}
.scan-window {
position: absolute;
inset: 16% 12%;
border-radius: 24px;
box-shadow: 0 0 0 999px rgba(15, 23, 42, 0.22);
border: 2px solid rgba(255, 255, 255, 0.85);
}
.scan-window::before,
.scan-window::after {
content: '';
position: absolute;
width: 46px;
height: 46px;
border-color: #ffffff;
border-style: solid;
}
.scan-window::before {
top: -2px;
left: -2px;
border-width: 4px 0 0 4px;
border-radius: 22px 0 0 0;
}
.scan-window::after {
right: -2px;
bottom: -2px;
border-width: 0 4px 4px 0;
border-radius: 0 0 22px 0;
}
.camera-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.barcode-input-wrapper {
margin-bottom: 20px;
}
.barcode-input-wrapper label {
display: block;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.barcode-input {
width: 100%;
padding: 15px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 18px;
font-family: 'Courier New', monospace;
box-sizing: border-box;
transition: all 0.3s;
}
.barcode-input:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
background-color: #fffef0;
}
.barcode-input.highlight {
border-color: #4CAF50;
background-color: #e8f5e9;
}
.scanner-status {
text-align: center;
padding: 15px;
border-radius: 4px;
background-color: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
font-size: 14px;
margin-bottom: 20px;
}
.scanner-status.active {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
background-color: #e8f5e9;
}
50% {
background-color: #c8e6c9;
}
}
.scanner-info {
background-color: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 4px;
padding: 15px;
margin-top: 20px;
font-size: 13px;
color: #1565c0;
}
.scanner-info h4 {
margin: 0 0 10px 0;
color: #0d47a1;
}
.scanner-info ul {
margin: 10px 0 0 0;
padding-left: 20px;
}
.scanner-info li {
margin: 5px 0;
padding: 12px 14px;
border-radius: 14px;
background: var(--status-info-bg);
border: 1px solid var(--status-info-border);
color: var(--status-info-text);
line-height: 1.5;
flex: 1;
min-width: 240px;
}
.console-hint {
background-color: #fff3e0;
border: 1px solid #ffe0b2;
border-radius: 4px;
padding: 12px;
margin-top: 15px;
font-size: 13px;
color: #e65100;
}
.console-hint strong {
display: block;
margin-bottom: 5px;
color: #d84315;
}
.button-group {
.camera-controls,
.manual-entry-actions,
.quick-actions,
.lookup-result-actions {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.button-group .btn {
flex: 1;
min-width: 120px;
}
.open-console {
background-color: #2196F3;
color: white;
}
.open-console:hover {
background-color: #0b7dda;
}
.error-message {
background-color: #ffebee;
border: 1px solid #f5a5ac;
border-radius: 4px;
padding: 12px;
color: #c62828;
margin-bottom: 15px;
}
.error-message.show {
display: block;
}
.detected-barcode {
background-color: #e8f5e9;
border: 2px solid #4CAF50;
border-radius: 4px;
padding: 15px;
text-align: center;
margin-top: 15px;
}
.detected-barcode.show {
display: block;
}
.detected-barcode-value {
font-family: 'Courier New', monospace;
font-size: 20px;
font-weight: bold;
color: #2e7d32;
}
@media (max-width: 600px) {
.scanner-container {
padding: 20px;
margin: 20px 10px;
.manual-entry-panel {
display: grid;
gap: 14px;
align-content: start;
}
.barcode-input {
font-size: 16px;
padding: 12px;
font-family: Consolas, 'Courier New', monospace;
font-size: 18px;
}
.button-group {
flex-direction: column;
.scan-summary-card {
display: grid;
gap: 12px;
padding: 18px;
border-radius: 18px;
background:
radial-gradient(circle at top right, rgba(76, 175, 80, 0.18), transparent 32%),
linear-gradient(180deg, var(--color-surface-subtle), var(--color-surface));
border: 1px solid var(--color-border);
}
.button-group .btn {
width: 100%;
.scan-summary-label {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.mode-toggle {
.scan-summary-value {
font-family: Consolas, 'Courier New', monospace;
font-size: clamp(28px, 4vw, 40px);
line-height: 1.1;
color: var(--color-text-strong);
word-break: break-word;
}
.scan-summary-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mode-btn {
flex: 1;
min-width: 120px;
.scan-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
border: 1px solid transparent;
}
.scan-chip.is-success {
background: var(--status-success-bg);
border-color: var(--status-success-border);
color: var(--status-success-text);
}
.scan-chip.is-warning {
background: var(--status-warning-bg);
border-color: var(--status-warning-border);
color: var(--status-warning-text);
}
.scan-chip.is-neutral {
background: var(--color-surface-muted);
border-color: var(--color-border-muted);
color: var(--color-text-soft);
}
.lookup-results-grid,
.match-candidate-list {
display: grid;
gap: 12px;
}
.lookup-results-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.lookup-result-card,
.match-candidate-row,
.editor-empty-state {
border-radius: 16px;
border: 1px solid var(--color-border-muted);
background: var(--color-surface-muted);
}
.lookup-result-card {
display: grid;
gap: 10px;
padding: 16px;
}
.lookup-result-card.is-selected {
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
}
.lookup-result-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.lookup-result-meta {
color: var(--color-text-soft);
line-height: 1.5;
}
.editor-empty-state {
padding: 22px;
}
.match-search-field {
max-width: 520px;
}
.match-candidate-row {
padding: 16px;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.match-candidate-copy {
display: grid;
gap: 6px;
min-width: 0;
}
@media (max-width: 1024px) {
.barcode-workspace,
.barcode-management-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.scanner-header,
.camera-toolbar,
.match-candidate-row {
flex-direction: column;
align-items: stretch;
}
.camera-frame {
min-height: 300px;
}
.scan-window {
inset: 20% 8%;
}
.camera-controls .btn,
.manual-entry-actions .btn,
.quick-actions .btn,
.lookup-result-actions .btn,
.match-candidate-row .btn {
width: 100%;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,73 @@
/* HomePage-specific styles (if needed) */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.auth-grid {
align-items: start;
margin-top: 24px;
}
.stack-form {
display: grid;
gap: 16px;
}
.two-column-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stats-panel {
align-self: start;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 12px;
}
.stat-card {
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
border-radius: 12px;
padding: 16px;
display: grid;
gap: 8px;
min-width: 0;
}
.stat-card strong {
font-size: 26px;
}
.stat-label {
color: var(--color-text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.3;
overflow-wrap: anywhere;
}
.chart-panel {
grid-column: span 2;
}
.full-width-panel {
grid-column: 1 / -1;
}
@media (max-width: 900px) {
.chart-panel {
grid-column: span 1;
}
.two-column-grid,
.stats-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,42 +1,221 @@
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
import { useEffect, useState } from 'react'
import PieChart from '../../components/PieChart/PieChart.jsx'
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
import { inventoryApi, locationsApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
import './HomePage.css'
const demoItems = [
{ imgSrc: 'https://picsum.photos/seed/1/200/200', imgAlt: 'Chicken Salad', text1: 'Chicken Salad', text2: 'Fresh — 2 left', text3: 'Use within 3 days' },
{ imgSrc: 'https://picsum.photos/seed/2/200/200', imgAlt: 'Yogurt', text1: 'Greek Yogurt', text2: 'Chilled — 6 left', text3: 'Best before 5 days' },
{ imgSrc: 'https://picsum.photos/seed/3/200/200', imgAlt: 'Apples', text1: 'Red Apples', text2: 'Room temp — 12 left', text3: 'Keep away from moisture' },
]
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
function getRandomColor() {
const letters = '0123456789ABCDEF'
let color = '#'
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 10)]
}
return color
const INITIAL_LOGIN_FORM = {
email: '',
password: '',
}
const pieSegments = [
{ label: 'Pantry', value: 104, color: getRandomColor() },
{ label: 'Fridge', value: 30, color: getRandomColor() },
{ label: 'Freezer', value: 87, color: getRandomColor() },
]
function buildPieSegments(items, locations) {
const counts = new Map(locations.map(location => [location.name, 0]))
items.forEach(item => {
const locationName = item.location?.name || 'Unassigned'
counts.set(locationName, (counts.get(locationName) ?? 0) + 1)
})
return Array.from(counts.entries())
.filter(([, value]) => value > 0)
.map(([label, value], index) => ({
label,
value,
color: PIE_COLORS[index % PIE_COLORS.length],
}))
}
function sortByExpiry(items) {
return [...items].sort((left, right) => {
const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
return leftDate - rightDate
})
}
function HomePage() {
const { initializing, isAuthenticated, login } = useAuth()
const [loginForm, setLoginForm] = useState(INITIAL_LOGIN_FORM)
const [formError, setFormError] = useState('')
const [formStatus, setFormStatus] = useState('')
const [dashboardError, setDashboardError] = useState('')
const [dashboardLoading, setDashboardLoading] = useState(false)
const [locations, setLocations] = useState([])
const [items, setItems] = useState([])
useEffect(() => {
let cancelled = false
async function loadDashboard() {
if (!isAuthenticated) {
setLocations([])
setItems([])
setDashboardError('')
return
}
setDashboardLoading(true)
setDashboardError('')
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
if (!cancelled) {
setLocations(nextLocations)
setItems(nextItems)
}
} catch (error) {
if (!cancelled) {
setDashboardError(error.message)
}
} finally {
if (!cancelled) {
setDashboardLoading(false)
}
}
}
loadDashboard()
return () => {
cancelled = true
}
}, [isAuthenticated])
async function handleLoginSubmit(event) {
event.preventDefault()
setFormError('')
setFormStatus('')
try {
await login({
email: loginForm.email.trim(),
password: loginForm.password,
})
setFormStatus('Signed in successfully.')
setLoginForm(INITIAL_LOGIN_FORM)
} catch (error) {
setFormError(error.message)
}
}
const pieSegments = buildPieSegments(items, locations)
const expiringItems = sortByExpiry(items)
.filter(item => item.expiryDate)
.slice(0, 3)
const isLoginView = !isAuthenticated
return (
<>
<h2>Homepage</h2>
{!isLoginView && (
<>
<h2>Dashboard</h2>
<hr />
<h3>To use</h3>
<div className="item-component-grid">
{demoItems.map((item, i) => (
<ItemCard key={i} {...item} editable={false} />
))}
</>
)}
{initializing && <div className="status-banner status-banner--info">Restoring your session...</div>}
{formError && <div className="status-banner status-banner--error">{formError}</div>}
{formStatus && <div className="status-banner status-banner--success">{formStatus}</div>}
{dashboardLoading && !isLoginView && <div className="status-banner status-banner--info">Loading dashboard data...</div>}
{dashboardError && <div className="status-banner status-banner--error">{dashboardError}</div>}
{isLoginView ? (
<div className="dashboard-grid auth-grid">
<section className="panel">
<h3>Login</h3>
<form className="stack-form" onSubmit={handleLoginSubmit}>
<div className="field-group">
<label htmlFor="login-email">Email</label>
<input
id="login-email"
type="email"
value={loginForm.email}
onChange={event => setLoginForm(current => ({ ...current, email: event.target.value }))}
required
/>
</div>
<hr />
<div className="field-group">
<label htmlFor="login-password">Password</label>
<input
id="login-password"
type="password"
value={loginForm.password}
onChange={event => setLoginForm(current => ({ ...current, password: event.target.value }))}
required
/>
</div>
<button type="submit" className="btn btn-primary">Login</button>
</form>
</section>
</div>
) : (
<div className="dashboard-grid">
<section className="panel stats-panel">
<h3>Summary</h3>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-label">Locations</span>
<strong>{locations.length}</strong>
</div>
<div className="stat-card">
<span className="stat-label">Inventory Items</span>
<strong>{items.length}</strong>
</div>
<div className="stat-card">
<span className="stat-label">Expiring Soon</span>
<strong>{items.filter(item => {
const status = getExpiryStatus(item.expiryDate)
return status.status === 'Soon' || status.status === 'Today'
}).length}</strong>
</div>
</div>
</section>
<section className="panel chart-panel">
<h3>Inventory Breakdown</h3>
{pieSegments.length > 0 ? (
<PieChart segments={pieSegments} />
) : (
<div className="empty-state compact-empty-state">Create some inventory items to populate the chart.</div>
)}
</section>
<section className="panel full-width-panel">
<h3>Nearest Expiry Dates</h3>
{expiringItems.length === 0 ? (
<div className="empty-state compact-empty-state">No dated inventory items yet.</div>
) : (
<div className="item-component-grid">
{expiringItems.map(item => {
const expiryStatus = getExpiryStatus(item.expiryDate)
return (
<ItemCard
key={item.id}
text1={item.name}
text2={`${item.location?.name || 'No location'} | ${formatAmount(item.amount, item.amountType)}`}
text3={`${formatDate(item.expiryDate)} | ${expiryStatus.text}`}
editable={false}
/>
)
})}
</div>
)}
</section>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,89 @@
.management-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.editor-form {
display: grid;
gap: 16px;
}
.entity-list {
display: grid;
gap: 12px;
margin-top: 20px;
}
.entity-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding: 16px;
border: 1px solid var(--color-border-subtle);
border-radius: 12px;
background: var(--color-surface-subtle);
}
.entity-row.is-selected {
border-color: var(--color-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
}
.entity-copy {
display: grid;
gap: 6px;
min-width: 0;
}
.entity-meta {
color: var(--color-text-muted);
font-size: 14px;
line-height: 1.5;
}
.entity-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.item-detail-card {
margin-top: 20px;
padding: 16px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
display: grid;
gap: 6px;
}
.inventory-list-panel {
margin-top: 20px;
}
.compact-empty-state {
margin: 0;
padding: 20px;
}
.form-note {
margin: 0;
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 700px) {
.entity-row {
flex-direction: column;
}
.entity-actions {
width: 100%;
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,527 @@
import { useEffect, useState } from 'react'
import {
inventoryApi,
locationsApi,
} from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import {
buildInventoryPayload,
createItemForm,
mapItemToForm,
} from '../../utils/inventoryItemUtils.js'
import {
formatAmount,
formatDate,
getExpiryStatus,
} from '../../utils/searchUtils.js'
import './InventoryPage.css'
const EMPTY_LOCATION_FORM = {
name: '',
description: '',
}
function InventoryPage() {
const { isAuthenticated } = useAuth()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [locations, setLocations] = useState([])
const [items, setItems] = useState([])
const [editingLocationId, setEditingLocationId] = useState('')
const [editingItemId, setEditingItemId] = useState('')
const [selectedItem, setSelectedItem] = useState(null)
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
const [itemForm, setItemForm] = useState(createItemForm())
async function loadPageData() {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
setLocations(nextLocations)
setItems(nextItems)
}
useEffect(() => {
let cancelled = false
async function loadInitialData() {
if (!isAuthenticated) {
setLocations([])
setItems([])
setSelectedItem(null)
setEditingItemId('')
setEditingLocationId('')
setLocationForm(EMPTY_LOCATION_FORM)
setItemForm(createItemForm())
setErrorMessage('')
setStatusMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
if (!cancelled) {
setLocations(nextLocations)
setItems(nextItems)
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialData()
return () => {
cancelled = true
}
}, [isAuthenticated])
function resetLocationEditor() {
setEditingLocationId('')
setLocationForm(EMPTY_LOCATION_FORM)
}
function resetItemEditor() {
setEditingItemId('')
setSelectedItem(null)
setItemForm(createItemForm())
}
async function handleLocationSubmit(event) {
event.preventDefault()
const name = locationForm.name.trim()
if (!name) {
setErrorMessage('Location name is required.')
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const payload = {
name,
description: locationForm.description.trim() || null,
}
if (editingLocationId) {
await locationsApi.updateLocation(editingLocationId, payload)
setStatusMessage('Location updated.')
} else {
await locationsApi.createLocation(payload)
setStatusMessage('Location created.')
}
await loadPageData()
resetLocationEditor()
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleDeleteLocation(locationId) {
const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.')
if (!confirmed) return
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await locationsApi.deleteLocation(locationId)
await loadPageData()
if (editingLocationId === locationId) {
resetLocationEditor()
}
setItemForm(currentForm => (
currentForm.locationId === locationId
? { ...currentForm, locationId: '' }
: currentForm
))
setStatusMessage('Location deleted.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleEditItem(itemId) {
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const item = await inventoryApi.getInventoryItem(itemId)
setSelectedItem(item)
setEditingItemId(item.id)
setItemForm(mapItemToForm(item))
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleItemSubmit(event) {
event.preventDefault()
const name = itemForm.name.trim()
if (!name) {
setErrorMessage('Item name is required.')
return
}
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
let itemId = editingItemId
if (editingItemId) {
await inventoryApi.updateInventoryItem(editingItemId, buildInventoryPayload(itemForm, true))
setStatusMessage('Inventory item updated.')
} else {
const created = await inventoryApi.createInventoryItem(buildInventoryPayload(itemForm))
itemId = created.id
setStatusMessage('Inventory item created.')
}
await loadPageData()
if (itemId) {
const freshItem = await inventoryApi.getInventoryItem(itemId)
setSelectedItem(freshItem)
setEditingItemId(freshItem.id)
setItemForm(mapItemToForm(freshItem))
}
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function handleDeleteItem(itemId) {
const confirmed = window.confirm('Delete this inventory item?')
if (!confirmed) return
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
await inventoryApi.deleteInventoryItem(itemId)
await loadPageData()
if (editingItemId === itemId) {
resetItemEditor()
}
setStatusMessage('Inventory item deleted.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
return (
<>
<h2>Inventory</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before using the protected inventory and location endpoints.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Syncing with the API...</div>}
<div className="management-grid">
<section className="panel">
<div className="section-heading">
<h3>Locations</h3>
{editingLocationId && (
<button type="button" className="btn btn-secondary" onClick={resetLocationEditor}>
New location
</button>
)}
</div>
<form className="editor-form" onSubmit={handleLocationSubmit}>
<div className="field-group">
<label htmlFor="location-name">Name</label>
<input
id="location-name"
type="text"
value={locationForm.name}
onChange={event => setLocationForm(current => ({ ...current, name: event.target.value }))}
placeholder="Pantry"
required
/>
</div>
<div className="field-group">
<label htmlFor="location-description">Description</label>
<textarea
id="location-description"
rows="3"
value={locationForm.description}
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
placeholder="Main kitchen shelf"
/>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary">
{editingLocationId ? 'Update location' : 'Create location'}
</button>
<button type="button" className="btn btn-secondary" onClick={resetLocationEditor}>
Clear
</button>
</div>
</form>
<div className="entity-list">
{locations.length === 0 ? (
<div className="empty-state compact-empty-state">No locations yet.</div>
) : (
locations.map(location => (
<div
className={`entity-row ${editingLocationId === location.id ? 'is-selected' : ''}`}
key={location.id}
>
<div className="entity-copy">
<strong>{location.name}</strong>
<div className="entity-meta">{location.description || 'No description provided.'}</div>
</div>
<div className="entity-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setEditingLocationId(location.id)
setLocationForm({
name: location.name,
description: location.description ?? '',
})
}}
>
Edit
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => handleDeleteLocation(location.id)}
>
Delete
</button>
</div>
</div>
))
)}
</div>
</section>
<section className="panel">
<div className="section-heading">
<h3>{editingItemId ? 'Edit Item' : 'Add Item'}</h3>
{editingItemId && (
<button type="button" className="btn btn-secondary" onClick={resetItemEditor}>
New item
</button>
)}
</div>
<form className="editor-form" onSubmit={handleItemSubmit}>
<div className="form-grid">
<div className="field-group">
<label htmlFor="item-name">Name</label>
<input
id="item-name"
type="text"
value={itemForm.name}
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
placeholder="Whole Milk"
required
/>
</div>
<div className="field-group">
<label htmlFor="item-barcode">Barcode</label>
<input
id="item-barcode"
type="text"
value={itemForm.barcode}
onChange={event => setItemForm(current => ({ ...current, barcode: event.target.value }))}
placeholder="01234567890"
/>
</div>
<div className="field-group">
<label htmlFor="item-location">Location</label>
<select
id="item-location"
value={itemForm.locationId}
onChange={event => setItemForm(current => ({ ...current, locationId: event.target.value }))}
>
<option value="">No location</option>
{locations.map(location => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
</div>
<div className="field-group">
<label htmlFor="item-amount">Amount</label>
<input
id="item-amount"
type="number"
min="0"
step="0.1"
value={itemForm.amount}
onChange={event => setItemForm(current => ({ ...current, amount: event.target.value }))}
placeholder="2"
/>
</div>
<div className="field-group">
<label htmlFor="item-amount-type">Amount Type</label>
<input
id="item-amount-type"
type="text"
value={itemForm.amountType}
onChange={event => setItemForm(current => ({ ...current, amountType: event.target.value }))}
placeholder="litres"
/>
</div>
<div className="field-group">
<label htmlFor="item-expiry-date">Expiry Date</label>
<input
id="item-expiry-date"
type="date"
value={itemForm.expiryDate}
onChange={event => setItemForm(current => ({ ...current, expiryDate: event.target.value }))}
/>
</div>
<div className="field-group">
<label htmlFor="item-use-by-date">Use By Date</label>
<input
id="item-use-by-date"
type="date"
value={itemForm.useByDate}
onChange={event => setItemForm(current => ({ ...current, useByDate: event.target.value }))}
/>
</div>
</div>
<div className="button-row">
<button type="submit" className="btn btn-primary">
{editingItemId ? 'Update item' : 'Create item'}
</button>
<button type="button" className="btn btn-secondary" onClick={resetItemEditor}>
Clear
</button>
</div>
{editingItemId && (
<p className="form-note">
Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values.
</p>
)}
</form>
{selectedItem && (
<div className="item-detail-card">
<strong>Selected item</strong>
<div className="entity-meta">{selectedItem.name}</div>
<div className="entity-meta">{selectedItem.location?.name || 'No location'} | {formatAmount(selectedItem.amount, selectedItem.amountType)}</div>
<div className="entity-meta">Expiry: {formatDate(selectedItem.expiryDate) || 'Not set'}</div>
<div className="entity-meta">Use by: {formatDate(selectedItem.useByDate) || 'Not set'}</div>
<div className="entity-meta">Barcode: {selectedItem.barcode || 'Not set'}</div>
</div>
)}
</section>
</div>
<section className="panel inventory-list-panel">
<div className="section-heading">
<h3>Inventory Items</h3>
<span className="subtle-text">{items.length} total</span>
</div>
<div className="entity-list">
{items.length === 0 ? (
<div className="empty-state compact-empty-state">No inventory items yet.</div>
) : (
items.map(item => {
const expiryStatus = getExpiryStatus(item.expiryDate)
return (
<div
className={`entity-row ${editingItemId === item.id ? 'is-selected' : ''}`}
key={item.id}
>
<div className="entity-copy">
<strong>{item.name}</strong>
<div className="entity-meta">{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</div>
<div className="entity-meta">Expiry: {formatDate(item.expiryDate) || 'Not set'} | {expiryStatus.text || 'No expiry date'}</div>
<div className="entity-meta">Barcode: {item.barcode || 'Not set'}</div>
</div>
<div className="entity-actions">
<button type="button" className="btn btn-secondary" onClick={() => handleEditItem(item.id)}>
Edit
</button>
<button type="button" className="btn btn-danger" onClick={() => handleDeleteItem(item.id)}>
Delete
</button>
</div>
</div>
)
})
)}
</div>
</section>
</>
)}
</>
)
}
export default InventoryPage

View File

@@ -1,7 +1,4 @@
.search-container {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
@@ -19,23 +16,25 @@
.form-group label {
font-weight: bold;
color: #333;
color: var(--color-text-strong);
}
.form-group input,
.form-group select {
padding: 10px;
border: 1px solid #ddd;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
font-family: inherit;
background: var(--color-input-bg);
color: inherit;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
border-color: var(--button-primary-bg);
box-shadow: 0 0 5px var(--button-primary-ring);
}
.quantity-range,
@@ -53,19 +52,40 @@
.search-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.location-results {
margin-bottom: 24px;
}
.location-results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.location-result-card {
background: var(--color-surface-muted);
border: 1px solid var(--color-border-subtle);
border-radius: 12px;
padding: 16px;
display: grid;
gap: 6px;
color: var(--color-text-soft);
}
.results-info {
text-align: center;
margin-bottom: 20px;
font-size: 16px;
color: #666;
color: var(--color-text-muted);
}
.no-results {
text-align: center;
padding: 40px;
color: #999;
color: var(--color-text-muted);
font-size: 18px;
}
@@ -79,18 +99,18 @@
}
.expiry-fresh {
background-color: #e8f5e9;
color: #2e7d32;
background-color: var(--status-success-bg);
color: var(--status-success-text);
}
.expiry-soon {
background-color: #fff3e0;
color: #e65100;
background-color: var(--status-warning-bg);
color: var(--status-warning-text);
}
.expiry-expired {
background-color: #ffebee;
color: #c62828;
background-color: var(--status-error-bg);
color: var(--status-error-text);
}
.pagination-controls {
@@ -110,21 +130,22 @@
.pagination-size label {
font-weight: bold;
color: #333;
color: var(--color-text-strong);
}
.pagination-size select {
padding: 8px 12px;
border: 1px solid #ddd;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
background-color: white;
background-color: var(--color-input-bg);
color: inherit;
cursor: pointer;
}
.pagination-size select:focus {
outline: none;
border-color: #4CAF50;
border-color: var(--button-primary-bg);
}
.pagination-nav {
@@ -135,9 +156,9 @@
.pagination-nav button {
padding: 8px 12px;
border: 1px solid #ddd;
background-color: #f5f5f5;
color: #333;
border: 1px solid var(--color-border);
background-color: var(--color-surface-muted);
color: var(--color-text-strong);
border-radius: 4px;
cursor: pointer;
font-weight: bold;
@@ -145,9 +166,9 @@
}
.pagination-nav button:hover:not(:disabled) {
background-color: #4CAF50;
color: white;
border-color: #4CAF50;
background-color: var(--button-primary-bg);
color: var(--button-primary-text);
border-color: var(--button-primary-bg);
}
.pagination-nav button:disabled {
@@ -157,12 +178,15 @@
.pagination-info {
font-size: 14px;
color: #666;
color: var(--color-text-muted);
min-width: 120px;
text-align: center;
}
@media (max-width: 600px) {
.quantity-range,
.date-range,
.search-buttons,
.pagination-controls {
flex-direction: column;
gap: 15px;

View File

@@ -1,58 +1,176 @@
import { useState, useMemo } from 'react'
import { useEffect, useState } from 'react'
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
import { searchInventory, getLocations, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
import {
inventoryApi,
locationsApi,
searchApi,
} from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import {
filterInventoryItems,
formatAmount,
formatDate,
getExpiryStatus,
} from '../../utils/searchUtils.js'
import { getPaginatedResults } from '../../utils/paginationUtils.js'
import './SearchPage.css'
function ExpiryBadge({ expiryDate }) {
const status = getExpiryStatus(expiryDate)
const badgeClass = status.status === 'Expired' ? 'expiry-expired'
: (status.status === 'Soon' || status.status === 'Today') ? 'expiry-soon'
const badgeClass = status.status === 'Expired'
? 'expiry-expired'
: (status.status === 'Soon' || status.status === 'Today')
? 'expiry-soon'
: 'expiry-fresh'
return (
<div className={`item-expiry-badge ${badgeClass}`}>
{formatDate(expiryDate)}
{formatDate(expiryDate) || 'No expiry date'}
</div>
)
}
function SearchPage() {
const locations = useMemo(() => getLocations(), [])
const { isAuthenticated } = useAuth()
const [searchName, setSearchName] = useState('')
const [location, setLocation] = useState('All')
const [minQuantity, setMinQuantity] = useState('0')
const [maxQuantity, setMaxQuantity] = useState('999')
const [locationId, setLocationId] = useState('')
const [minAmount, setMinAmount] = useState('0')
const [maxAmount, setMaxAmount] = useState('999')
const [minExpiryDate, setMinExpiryDate] = useState('')
const [maxExpiryDate, setMaxExpiryDate] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(15)
const [results, setResults] = useState(() => searchInventory())
const [locations, setLocations] = useState([])
const [matchingLocations, setMatchingLocations] = useState([])
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
function performSearch() {
const minQty = parseInt(minQuantity) || 0
const maxQty = parseInt(maxQuantity) || Infinity
useEffect(() => {
let cancelled = false
async function loadInitialData() {
if (!isAuthenticated) {
setLocations([])
setMatchingLocations([])
setResults([])
setErrorMessage('')
return
}
setLoading(true)
setErrorMessage('')
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
if (!cancelled) {
setLocations(nextLocations)
setMatchingLocations([])
setResults(nextItems)
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
loadInitialData()
return () => {
cancelled = true
}
}, [isAuthenticated])
async function performSearch() {
const parsedMinAmount = minAmount === '' ? 0 : Number(minAmount)
const parsedMaxAmount = maxAmount === '' ? Infinity : Number(maxAmount)
const safeMinAmount = Number.isFinite(parsedMinAmount) ? parsedMinAmount : 0
const safeMaxAmount = maxAmount === '' || !Number.isFinite(parsedMaxAmount) ? Infinity : parsedMaxAmount
if (minExpiryDate && maxExpiryDate && minExpiryDate > maxExpiryDate) {
alert('Start date cannot be after end date')
return
}
setCurrentPage(1)
setResults(searchInventory(searchName, location, minQty, maxQty, minExpiryDate, maxExpiryDate))
if (safeMinAmount > safeMaxAmount) {
alert('Minimum amount cannot be greater than maximum amount')
return
}
function resetForm() {
setLoading(true)
setErrorMessage('')
try {
const trimmedQuery = searchName.trim()
const [nextItems, nextMatchingLocations] = trimmedQuery
? await Promise.all([
searchApi.searchItems(trimmedQuery),
searchApi.searchLocations(trimmedQuery),
])
: await Promise.all([
inventoryApi.getInventoryItems(),
Promise.resolve([]),
])
const filteredItems = filterInventoryItems(nextItems, {
locationId,
minAmount: safeMinAmount,
maxAmount: safeMaxAmount,
minExpiryDate,
maxExpiryDate,
})
setCurrentPage(1)
setResults(filteredItems)
setMatchingLocations(nextMatchingLocations)
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
async function resetForm() {
setSearchName('')
setLocation('All')
setMinQuantity('0')
setMaxQuantity('999')
setLocationId('')
setMinAmount('0')
setMaxAmount('999')
setMinExpiryDate('')
setMaxExpiryDate('')
setCurrentPage(1)
setPageSize(15)
setResults(searchInventory())
setMatchingLocations([])
setErrorMessage('')
if (!isAuthenticated) {
setResults([])
return
}
setLoading(true)
try {
const [nextLocations, nextItems] = await Promise.all([
locationsApi.getLocations(),
inventoryApi.getInventoryItems(),
])
setLocations(nextLocations)
setResults(nextItems)
} catch (error) {
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
const paginationData = getPaginatedResults(results, currentPage, pageSize)
@@ -62,75 +180,92 @@ function SearchPage() {
<h2>Search Inventory</h2>
<hr />
<div className="search-container">
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before using the protected item and location search endpoints.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{loading && <div className="status-banner status-banner--info">Searching the API...</div>}
<div className="search-container panel">
<div className="search-form">
<div className="form-group">
<label htmlFor="search-name">Item Name:</label>
<label htmlFor="search-name">Item / Location / Barcode Search</label>
<input
type="text"
id="search-name"
placeholder="e.g., Milk, Pasta, Chicken..."
placeholder="milk, fridge, 01234567890..."
autoComplete="off"
value={searchName}
onChange={e => setSearchName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') performSearch() }}
onChange={event => setSearchName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
performSearch()
}
}}
/>
</div>
<div className="form-group">
<label htmlFor="search-location">Storage Location:</label>
<select id="search-location" value={location} onChange={e => setLocation(e.target.value)}>
{locations.map(loc => (
<option key={loc} value={loc}>{loc}</option>
<label htmlFor="search-location">Storage location</label>
<select id="search-location" value={locationId} onChange={event => setLocationId(event.target.value)}>
<option value="">All locations</option>
{locations.map(location => (
<option key={location.id} value={location.id}>{location.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Quantity Range:</label>
<label>Amount range</label>
<div className="quantity-range">
<div className="form-group">
<label htmlFor="min-quantity" style={{ fontSize: '12px' }}>Min:</label>
<label htmlFor="min-amount" style={{ fontSize: '12px' }}>Min</label>
<input
type="number"
id="min-quantity"
id="min-amount"
min="0"
value={minQuantity}
onChange={e => setMinQuantity(e.target.value)}
step="0.1"
value={minAmount}
onChange={event => setMinAmount(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="max-quantity" style={{ fontSize: '12px' }}>Max:</label>
<label htmlFor="max-amount" style={{ fontSize: '12px' }}>Max</label>
<input
type="number"
id="max-quantity"
id="max-amount"
min="0"
value={maxQuantity}
onChange={e => setMaxQuantity(e.target.value)}
step="0.1"
value={maxAmount}
onChange={event => setMaxAmount(event.target.value)}
/>
</div>
</div>
</div>
<div className="form-group">
<label>Expiry Date Range:</label>
<label>Expiry date range</label>
<div className="date-range">
<div className="form-group">
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start Date:</label>
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start date</label>
<input
type="date"
id="min-expiry-date"
value={minExpiryDate}
onChange={e => setMinExpiryDate(e.target.value)}
onChange={event => setMinExpiryDate(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End Date:</label>
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End date</label>
<input
type="date"
id="max-expiry-date"
value={maxExpiryDate}
onChange={e => setMaxExpiryDate(e.target.value)}
onChange={event => setMaxExpiryDate(event.target.value)}
/>
</div>
</div>
@@ -143,7 +278,25 @@ function SearchPage() {
</div>
</div>
<div className="results-container">
{searchName.trim() && (
<div className="location-results panel">
<h3>Matching Locations</h3>
{matchingLocations.length === 0 ? (
<div className="empty-state">No locations matched that query.</div>
) : (
<div className="location-results-grid">
{matchingLocations.map(location => (
<div className="location-result-card" key={location.id}>
<strong>{location.name}</strong>
<div>{location.description || 'No description provided.'}</div>
</div>
))}
</div>
)}
</div>
)}
<div className="results-container panel">
<div className="results-info">
{results.length === 0
? 'No results'
@@ -157,7 +310,10 @@ function SearchPage() {
<select
id="page-size-select"
value={pageSize}
onChange={e => { setPageSize(parseInt(e.target.value)); setCurrentPage(1) }}
onChange={event => {
setPageSize(parseInt(event.target.value, 10))
setCurrentPage(1)
}}
>
<option value="15">15</option>
<option value="30">30</option>
@@ -169,9 +325,9 @@ function SearchPage() {
type="button"
className="btn"
disabled={paginationData.currentPage === 1}
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
>
Previous
Previous
</button>
<div className="pagination-info">
Page {paginationData.currentPage} of {paginationData.totalPages}
@@ -180,9 +336,9 @@ function SearchPage() {
type="button"
className="btn"
disabled={paginationData.currentPage === paginationData.totalPages}
onClick={() => setCurrentPage(p => p + 1)}
onClick={() => setCurrentPage(page => page + 1)}
>
Next
Next
</button>
</div>
</div>
@@ -194,11 +350,10 @@ function SearchPage() {
{paginationData.items.map(item => (
<ItemCard
key={item.id}
imgSrc={item.img}
imgAlt={item.name}
text1={item.name}
text2={`${item.location} ${item.quantity} ${item.unit}`}
text3={`Expires: ${formatDate(item.expiryDate)}`}
text2={`${item.location?.name || 'No location'} | ${formatAmount(item.amount, item.amountType)}`}
text3={`Barcode: ${item.barcode || 'Not set'} | Expires: ${formatDate(item.expiryDate) || 'Not set'}`}
editable={false}
>
<ExpiryBadge expiryDate={item.expiryDate} />
@@ -208,6 +363,8 @@ function SearchPage() {
)}
</div>
</>
)}
</>
)
}

View File

@@ -0,0 +1,47 @@
.users-page-panel {
margin-top: 24px;
}
.users-page-note {
margin-bottom: 20px;
}
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.user-card {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
}
.users-role-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.users-role-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--status-info-bg);
color: var(--status-info-text);
border: 1px solid var(--status-info-border);
}
.users-role-badge--empty {
background: var(--color-surface-subtle);
color: var(--color-text-muted);
border-color: var(--color-border-muted);
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { usersApi } from '../../api/client.js'
import { useAuth } from '../../context/AuthContext.jsx'
import './UsersPage.css'
function formatUserName(user) {
const fullName = [user.firstName, user.lastName]
.filter(Boolean)
.join(' ')
return fullName || user.email || 'Unnamed user'
}
function UsersPage() {
const { isAuthenticated, isSiteAdmin } = useAuth()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const [endpointUnavailable, setEndpointUnavailable] = useState(false)
async function loadUsers() {
setLoading(true)
setErrorMessage('')
setStatusMessage('')
try {
const response = await usersApi.getUsers()
setUsers(Array.isArray(response) ? response : [])
setEndpointUnavailable(false)
} catch (error) {
setUsers([])
if (error.status === 404) {
setEndpointUnavailable(true)
setStatusMessage('GET /api/users is not available in the current backend build yet.')
return
}
setErrorMessage(error.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!isAuthenticated || !isSiteAdmin) {
setUsers([])
setErrorMessage('')
setStatusMessage('')
setEndpointUnavailable(false)
return
}
loadUsers()
}, [isAuthenticated, isSiteAdmin])
return (
<>
<h2>Users</h2>
<hr />
{!isAuthenticated ? (
<div className="auth-required">
Sign in on the dashboard before using the users endpoint.
</div>
) : !isSiteAdmin ? (
<div className="auth-required">
The users page is reserved for accounts with the site admin role.
</div>
) : (
<>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--info">{statusMessage}</div>}
{loading && <div className="status-banner status-banner--info">Loading users...</div>}
<section className="panel users-page-panel">
<div className="section-heading">
<h3>User Directory</h3>
<button type="button" className="btn btn-secondary" onClick={loadUsers}>
Refresh users
</button>
</div>
<p className="form-note users-page-note">
This page is intentionally minimal. It is ready to consume a backend users endpoint as soon as that contract exists.
</p>
{endpointUnavailable ? (
<div className="empty-state compact-empty-state">
Add `GET /api/users` to the backend to populate this page.
</div>
) : users.length === 0 ? (
<div className="empty-state compact-empty-state">No users were returned.</div>
) : (
<div className="users-grid">
{users.map(user => {
const roles = Array.isArray(user.roles) ? user.roles : []
return (
<article className="user-card" key={user.id ?? user.email}>
<strong>{formatUserName(user)}</strong>
<div className="entity-meta">{user.email || 'No email provided.'}</div>
<div className="entity-meta">ID: {user.id || 'Not set'}</div>
<div className="users-role-list">
{roles.length === 0 ? (
<span className="users-role-badge users-role-badge--empty">No roles</span>
) : (
roles.map(role => (
<span className="users-role-badge" key={role}>{role}</span>
))
)}
</div>
</article>
)
})}
</div>
)}
</section>
</>
)}
</>
)
}
export default UsersPage

View File

@@ -0,0 +1,63 @@
import { toDateInputValue } from './searchUtils.js'
export const EMPTY_ITEM_FORM = {
name: '',
barcode: '',
expiryDate: '',
useByDate: '',
amount: '',
amountType: '',
locationId: '',
}
export function normalizeBarcode(value) {
return String(value ?? '').trim()
}
export function createItemForm(barcode = '') {
return {
...EMPTY_ITEM_FORM,
barcode: normalizeBarcode(barcode),
}
}
export function buildInventoryPayload(form, includeBlankText = false) {
const payload = {
name: form.name.trim(),
}
const barcode = normalizeBarcode(form.barcode)
const amountType = form.amountType.trim()
if (barcode || includeBlankText) payload.barcode = barcode
if (amountType || includeBlankText) payload.amountType = amountType
if (form.expiryDate) payload.expiryDate = form.expiryDate
if (form.useByDate) payload.useByDate = form.useByDate
if (form.amount !== '') payload.amount = Number(form.amount)
if (form.locationId) payload.locationId = form.locationId
return payload
}
export function mapItemToForm(item) {
return {
name: item.name ?? '',
barcode: item.barcode ?? '',
expiryDate: toDateInputValue(item.expiryDate),
useByDate: toDateInputValue(item.useByDate),
amount: item.amount == null ? '' : String(item.amount),
amountType: item.amountType ?? '',
locationId: item.locationId ?? item.location?.id ?? '',
}
}
export function findItemsByBarcode(items, barcode) {
const normalizedBarcode = normalizeBarcode(barcode)
if (!normalizedBarcode) {
return []
}
return items.filter(item => normalizeBarcode(item.barcode) === normalizedBarcode)
}

View File

@@ -1,18 +1,32 @@
import { inventoryData } from '../data/inventory.js'
export function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
const normalizedDate = toDateInputValue(dateStr)
const date = new Date(`${normalizedDate}T00:00:00`)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
export function toDateInputValue(dateStr) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
}
export function getExpiryStatus(expiryDate) {
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }
const today = new Date()
today.setHours(0, 0, 0, 0)
const expiry = new Date(expiryDate + 'T00:00:00')
const normalizedDate = toDateInputValue(expiryDate)
const expiry = new Date(`${normalizedDate}T00:00:00`)
if (Number.isNaN(expiry.getTime())) {
return { status: 'Unknown', color: '#999', text: '' }
}
const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24))
if (daysUntilExpiry < 0) {
@@ -26,24 +40,40 @@ export function getExpiryStatus(expiryDate) {
}
}
export function searchInventory(searchName = '', selectedLocation = 'All', minQuantity = 0, maxQuantity = Infinity, minExpiryDate = '', maxExpiryDate = '') {
return inventoryData.filter(item => {
const nameMatch = item.name.toLowerCase().includes(searchName.toLowerCase())
const locationMatch = selectedLocation === 'All' || item.location === selectedLocation
const quantityMatch = item.quantity >= minQuantity && item.quantity <= maxQuantity
export function formatAmount(amount, amountType = '') {
if (amount == null || amount === '') return 'Amount not set'
let expiryMatch = true
if (minExpiryDate || maxExpiryDate) {
const itemExpiry = item.expiryDate
if (minExpiryDate && itemExpiry < minExpiryDate) expiryMatch = false
if (maxExpiryDate && itemExpiry > maxExpiryDate) expiryMatch = false
const unit = amountType?.trim() ?? ''
return unit ? `${amount} ${unit}` : String(amount)
}
return nameMatch && locationMatch && quantityMatch && expiryMatch
export function filterInventoryItems(items, filters = {}) {
const {
locationId = '',
minAmount = 0,
maxAmount = Infinity,
minExpiryDate = '',
maxExpiryDate = '',
} = filters
return items.filter(item => {
const itemLocationId = item.locationId ?? item.location?.id ?? ''
const locationMatch = !locationId || itemLocationId === locationId
const numericAmount = item.amount == null ? 0 : Number(item.amount)
const amountMatch = numericAmount >= minAmount && numericAmount <= maxAmount
const normalizedExpiryDate = toDateInputValue(item.expiryDate)
let expiryMatch = true
if (minExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate < minExpiryDate)) {
expiryMatch = false
}
if (maxExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate > maxExpiryDate)) {
expiryMatch = false
}
return locationMatch && amountMatch && expiryMatch
})
}
export function getLocations() {
const locations = [...new Set(inventoryData.map(item => item.location))]
return ['All', ...locations.sort()]
}