Public Access
feat: drag-and-drop meal scheduling + 7-day grid
This commit is contained in:
@@ -42,6 +42,7 @@ export const mealPlannerApi = {
|
||||
getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`),
|
||||
submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data),
|
||||
swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
|
||||
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
|
||||
},
|
||||
|
||||
pantry: {
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
|
||||
GripVertical
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
type DroppableProvided,
|
||||
type DraggableProvided,
|
||||
type DraggableProvidedDragHandleProps,
|
||||
type DraggableStateSnapshot,
|
||||
type DroppableStateSnapshot,
|
||||
} from '@hello-pangea/dnd'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { MealPlan, MealPlanItem } from '../types'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
@@ -14,7 +28,14 @@ const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
||||
|
||||
function MealCard({ item }: { item: MealPlanItem }) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealCard (draggable) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealCard({ item, dragHandleProps, isDragging }: {
|
||||
item: MealPlanItem
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps | null
|
||||
isDragging?: boolean
|
||||
}) {
|
||||
const totalTime = item.recipe?.total_time_minutes ??
|
||||
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
|
||||
|
||||
@@ -25,76 +46,151 @@ function MealCard({ item }: { item: MealPlanItem }) {
|
||||
'neutral'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/meals/${item.id}`}
|
||||
className="group block bg-surface-0 rounded-xl border border-surface-200 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200"
|
||||
<div
|
||||
className={`
|
||||
group block bg-surface-0 rounded-xl border overflow-hidden
|
||||
hover:shadow-md hover:border-primary-200 transition-all duration-200
|
||||
${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}
|
||||
`}
|
||||
>
|
||||
<div className="flex gap-3 p-3">
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* drag handle */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="flex-shrink-0 self-center cursor-grab active:cursor-grabbing text-surface-400 hover:text-surface-600"
|
||||
aria-label="Drag to move meal"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{item.recipe?.image_url ? (
|
||||
<img
|
||||
src={item.recipe.image_url}
|
||||
alt={item.recipe.name}
|
||||
className="w-20 h-20 object-cover rounded-lg flex-shrink-0"
|
||||
className="w-14 h-14 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-8 h-8 text-surface-400" />
|
||||
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-6 h-6 text-surface-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||
{item.recipe?.name || 'Unknown Recipe'}
|
||||
</h4>
|
||||
<p className="text-xs text-surface-500 mt-0.5">
|
||||
<Link to={`/meals/${item.id}`} className="block">
|
||||
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||
{item.recipe?.name || 'Unknown Recipe'}
|
||||
</h4>
|
||||
</Link>
|
||||
<p className="text-[11px] text-surface-500 mt-0.5">
|
||||
{totalTime > 0 && `${totalTime} min · `}
|
||||
{item.recipe?.servings} servings
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={statusVariant}>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
|
||||
{item.approval_status}
|
||||
</Badge>
|
||||
{item.estimated_cost && (
|
||||
<span className="text-xs font-medium text-surface-600">
|
||||
<span className="text-[11px] font-medium text-surface-600">
|
||||
${item.estimated_cost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealSlot — droppable area for one day + meal_type */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealSlot({
|
||||
dayIndex,
|
||||
mealType,
|
||||
item,
|
||||
}: {
|
||||
dayIndex: number
|
||||
mealType: string
|
||||
item?: MealPlanItem
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
) : (
|
||||
/* empty slot placeholder — invisible but droppable */
|
||||
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
||||
<span className="text-xs text-surface-300">Drop here</span>
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DayColumn */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function DayColumn({
|
||||
dayIndex,
|
||||
items,
|
||||
}: {
|
||||
dayIndex: number
|
||||
items: MealPlanItem[]
|
||||
}) {
|
||||
const today = new Date().getDay()
|
||||
const isToday = today === (dayIndex + 1) % 7
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 w-[260px]">
|
||||
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
||||
<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-sm font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
||||
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
||||
{FULL_DAY_NAMES[dayIndex]}
|
||||
</span>
|
||||
{isToday && <Badge variant="primary">Today</Badge>}
|
||||
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
|
||||
</div>
|
||||
<span className="text-xs text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
||||
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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-[10px] font-medium text-surface-400 uppercase tracking-wider px-1">
|
||||
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
|
||||
{mealType}
|
||||
</span>
|
||||
{item ? (
|
||||
<MealCard item={item} />
|
||||
) : (
|
||||
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
||||
<span className="text-xs text-surface-400">—</span>
|
||||
</div>
|
||||
)}
|
||||
<MealSlot
|
||||
dayIndex={dayIndex}
|
||||
mealType={mealType}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Skeleton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
|
||||
<Skeleton className="h-7 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
@@ -131,6 +230,9 @@ function DashboardSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VoteEmailButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function VoteEmailButton() {
|
||||
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
|
||||
|
||||
@@ -159,16 +261,36 @@ function VoteEmailButton() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dashboard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function Dashboard() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||
queryKey: ['mealPlan'],
|
||||
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
async function handleDrop(itemId: string, newDay: number, newType: string) {
|
||||
try {
|
||||
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
|
||||
toast.success('Meal moved')
|
||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.detail || 'Failed to move meal')
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(result: DropResult) {
|
||||
if (!result.destination) return
|
||||
const { draggableId, destination } = result
|
||||
const itemId = draggableId.replace('item-', '')
|
||||
const [, dayStr, typeStr] = destination.droppableId.split('-')
|
||||
handleDrop(itemId, parseInt(dayStr, 10), typeStr)
|
||||
}
|
||||
|
||||
if (isLoading) return <DashboardSkeleton />
|
||||
|
||||
if (!mealPlan) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -228,17 +350,23 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Grid */}
|
||||
{/* Weekly Grid — all 7 days visible */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||
{itemsByDay.map((items, index) => (
|
||||
<DayColumn key={index} dayIndex={index} items={items} />
|
||||
))}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{itemsByDay.map((items, index) => (
|
||||
<DayColumn
|
||||
key={index}
|
||||
dayIndex={index}
|
||||
items={items}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user