Files
Meal-Planner/frontend/src/pages/Pantry.tsx
T
admin f740f40103 feat(ui): global keyboard shortcuts + shortcut help banner (Sprint 5 F2)
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.
2026-06-04 12:36:32 -07:00

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