Public Access
F2 — Vim-style keyboard shortcuts (the audit's F2 / H7 finding).
New files:
- frontend/src/hooks/useKeyboardShortcuts.ts: lightweight global
handler. Supports both single keys ('/', '?', 'Escape') and
vim-style 2-key sequences ('g d', 'g r', 'g p', 'g s' for nav).
Sequence timeout is 1500ms; pending prefix is cleared on any
unrecognised key so typing 'g' alone is safe. Suppressed when
the user is typing in an input/textarea/select/contenteditable,
or when any modifier key (Ctrl/Cmd/Alt) is held — those chords
belong to the browser or other handlers. Uses a ref so the
listener is registered once and always sees the latest callbacks.
- frontend/src/hooks/useFocusSearch.ts: tiny CustomEvent bus.
requestFocusSearch() dispatches a 'mealplanner:focus-search'
event; useFocusSearchOnShortcut(ref) subscribes and focuses the
supplied input. The decoupling lets any page opt in without the
global handler needing to know the page's DOM.
- frontend/src/components/ShortcutHelpBanner.tsx: dismissible help
dialog that slides down under the nav when '?' is pressed.
Auto-dismisses after 6s; Escape also dismisses. role=dialog +
aria-label for screen readers; the kbd elements use the
<kbd> semantic for assistive tech.
Wired in App.tsx:
- New <GlobalShortcuts /> child of <BrowserRouter> calls
useKeyboardShortcuts with the 4 nav sequences, '/' →
requestFocusSearch(), and '?' → dispatch SHOW_SHORTCUT_HELP_EVENT.
- <ShortcutHelpBanner /> mounted inside the page wrapper (after
<main>).
Pantry and Recipes now call useFocusSearchOnShortcut with a
forwardRef attached to their top search inputs. Recipes's search
already debounced via handleSearch so focusing just selects the
existing text for the user to replace. Pantry's search is a plain
controlled input, same treatment.
Behaviour summary:
- g d / g r / g p / g s → navigate to the 4 main pages
- / → focus the search input on the current page (Pantry + Recipes
only — other pages have no search)
- ? → show the help banner
- All shortcuts are no-ops inside text-entry controls, so a user
typing 'p' into the pantry search box will not trigger navigation.
Build: tsc 0 errors, vite 0 errors. 5 files, +185/-3.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
|
|
import { mealPlannerApi } from '../api'
|
|
import { PANTRY_AISLES, type HomePantryItem, type Ingredient } from '../types'
|
|
import { Button } from '../components/ui/Button'
|
|
import { Card, CardBody } from '../components/ui/Card'
|
|
import { Input } from '../components/ui/Input'
|
|
import { Select } from '../components/ui/Select'
|
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
|
import { EmptyState } from '../components/ui/EmptyState'
|
|
import { showToast, showApiError } from '../lib/toast'
|
|
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
|
|
|
|
const AISLE_OPTIONS = [
|
|
{ value: '', label: 'Select aisle…' },
|
|
...PANTRY_AISLES.map(a => ({ value: a, label: a })),
|
|
]
|
|
|
|
export default function Pantry() {
|
|
const queryClient = useQueryClient()
|
|
const [showAddForm, setShowAddForm] = useState(false)
|
|
const [removeId, setRemoveId] = useState<string | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
useFocusSearchOnShortcut(searchInputRef)
|
|
|
|
/* ingredient name typed by user */
|
|
const [ingredientName, setIngredientName] = useState('')
|
|
const [quantity, setQuantity] = useState('')
|
|
const [unit, setUnit] = useState('')
|
|
const [aisle, setAisle] = useState('')
|
|
|
|
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
|
queryKey: ['pantry'],
|
|
queryFn: () => mealPlannerApi.pantry.list().then(r => r.data),
|
|
})
|
|
|
|
const { data: ingredients } = useQuery<Ingredient[]>({
|
|
queryKey: ['ingredients'],
|
|
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
|
|
})
|
|
|
|
function _ingredientMatchesName(ing: Ingredient, search: string): boolean {
|
|
const s = search.toLowerCase()
|
|
return ing.name.toLowerCase() === s || (ing.aliases ?? []).some(a => a.toLowerCase() === s)
|
|
}
|
|
|
|
/* fuzzy match existing ingredient */
|
|
const matchedIngredient = ingredientName.trim().length > 0
|
|
? ingredients?.find(
|
|
ing => _ingredientMatchesName(ing, ingredientName.trim())
|
|
)
|
|
: undefined
|
|
|
|
const addMutation = useMutation({
|
|
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
|
|
mealPlannerApi.pantry.add(data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
|
setShowAddForm(false)
|
|
setIngredientName('')
|
|
setQuantity('')
|
|
setUnit('')
|
|
setAisle('')
|
|
showToast.success('Item added to pantry')
|
|
},
|
|
})
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
|
showToast.success('Item removed')
|
|
setRemoveId(null)
|
|
},
|
|
})
|
|
|
|
async function handleRemove(item: HomePantryItem) {
|
|
if (!item.ingredient_id) {
|
|
showToast.error('Cannot remove: missing ingredient link')
|
|
return
|
|
}
|
|
setRemoveId(item.id)
|
|
try {
|
|
await mealPlannerApi.pantry.remove(item.id)
|
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
|
showToast.undo(
|
|
'Item removed',
|
|
async () => {
|
|
try {
|
|
await mealPlannerApi.pantry.add({
|
|
ingredient_id: item.ingredient_id!,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
})
|
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
|
showToast.success('Item restored')
|
|
} catch (err) {
|
|
showApiError(err, 'Failed to restore item')
|
|
}
|
|
}
|
|
)
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
} finally {
|
|
setRemoveId(null)
|
|
}
|
|
}
|
|
|
|
async function handleAdd() {
|
|
const name = ingredientName.trim()
|
|
if (!name) {
|
|
showToast.error('Please enter an ingredient name')
|
|
return
|
|
}
|
|
|
|
let ingredientId = matchedIngredient?.id
|
|
|
|
if (!ingredientId) {
|
|
try {
|
|
const res = await mealPlannerApi.recipes.createIngredient({
|
|
name,
|
|
aisle: aisle.trim() || undefined,
|
|
unit: unit.trim() || undefined,
|
|
})
|
|
const created = res.data as any
|
|
ingredientId = created?.id
|
|
// refresh ingredient list so next add sees it
|
|
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
return
|
|
}
|
|
}
|
|
|
|
if (!ingredientId) {
|
|
showToast.error('Could not resolve ingredient')
|
|
return
|
|
}
|
|
|
|
addMutation.mutate({
|
|
ingredient_id: ingredientId,
|
|
quantity: quantity ? parseFloat(quantity) : undefined,
|
|
unit: unit.trim() || undefined,
|
|
})
|
|
}
|
|
|
|
const filteredItems = pantryItems?.filter(item =>
|
|
item.ingredient?.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="flex justify-between items-center">
|
|
<Skeleton className="h-8 w-32" />
|
|
<Skeleton className="h-9 w-28" />
|
|
</div>
|
|
<Card>
|
|
<CardBody>
|
|
<SkeletonText lines={3} />
|
|
</CardBody>
|
|
</Card>
|
|
<Card>
|
|
<CardBody>
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex justify-between">
|
|
<Skeleton className="h-5 w-48" />
|
|
<Skeleton className="h-5 w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center">
|
|
<Package className="w-5 h-5 text-warning-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-surface-900">Home Pantry</h1>
|
|
<p className="text-sm text-surface-500">
|
|
{pantryItems?.length || 0} items
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
icon={<Plus className="w-4 h-4" />}
|
|
onClick={() => setShowAddForm(!showAddForm)}
|
|
variant={showAddForm ? 'secondary' : 'primary'}
|
|
>
|
|
{showAddForm ? 'Cancel' : 'Add Item'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Add Form */}
|
|
{showAddForm && (
|
|
<Card className="animate-slide-down">
|
|
<CardBody>
|
|
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
|
<div className="md:col-span-2">
|
|
<Input
|
|
label="Ingredient name *"
|
|
value={ingredientName}
|
|
onChange={(e) => setIngredientName(e.target.value)}
|
|
placeholder="e.g., Avocado"
|
|
required
|
|
/>
|
|
{matchedIngredient && (
|
|
<p className="mt-1 text-xs text-success-600">Matched existing ingredient ✓</p>
|
|
)}
|
|
</div>
|
|
<Input
|
|
label="Quantity"
|
|
type="number"
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(e.target.value)}
|
|
placeholder="e.g., 5"
|
|
/>
|
|
<Select
|
|
label="Unit"
|
|
value={unit}
|
|
onChange={(e) => setUnit(e.target.value)}
|
|
options={[
|
|
{ value: '', label: 'Select unit…' },
|
|
{ value: 'each', label: 'each' },
|
|
{ value: 'g', label: 'g' },
|
|
{ value: 'kg', label: 'kg' },
|
|
{ value: 'oz', label: 'oz' },
|
|
{ value: 'lb', label: 'lb' },
|
|
{ value: 'ml', label: 'ml' },
|
|
{ value: 'l', label: 'l' },
|
|
{ value: 'cup', label: 'cup' },
|
|
{ value: 'tbsp', label: 'tbsp' },
|
|
{ value: 'tsp', label: 'tsp' },
|
|
{ value: 'can', label: 'can' },
|
|
{ value: 'bunch', label: 'bunch' },
|
|
{ value: 'clove', label: 'clove' },
|
|
]}
|
|
/>
|
|
<Select
|
|
label="Aisle"
|
|
value={aisle}
|
|
onChange={(e) => setAisle(e.target.value)}
|
|
options={AISLE_OPTIONS}
|
|
/>
|
|
<div className="flex items-end">
|
|
<Button
|
|
onClick={handleAdd}
|
|
loading={addMutation.isPending}
|
|
disabled={!ingredientName.trim() || addMutation.isPending}
|
|
className="w-full"
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Search */}
|
|
{pantryItems && pantryItems.length > 0 && (
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
|
<Input
|
|
ref={searchInputRef}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search pantry items..."
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Items List */}
|
|
{filteredItems && filteredItems.length > 0 ? (
|
|
<Card className="overflow-hidden">
|
|
<div className="relative">
|
|
<div
|
|
className="overflow-x-auto"
|
|
role="region"
|
|
aria-label="Pantry items, scroll horizontally to see all columns"
|
|
>
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-surface-200 bg-surface-50">
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Item</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Aisle</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Quantity</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Expires</th>
|
|
<th className="px-6 py-3 text-right text-xs font-semibold text-surface-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-surface-200">
|
|
{filteredItems.map((item) => {
|
|
const isExpiringSoon = item.expires_at &&
|
|
new Date(item.expires_at).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
|
|
|
|
return (
|
|
<tr key={item.id} className="hover:bg-surface-50 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0">
|
|
<Package className="w-4 h-4 text-primary-600" />
|
|
</div>
|
|
<span className="font-medium text-sm text-surface-900">
|
|
{item.ingredient?.name || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-surface-500">
|
|
{item.ingredient?.aisle || <span className="text-surface-400">—</span>}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-surface-100 text-surface-700">
|
|
{item.quantity} {item.unit || ''}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{item.expires_at ? (
|
|
<div className="flex items-center gap-1.5">
|
|
{isExpiringSoon && <AlertTriangle className="w-4 h-4 text-warning-500" />}
|
|
<span className={`text-sm ${isExpiringSoon ? 'text-warning-600 font-medium' : 'text-surface-500'}`}>
|
|
{new Date(item.expires_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-surface-400">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
icon={<Trash2 className="w-4 h-4" />}
|
|
onClick={() => handleRemove(item)}
|
|
loading={removeMutation.isPending && removeId === item.id}
|
|
disabled={removeMutation.isPending}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
) : (
|
|
<EmptyState
|
|
icon={Package}
|
|
title={searchQuery ? 'No matches found' : 'Your pantry is empty'}
|
|
description={
|
|
searchQuery
|
|
? "Try adjusting your search."
|
|
: "Add ingredients you already have at home to avoid buying duplicates."
|
|
}
|
|
action={
|
|
!searchQuery
|
|
? { label: 'Add your first item', onClick: () => setShowAddForm(true) }
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|