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 />);