This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

View file

@ -0,0 +1,229 @@
"use client";
import { Check, Lightbulb } from "lucide-react";
import { useState } from "react";
import { ingredientLines } from "@/app/lib/display";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
import type { Recipe } from "@/app/types";
import { ApplianceIcon, StyleIcon } from "./Icons";
interface RecipeDetailProps {
recipe: Recipe;
/** People to scale ingredient amounts for. */
servings: number;
/** Hide the cuisine/time/appliance chips when surrounding UI already shows them. */
showMeta?: boolean;
/** Smaller type for use inside lists. */
compact?: boolean;
/** Allow tapping ingredients & steps to check them off while cooking. */
interactive?: boolean;
}
export function RecipeDetail({
recipe,
servings,
showMeta = true,
compact = false,
interactive = false,
}: RecipeDetailProps) {
const lines = ingredientLines(recipe, servings);
const text = compact ? "text-sm" : "text-base";
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(() => new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(() => new Set());
function toggleSet(setter: React.Dispatch<React.SetStateAction<Set<number>>>, index: number) {
setter((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
return (
<div className={compact ? "space-y-4" : "space-y-8"}>
{showMeta && <RecipeMeta recipe={recipe} />}
<div className={compact ? "grid gap-5 md:grid-cols-[2fr_3fr]" : "grid gap-8 md:grid-cols-[2fr_3fr]"}>
{/* --- Ingredients ------------------------------------------------- */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>
Ingredients{" "}
<span className="font-sans text-sm font-normal text-ink-muted">
· {servings} serving{servings === 1 ? "" : "s"}
</span>
</h3>
{interactive && checkedIngredients.size > 0 && (
<button
type="button"
onClick={() => setCheckedIngredients(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset
</button>
)}
</div>
<ul className={`mt-3 divide-y divide-line ${text}`}>
{lines.map((line, i) => {
const done = interactive && checkedIngredients.has(i);
const content = (
<>
<span className="w-20 shrink-0 text-right font-mono text-sm font-semibold text-ink tabular-nums">
{line.quantity} {line.unit}
</span>
<span className={done ? "text-ink-faint line-through" : "text-ink"}>
{line.name}
{line.detail && <span className="text-ink-muted"> ({line.detail})</span>}
</span>
</>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedIngredients, i)}
className="flex min-h-10 w-full items-baseline gap-3 py-2 text-left transition-colors hover:bg-raised/50"
>
{content}
</button>
) : (
<div className="flex items-baseline gap-3 py-1.5">{content}</div>
)}
</li>
);
})}
</ul>
</section>
{/* --- Instructions ------------------------------------------------ */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>Instructions</h3>
{interactive && checkedSteps.size > 0 && (
<button
type="button"
onClick={() => setCheckedSteps(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset steps
</button>
)}
</div>
{servings !== recipe.servings && (
<p className="mt-2.5 rounded-lg border border-line-strong/50 bg-raised px-3 py-2 text-xs text-ink-muted">
Ingredient quantities on the left are scaled to <strong className="font-semibold text-ink">{servings}</strong>.
Step prose is written for the original {recipe.servings}-serving batch—scale pan sizes or liquid mentions the same way.
</p>
)}
<ol className={`mt-3 space-y-3 ${text}`}>
{recipe.steps.map((step, i) => {
const done = interactive && checkedSteps.has(i);
const badge = (
<span
className={`grid size-6 shrink-0 place-items-center rounded-full font-mono text-xs font-semibold transition-colors ${
done
? "bg-status-ok text-canvas"
: "border border-selected-line/50 bg-selected text-ink"
}`}
>
{done ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : i + 1}
</span>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedSteps, i)}
className="flex w-full items-start gap-3 rounded-lg p-1.5 -m-1.5 text-left transition-colors hover:bg-raised/50"
>
{badge}
<span className={`pt-0.5 leading-relaxed ${done ? "text-ink-faint line-through" : "text-ink"}`}>{step}</span>
</button>
) : (
<div className="flex items-start gap-3">
{badge}
<span className="pt-0.5 leading-relaxed">{step}</span>
</div>
)}
</li>
);
})}
</ol>
{recipe.tip && (
<div className="mt-5 flex items-start gap-2.5 rounded-lg border border-brand-border bg-brand-950 px-3.5 py-3 text-sm text-ink">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
<p>
<span className="font-semibold text-brand-300">Cook&apos;s note:</span> {recipe.tip}
</p>
</div>
)}
</section>
</div>
</div>
);
}
export function RecipeMeta({ recipe }: { recipe: Recipe }) {
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
return (
<ul className="flex flex-wrap gap-1.5">
{cuisineLabel && (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs font-medium text-ink">
{cuisineLabel}
</li>
)}
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
{MEAL_KIND_LABELS[recipe.kind]}
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 font-mono text-xs text-ink-muted">
{recipe.prepTime} min
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
Serves {recipe.servings}
</li>
{recipe.requiredAppliances.length === 0 ? (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">No cooking</li>
) : (
recipe.requiredAppliances.map((a) => {
const label = APPLIANCES.find((x) => x.id === a)?.label ?? a;
return (
<li
key={a}
className="inline-flex items-center gap-1.5 rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted"
>
<ApplianceIcon id={a} className="size-3.5 text-ink" />
{label}
</li>
);
})
)}
{recipe.styles.map((s) => {
const label = STYLES.find((x) => x.id === s)?.label;
if (!label) return null;
return (
<li
key={s}
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted"
>
<StyleIcon id={s} className="size-3.5 text-brand-300" />
{label}
</li>
);
})}
</ul>
);
}