init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
229
app/components/RecipeDetail.tsx
Normal file
229
app/components/RecipeDetail.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue