Recipes
index.jsx 403 lines
Files · 8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403import "./style.css";
import React, { useEffect, useState } from "react";
import { FormActions, ConfirmDelete } from "../../shared/forms.jsx";
import { useOperations } from "../../shared/data.js";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Modal,
Field,
EmptyState,
Stats,
Search,
ErrorNotice,
} from "../../shared/ui.jsx";
import { durationLabel, nullable } from "../../shared/format.js";
import { createRecipes, samples } from "./model.js";
const L = window.lattice,
store = createRecipes(L);
const extras = [
["author", "Author"],
["url", "Source URL"],
["date_published", "Published date"],
["recipe_cuisine", "Cuisine"],
];
function RecipeForm({ recipe, onSave, onClose, busy, error }) {
const [form, setForm] = useState({
...recipe,
recipe_ingredient: JSON.parse(recipe?.recipe_ingredient || "[]").join("\n"),
});
const field = (key, label, type = "text", hint) => (
<Field key={key} label={label} hint={hint}>
<input
type={type}
value={form[key] ?? ""}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</Field>
);
return (
<Modal title={recipe ? "Edit recipe" : "New recipe"} onClose={onClose}>
<ErrorNotice error={error} />
<form
onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(
[
"name",
"description",
"url",
"author",
"date_published",
"prep_time",
"cook_time",
"total_time",
"recipe_yield",
"recipe_category",
"recipe_cuisine",
"recipe_instructions",
].map((k) => [k, nullable(form[k])]),
);
data.name = form.name?.trim() || "";
data.recipe_ingredient = JSON.stringify(
(form.recipe_ingredient || "").split("\n").filter((s) => s.trim()),
);
onSave(data);
}}
>
<div className="ui-form-grid">
<Field label="Name" wide>
<input
required
autoFocus
value={form.name || ""}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</Field>
<Field label="Description" wide>
<textarea
rows="2"
value={form.description || ""}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</Field>
{field(
"recipe_yield",
"Yield",
"text",
"For example: 4 servings or 1 loaf",
)}
{field("recipe_category", "Category")}
{field("prep_time", "Prep time", "text", "ISO duration, e.g. PT15M")}
{field("cook_time", "Cook time", "text", "ISO duration, e.g. PT1H")}
{field(
"total_time",
"Total time",
"text",
"Includes resting time, e.g. PT2H15M",
)}
<Field
label="Ingredients"
wide
hint="One ingredient per line. Keep quantities and units in the text."
>
<textarea
rows="6"
value={form.recipe_ingredient || ""}
onChange={(e) =>
setForm({ ...form, recipe_ingredient: e.target.value })
}
/>
</Field>
<Field label="Instructions" wide>
<textarea
rows="7"
value={form.recipe_instructions || ""}
onChange={(e) =>
setForm({ ...form, recipe_instructions: e.target.value })
}
/>
</Field>
</div>
<details>
<summary>Source and other details</summary>
<div className="ui-form-grid">
{extras.map(([k, label]) =>
field(
k,
label,
k === "date_published" ? "date" : k === "url" ? "url" : "text",
),
)}
</div>
</details>
<FormActions busy={busy} onCancel={onClose} label="Save recipe" />
</form>
</Modal>
);
}
function RecipeDetail({ recipe: r, onClose, onEdit }) {
return (
<Modal title={r.name} onClose={onClose}>
<div className="ui-actions">
<span className="ui-badge ui-badge-accent">
{r.recipe_category || "Recipe"}
</span>
{r.recipe_cuisine && (
<span className="ui-badge">{r.recipe_cuisine}</span>
)}
</div>
{r.description && <p className="card-description">{r.description}</p>}
<div className="card-meta">
<span>Prep {durationLabel(r.prep_time)}</span>
<span>Cook {durationLabel(r.cook_time)}</span>
<span>Total {durationLabel(r.total_time)}</span>
{r.recipe_yield && <span>{r.recipe_yield}</span>}
</div>
<div className="ui-detail-columns">
<section>
<h3>Ingredients</h3>
<ul className="ingredients">
{JSON.parse(r.recipe_ingredient).map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</section>
<section>
<h3>Instructions</h3>
<p className="instructions">
{r.recipe_instructions || "No instructions added."}
</p>
</section>
</div>
{(r.author || r.url || r.date_published) && (
<div className="card-meta">
{r.author && <span>By {r.author}</span>}
{r.date_published && <span>Published {r.date_published}</span>}
{r.url && <span className="ui-mono">Source: {r.url}</span>}
</div>
)}
<div className="ui-form-actions">
<Button onClick={() => onEdit(r)}>Edit recipe</Button>
<Button variant="primary" onClick={onClose}>
Done
</Button>
</div>
</Modal>
);
}
function App() {
const [recipes, setRecipes] = useState([]),
[loading, setLoading] = useState(true),
[search, setSearch] = useState(""),
[category, setCategory] = useState("All"),
[editing, setEditing] = useState(null),
[viewing, setViewing] = useState(null),
[deleting, setDeleting] = useState(null);
const load = async () => {
setRecipes(await store.list());
setLoading(false);
};
const { busy, error, setError, act } = useOperations(load);
useEffect(() => {
load().catch((e) => {
setError(e.message);
setLoading(false);
});
L.events.listen("save-recipe", async (data) => {
try {
await store.save(data);
await load();
} catch (e) {
setError(e.message);
throw e;
}
});
}, []);
const categories = [
"All",
...new Set(recipes.map((r) => r.recipe_category).filter(Boolean)),
];
const filtered = recipes.filter(
(r) =>
(category === "All" || r.recipe_category === category) &&
`${r.name} ${r.description || ""} ${r.recipe_ingredient} ${r.recipe_cuisine || ""}`
.toLowerCase()
.includes(search.toLowerCase()),
);
return (
<Page className="record-page">
<Header
title="Recipes"
subtitle="Recipes, ingredients, and cooking times."
actions={
<>
{recipes.length === 0 && (
<Button
disabled={busy}
onClick={() =>
act(async () => {
for (const r of samples) await store.save(r);
})
}
>
Add sample data
</Button>
)}
<Button variant="primary" onClick={() => setEditing("new")}>
Add recipe
</Button>
</>
}
/>
<ErrorNotice error={error} clear={() => setError("")} />
<Stats
items={[
[recipes.length, "Saved recipes"],
[
new Set(recipes.map((r) => r.recipe_category).filter(Boolean)).size,
"Categories",
],
[
recipes.filter((r) => r.recipe_yield).length,
"With serving details",
],
]}
/>
<div className="ui-toolbar">
<Search
value={search}
onChange={setSearch}
placeholder="Search recipes or ingredients"
/>
<div className="ui-tabs" aria-label="Recipe categories">
{categories.map((c) => (
<Button
key={c}
aria-pressed={category === c}
onClick={() => setCategory(c)}
>
{c}
</Button>
))}
</div>
</div>
{loading ? (
<p className="ui-loading">Loading recipes…</p>
) : filtered.length ? (
<div className="ui-card-grid">
{filtered.map((r) => (
<article className="ui-panel" key={r.id}>
<div className="recipe-band">
<span aria-hidden="true">
{r.recipe_category === "Baking"
? "♨"
: r.recipe_category === "Breakfast"
? "☀"
: "♧"}
</span>
<span>{r.recipe_category || "Recipe"}</span>
</div>
<div className="ui-panel-pad">
<h3>{r.name}</h3>
<p className="card-description">
{r.description ||
`${JSON.parse(r.recipe_ingredient).length} ingredients`}
</p>
<div className="ui-actions">
{r.recipe_cuisine && (
<span className="ui-badge">{r.recipe_cuisine}</span>
)}
{r.recipe_yield && (
<span className="ui-badge">{r.recipe_yield}</span>
)}
</div>
<div className="card-meta">
<span>
◷{" "}
{durationLabel(r.total_time || r.cook_time || r.prep_time)}
</span>
<span>
{JSON.parse(r.recipe_ingredient).length} ingredients
</span>
</div>
<div className="card-actions">
<Button onClick={() => setViewing(r)}>View recipe</Button>
<Button
aria-label={`Edit ${r.name}`}
onClick={() => setEditing(r)}
>
Edit
</Button>
<Button
aria-label={`Delete ${r.name}`}
onClick={() => setDeleting(r)}
>
Delete
</Button>
</div>
</div>
</article>
))}
</div>
) : (
<EmptyState
title={
recipes.length ? "No recipes found" : "Your recipe box is empty"
}
>
{recipes.length
? "Try a different search or category."
: "Add a recipe or try sample recipes."}
</EmptyState>
)}
{editing && (
<RecipeForm
key={editing.id || "new"}
recipe={editing === "new" ? null : editing}
busy={busy}
error={error}
onClose={() => setEditing(null)}
onSave={(data) =>
act(async () => {
await store.save(
data,
editing === "new" ? undefined : editing.id,
);
setEditing(null);
})
}
/>
)}
{viewing && (
<RecipeDetail
recipe={viewing}
onClose={() => setViewing(null)}
onEdit={(r) => {
setViewing(null);
setEditing(r);
}}
/>
)}
{deleting && (
<ConfirmDelete
title="Delete recipe?"
children={`Delete “${deleting.name}” ?`}
busy={busy}
onClose={() => setDeleting(null)}
onConfirm={() =>
act(async () => {
await store.remove(deleting.id);
setDeleting(null);
})
}
/>
)}
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);