fix: improve mobile layout on meals page and navigation

- Dashboard: stack days vertically on mobile and suppress empty meal slots
- Nav: allow wrapping on narrow screens
- MealDetail: responsive hero sizing and padding
This commit is contained in:
2026-05-17 21:22:37 -07:00
parent 879768f72b
commit 986968b93d
3 changed files with 162 additions and 167 deletions
+5 -7
View File
@@ -21,13 +21,11 @@ function Navigation() {
return ( return (
<nav className="bg-white border-b border-surface-200 sticky top-0 z-50"> <nav className="bg-white border-b border-surface-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
<div className="flex justify-between h-14"> <div className="flex items-center gap-1 min-h-14 py-2">
<div className="flex items-center space-x-1"> <Link to="/" className={linkClass('/')}>MealPlanner</Link>
<Link to="/" className={linkClass('/')}>MealPlanner</Link> <Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link> <Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
</div>
</div> </div>
</div> </div>
</nav> </nav>
+147 -150
View File
@@ -119,159 +119,156 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
) )
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* MealSlot — droppable area for one day + meal_type */ /* MealSlot — droppable area for one day + meal_type */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function MealSlot({ function MealSlot({
dayIndex, dayIndex,
mealType, mealType,
item, item,
onApprove, onApprove,
onDeny, onDeny,
onGenerate, onGenerate,
}: { }: {
dayIndex: number dayIndex: number
mealType: string mealType: string
item?: MealPlanItem item?: MealPlanItem
onApprove?: (itemId: string) => void onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void onGenerate?: (dayIndex: number, mealType: string) => void
}) { }) {
const droppableId = `slot-${dayIndex}-${mealType}` const droppableId = `slot-${dayIndex}-${mealType}`
return ( return (
<Droppable droppableId={droppableId}> <Droppable droppableId={droppableId}>
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
className={` className={`
rounded-lg p-1 min-h-[80px] transition-colors rounded-lg p-1 min-h-[20px] md:min-h-[80px] transition-colors
${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'} ${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'}
`} `}
> >
{item ? ( {item ? (
<Draggable draggableId={`item-${item.id}`} index={0}> <Draggable draggableId={`item-${item.id}`} index={0}>
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => ( {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
<div <div
ref={dragProvided.innerRef} ref={dragProvided.innerRef}
{...dragProvided.draggableProps} {...dragProvided.draggableProps}
style={dragProvided.draggableProps.style} style={dragProvided.draggableProps.style}
> >
<MealCard <MealCard
item={item} item={item}
dragHandleProps={dragProvided.dragHandleProps} dragHandleProps={dragProvided.dragHandleProps}
isDragging={dragSnapshot.isDragging} isDragging={dragSnapshot.isDragging}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onDeny={onDeny}
/> />
</div> </div>
)} )}
</Draggable> </Draggable>
) : ( ) : (
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex flex-col items-center justify-center gap-1"> <div className="hidden md:flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1">
<span className="text-xs text-surface-300">Empty</span> <span className="text-xs text-surface-300">Empty</span>
{onGenerate && ( {onGenerate && (
<button <button
onClick={() => onGenerate(dayIndex, mealType)} onClick={() => onGenerate(dayIndex, mealType)}
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors" className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors"
> >
Generate Generate
</button> </button>
)} )}
</div> </div>
)} )}
{provided.placeholder} {provided.placeholder}
</div>
)}
</Droppable>
)
}
/* ------------------------------------------------------------------ */
/* DayColumn */
/* ------------------------------------------------------------------ */
function DayColumn({
dayIndex,
items,
onApprove,
onDeny,
onGenerate,
}: {
dayIndex: number
items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
return (
<div className="flex flex-col gap-1">
<div className={`px-2 py-1.5 rounded-lg ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
{FULL_DAY_NAMES[dayIndex]}
</span>
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
</div>
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
</div>
<div className="space-y-1">
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType}>
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType}
</span>
<MealSlot
dayIndex={dayIndex}
mealType={mealType}
item={item}
onApprove={onApprove}
onDeny={onDeny}
onGenerate={onGenerate}
/>
</div>
)
})}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Skeleton */
/* ------------------------------------------------------------------ */
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<div className="flex gap-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-20" />
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-full" />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div> </div>
))} )}
</Droppable>
)
}
/* ------------------------------------------------------------------ */
/* DayColumn */
/* ------------------------------------------------------------------ */
function DayColumn({
dayIndex,
items,
onApprove,
onDeny,
onGenerate,
}: {
dayIndex: number
items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
return (
<div className="flex flex-col gap-1">
<div className={`px-2 py-1.5 rounded-lg ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
{FULL_DAY_NAMES[dayIndex]}
</span>
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
</div>
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
</div>
<div className="space-y-1">
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType} className={item ? '' : 'hidden md:block'}>
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType}
</span>
<MealSlot
dayIndex={dayIndex}
mealType={mealType}
item={item}
onApprove={onApprove}
onDeny={onDeny}
onGenerate={onGenerate}
/>
</div>
)
})}
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> )
<SkeletonCard /> }
<SkeletonCard />
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<div className="flex gap-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-20" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-full" />
<div className="hidden sm:block"><SkeletonCard /></div>
<div className="hidden sm:block"><SkeletonCard /></div>
<div className="hidden sm:block"><SkeletonCard /></div>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SkeletonCard />
<SkeletonCard />
</div>
</div> </div>
</div> )
) }
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* VoteEmailButton */ /* VoteEmailButton */
@@ -431,7 +428,7 @@ export default function Dashboard() {
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-7 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{itemsByDay.map((items, index) => ( {itemsByDay.map((items, index) => (
<DayColumn <DayColumn
key={index} key={index}
+10 -10
View File
@@ -170,34 +170,34 @@ export default function MealDetail() {
<img <img
src={recipe.image_url} src={recipe.image_url}
alt={recipe.name} alt={recipe.name}
className="w-full h-72 object-cover opacity-90" className="w-full h-48 sm:h-72 object-cover opacity-90"
/> />
) : ( ) : (
<div className="h-72 flex items-center justify-center"> <div className="h-48 sm:h-72 flex items-center justify-center">
<ChefHat className="w-16 h-16 text-surface-600" /> <ChefHat className="w-16 h-16 text-surface-600" />
</div> </div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6"> <div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6">
<div className="flex items-end justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div> <div>
<h1 className="text-3xl font-bold text-white">{recipe.name}</h1> <h1 className="text-xl sm:text-3xl font-bold text-white">{recipe.name}</h1>
{recipe.description && ( {recipe.description && (
<p className="text-white/80 mt-1 max-w-xl text-sm">{recipe.description}</p> <p className="text-white/80 mt-1 max-w-xl text-xs sm:text-sm">{recipe.description}</p>
)} )}
</div> </div>
<div className="text-right flex-shrink-0"> <div className="text-left sm:text-right flex-shrink-0">
<div className="text-2xl font-bold text-white"> <div className="text-xl sm:text-2xl font-bold text-white">
${item.estimated_cost?.toFixed(2) || 'N/A'} ${item.estimated_cost?.toFixed(2) || 'N/A'}
</div> </div>
<div className="text-sm text-white/70">per serving</div> <div className="text-xs sm:text-sm text-white/70">per serving</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Metadata Row */} {/* Metadata Row */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-3">
{totalTime > 0 && ( {totalTime > 0 && (
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg"> <div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
<Clock className="w-4 h-4 text-surface-500" /> <Clock className="w-4 h-4 text-surface-500" />