init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
146
app/components/AddToPlanControl.tsx
Normal file
146
app/components/AddToPlanControl.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
|
||||
const STORAGE_KEY = "budget-meal-planner:v2";
|
||||
|
||||
function readPlan(): MealPlanState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? sanitizeState(JSON.parse(raw)) : DEFAULT_STATE;
|
||||
} catch {
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [plan, setPlan] = useState<MealPlanState>(DEFAULT_STATE);
|
||||
const [addedLabel, setAddedLabel] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
return () => {
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function assignSlot(day: number, meal: number) {
|
||||
const latest = readPlan();
|
||||
const key = slotKey(day, meal);
|
||||
const updated: MealPlanState = {
|
||||
...latest,
|
||||
slots: { ...latest.slots, [key]: recipeId },
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
setPlan(updated);
|
||||
setAddedLabel(`${DAY_NAMES[day]} · ${mealLabel(meal)}`);
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// Blocked storage
|
||||
}
|
||||
}
|
||||
|
||||
const meals = visibleMeals(plan.mealsPerDay);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlan(readPlan());
|
||||
setOpen(true);
|
||||
}}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
|
||||
Add to meal plan
|
||||
</button>
|
||||
|
||||
{addedLabel && (
|
||||
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
|
||||
<Check className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
|
||||
Added to {addedLabel}
|
||||
<Link href="/plan" className="ml-1 inline-flex items-center gap-1 font-semibold underline hover:opacity-85">
|
||||
Open planner
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={() => setOpen(false)}
|
||||
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
|
||||
aria-labelledby="add-to-plan-title"
|
||||
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">Assign slot</p>
|
||||
<h2 id="add-to-plan-title" className="font-serif text-xl font-semibold">
|
||||
Add “{recipeTitle}”
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted hover:bg-raised hover:text-ink"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: plan.daysCount }, (_, day) => (
|
||||
<div key={day} className="rounded-lg border border-line bg-canvas p-3">
|
||||
<p className="font-serif text-sm font-semibold text-ink">{DAY_NAMES[day]}</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{meals.map((meal) => {
|
||||
const key = slotKey(day, meal);
|
||||
const existingId = plan.slots[key];
|
||||
const existingTitle = existingId ? RECIPES_BY_ID.get(existingId)?.title : null;
|
||||
const isSame = existingId === recipeId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => assignSlot(day, meal)}
|
||||
className={`min-h-12 rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||
isSame
|
||||
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
||||
: "border-line-strong bg-surface hover:border-brand-600 hover:bg-brand-950"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-mono text-[10px] tracking-wider text-ink-faint uppercase">
|
||||
{mealLabel(meal)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-medium text-ink">
|
||||
{existingTitle ? existingTitle : "+ Empty slot"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue