init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
231
app/components/MealGrid.tsx
Normal file
231
app/components/MealGrid.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
|
||||
import { useState, type Dispatch } from "react";
|
||||
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { formatUsd } from "@/app/lib/units";
|
||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
import { ApplianceIcon } from "./Icons";
|
||||
|
||||
interface MealGridProps {
|
||||
state: MealPlanState;
|
||||
dispatch: Dispatch<PlanAction>;
|
||||
mealCosts: Map<string, number>;
|
||||
onOpenSlot: (key: string) => void;
|
||||
onViewSlot: (key: string) => void;
|
||||
onRemoveSlot: (key: string, recipeTitle: string) => void;
|
||||
}
|
||||
|
||||
const applianceLabel = (id: string) => APPLIANCES.find((a) => a.id === id)?.label ?? id;
|
||||
|
||||
export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, onRemoveSlot }: MealGridProps) {
|
||||
const [dragFrom, setDragFrom] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState<string | null>(null);
|
||||
/** Mobile & keyboard slot-swap picker: holds the origin slot key currently choosing a destination. */
|
||||
const [moveSourceKey, setMoveSourceKey] = useState<string | null>(null);
|
||||
|
||||
const allSlots = activeSlots(state);
|
||||
const days = Array.from({ length: state.daysCount }, (_, day) => allSlots.filter((s) => s.dayIndex === day));
|
||||
|
||||
return (
|
||||
<section aria-labelledby="grid-heading">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id="grid-heading" className="font-serif text-xl font-semibold tracking-tight">
|
||||
Weekly menu
|
||||
</h2>
|
||||
<p className="text-xs text-ink-faint">
|
||||
<span className="hidden md:inline">Drag meals or use the swap icon to rearrange · </span>
|
||||
Tap any planned dish for its scaled recipe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{days.map((slots, day) => {
|
||||
const dayCost = slots.reduce((sum, s) => sum + (s.recipeId ? (mealCosts.get(s.recipeId) ?? 0) : 0), 0);
|
||||
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
|
||||
|
||||
return (
|
||||
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
|
||||
{/* Architectural day header */}
|
||||
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
|
||||
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
|
||||
{hasAnyMeal && (
|
||||
<span className="font-mono text-xs text-ink-muted tabular-nums">{formatUsd(dayCost)} eaten</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 divide-y divide-line">
|
||||
{slots.map((slot) => {
|
||||
const key = slotKey(slot.dayIndex, slot.mealNumber);
|
||||
const recipe = slot.recipeId ? RECIPES_BY_ID.get(slot.recipeId) : undefined;
|
||||
const blocked = recipe ? !isCookable(recipe, state) : false;
|
||||
const label = mealLabel(slot.mealNumber);
|
||||
const isDropTarget = dragOver === key && dragFrom !== key;
|
||||
const isMovingThis = moveSourceKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
onDragOver={(e) => {
|
||||
if (!dragFrom) return;
|
||||
e.preventDefault();
|
||||
setDragOver(key);
|
||||
}}
|
||||
onDragLeave={() => setDragOver((current) => (current === key ? null : current))}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragFrom && dragFrom !== key) dispatch({ type: "swapSlots", from: dragFrom, to: key });
|
||||
setDragFrom(null);
|
||||
setDragOver(null);
|
||||
}}
|
||||
className={`transition-colors ${isDropTarget ? "bg-brand-950 ring-2 ring-inset ring-brand-600" : ""}`}
|
||||
>
|
||||
{recipe ? (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => setDragFrom(key)}
|
||||
onDragEnd={() => {
|
||||
setDragFrom(null);
|
||||
setDragOver(null);
|
||||
}}
|
||||
className={`p-3.5 transition-colors ${
|
||||
blocked ? "bg-status-warn-bg/65" : "hover:bg-raised/45"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewSlot(key)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
aria-label={`View recipe for ${DAY_NAMES[day]} ${label}: ${recipe.title}`}
|
||||
>
|
||||
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block leading-snug font-semibold text-ink hover:text-brand-300">
|
||||
{recipe.title}
|
||||
</span>
|
||||
|
||||
<span className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-muted">
|
||||
<span>{recipe.prepTime} min</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono tabular-nums">{formatUsd(mealCosts.get(recipe.id) ?? 0)}/meal</span>
|
||||
{recipe.requiredAppliances.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-ink-faint"
|
||||
title={`Requires: ${recipe.requiredAppliances.map(applianceLabel).join(", ")}`}
|
||||
>
|
||||
{recipe.requiredAppliances.map((a) => (
|
||||
<ApplianceIcon key={a} id={a} className="size-3.5" />
|
||||
))}
|
||||
<span className="sr-only">
|
||||
Requires {recipe.requiredAppliances.map(applianceLabel).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{blocked && (
|
||||
<span className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-status-warn">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
|
||||
{describeBlockers(recipeBlockers(recipe, state))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Non-overlapping 36x36px touch & keyboard action controls */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{allSlots.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isMovingThis}
|
||||
onClick={() => setMoveSourceKey(isMovingThis ? null : key)}
|
||||
aria-label={`Move or swap ${recipe.title}`}
|
||||
title="Move or swap slot"
|
||||
className={`grid size-9 place-items-center rounded-lg transition-colors ${
|
||||
isMovingThis ? "bg-selected text-ink" : "text-ink-faint hover:bg-raised hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<ArrowLeftRight className="size-4" strokeWidth={1.75} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isMovingThis) setMoveSourceKey(null);
|
||||
onRemoveSlot(key, recipe.title);
|
||||
}}
|
||||
aria-label={`Remove ${recipe.title} from ${DAY_NAMES[day]} ${label}`}
|
||||
title="Remove meal"
|
||||
className="grid size-9 place-items-center rounded-lg text-ink-faint transition-colors hover:bg-status-danger-bg hover:text-status-danger"
|
||||
>
|
||||
<X className="size-4" strokeWidth={1.75} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touch & keyboard accessible Swap/Move picker */}
|
||||
{isMovingThis && (
|
||||
<div className="mt-3 rounded-lg border border-line-strong bg-raised p-2.5">
|
||||
<p className="text-xs font-medium text-ink-muted">Swap or move to another slot:</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{allSlots
|
||||
.filter((target) => slotKey(target.dayIndex, target.mealNumber) !== key)
|
||||
.map((target) => {
|
||||
const targetKey = slotKey(target.dayIndex, target.mealNumber);
|
||||
const occupant = target.recipeId ? RECIPES_BY_ID.get(target.recipeId)?.title : null;
|
||||
return (
|
||||
<button
|
||||
key={targetKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dispatch({ type: "swapSlots", from: key, to: targetKey });
|
||||
setMoveSourceKey(null);
|
||||
}}
|
||||
className="trim-cap min-h-8 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink transition-colors hover:border-brand-600 hover:bg-brand-950"
|
||||
title={occupant ? `Swap with ${occupant}` : "Move to empty slot"}
|
||||
>
|
||||
{DAY_NAMES[target.dayIndex]} {mealLabel(target.mealNumber)}
|
||||
{occupant ? " ⇄" : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSlot(key)}
|
||||
className="group flex min-h-16 w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-raised/65"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-sm font-medium text-ink-muted group-hover:text-ink">
|
||||
Choose a meal
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid size-8 place-items-center rounded-lg border border-dashed border-line-strong text-ink-muted transition-colors group-hover:border-brand-600 group-hover:bg-brand-950 group-hover:text-brand-300">
|
||||
<Plus className="size-4" strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue