Public Access
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:
+147
-150
@@ -119,159 +119,156 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealSlot — droppable area for one day + meal_type */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealSlot({
|
||||
dayIndex,
|
||||
mealType,
|
||||
item,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onGenerate,
|
||||
}: {
|
||||
dayIndex: number
|
||||
mealType: string
|
||||
item?: MealPlanItem
|
||||
onApprove?: (itemId: string) => void
|
||||
onDeny?: (itemId: string) => void
|
||||
onGenerate?: (dayIndex: number, mealType: string) => void
|
||||
}) {
|
||||
const droppableId = `slot-${dayIndex}-${mealType}`
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealSlot — droppable area for one day + meal_type */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealSlot({
|
||||
dayIndex,
|
||||
mealType,
|
||||
item,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onGenerate,
|
||||
}: {
|
||||
dayIndex: number
|
||||
mealType: string
|
||||
item?: MealPlanItem
|
||||
onApprove?: (itemId: string) => void
|
||||
onDeny?: (itemId: string) => void
|
||||
onGenerate?: (dayIndex: number, mealType: string) => void
|
||||
}) {
|
||||
const droppableId = `slot-${dayIndex}-${mealType}`
|
||||
|
||||
return (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`
|
||||
rounded-lg p-1 min-h-[80px] transition-colors
|
||||
${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'}
|
||||
`}
|
||||
>
|
||||
{item ? (
|
||||
<Draggable draggableId={`item-${item.id}`} index={0}>
|
||||
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
style={dragProvided.draggableProps.style}
|
||||
>
|
||||
<MealCard
|
||||
item={item}
|
||||
dragHandleProps={dragProvided.dragHandleProps}
|
||||
isDragging={dragSnapshot.isDragging}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
) : (
|
||||
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex flex-col items-center justify-center gap-1">
|
||||
<span className="text-xs text-surface-300">Empty</span>
|
||||
{onGenerate && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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 />
|
||||
return (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
{item ? (
|
||||
<Draggable draggableId={`item-${item.id}`} index={0}>
|
||||
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
style={dragProvided.draggableProps.style}
|
||||
>
|
||||
<MealCard
|
||||
item={item}
|
||||
dragHandleProps={dragProvided.dragHandleProps}
|
||||
isDragging={dragSnapshot.isDragging}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
) : (
|
||||
<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>
|
||||
{onGenerate && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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} 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 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VoteEmailButton */
|
||||
@@ -431,7 +428,7 @@ export default function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<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) => (
|
||||
<DayColumn
|
||||
key={index}
|
||||
|
||||
@@ -170,34 +170,34 @@ export default function MealDetail() {
|
||||
<img
|
||||
src={recipe.image_url}
|
||||
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" />
|
||||
</div>
|
||||
)}
|
||||
<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="flex items-end justify-between gap-4">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||
<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 && (
|
||||
<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 className="text-right flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
<div className="text-left sm:text-right flex-shrink-0">
|
||||
<div className="text-xl sm:text-2xl font-bold text-white">
|
||||
${item.estimated_cost?.toFixed(2) || 'N/A'}
|
||||
</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>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{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">
|
||||
<Clock className="w-4 h-4 text-surface-500" />
|
||||
|
||||
Reference in New Issue
Block a user