diff --git a/README.md b/README.md
index 691195a..c4449fc 100644
--- a/README.md
+++ b/README.md
@@ -43,3 +43,15 @@ npm run dev
```
Prettier and ESLint also run automatically on staged files when you commit (husky + lint-staged).
+
+## Deployment
+
+The client build in `client/dist` is served by Apache under `/project2/`. `client/public/.htaccess` tells Apache to fall back to `index.html` for unknown paths, so recipe pages like `/project2/recipe/2` also work on reload and from shared links.
+
+Apache only reads `.htaccess` if overrides are allowed for the folder it serves `/project2/` from. If reloading a recipe page still gives a 404, add this to the site config on the VM (adjust the path to that folder) and reload Apache:
+
+```apache
+
+ AllowOverride Indexes
+
+```
diff --git a/client/public/.htaccess b/client/public/.htaccess
new file mode 100644
index 0000000..91b2ba4
--- /dev/null
+++ b/client/public/.htaccess
@@ -0,0 +1,4 @@
+# Send unknown paths under /project2/ to index.html so React Router can handle them.
+# Without this, reloading /project2/recipe/2 gives a 404 because no such file exists.
+# Apache only reads this file if AllowOverride allows it for the web root (see README).
+FallbackResource /project2/index.html
diff --git a/client/src/api/queries.test.ts b/client/src/api/queries.test.ts
index 0e2d047..746135b 100644
--- a/client/src/api/queries.test.ts
+++ b/client/src/api/queries.test.ts
@@ -3,7 +3,7 @@ import { fetchRecipes } from "./queries";
// Mock the global fetch function
const globalFetchMock = vi.fn();
-global.fetch = globalFetchMock;
+globalThis.fetch = globalFetchMock as unknown as typeof fetch;
describe("fetchRecipes", () => {
beforeEach(() => {
diff --git a/client/src/components/RecipeDetail.tsx b/client/src/components/RecipeDetail.tsx
index 743bd33..87f6b23 100644
--- a/client/src/components/RecipeDetail.tsx
+++ b/client/src/components/RecipeDetail.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchRecipes } from "../api/queries";
import type { Recipe } from "../api/types";
+import { splitIngredients } from "../utils/ingredients";
export function RecipeDetail() {
const { id } = useParams<{ id: string }>();
@@ -44,12 +45,7 @@ export function RecipeDetail() {
);
}
- const ingredientList = recipe.ingredients
- ? recipe.ingredients
- .split(",")
- .map((i) => i.trim())
- .filter(Boolean)
- : [];
+ const ingredientList = splitIngredients(recipe.ingredients);
const directionList = recipe.directions
? recipe.directions
diff --git a/client/src/components/RecipeList.tsx b/client/src/components/RecipeList.tsx
index 1cda2c6..31324d0 100644
--- a/client/src/components/RecipeList.tsx
+++ b/client/src/components/RecipeList.tsx
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useTransition, useCallback } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { fetchRecipes } from "../api/queries";
+import { useDebounce } from "../hooks/useDebounce";
import type { Recipe, NumericSortField, SortOrder } from "../api/types";
@@ -166,6 +167,32 @@ export function RecipeList() {
};
}, [hasMore, offset, loadRecipes]);
+ // The field updates on every keystroke, but the URL (and so the API call)
+ // only changes once the user has stopped typing for 300 ms.
+ const [searchInput, setSearchInput] = useState(search);
+ const debouncedSearch = useDebounce(searchInput, 300);
+ const searchRef = useRef(search);
+
+ useEffect(() => {
+ searchRef.current = search;
+ }, [search]);
+
+ useEffect(() => {
+ if (debouncedSearch === searchRef.current) return;
+
+ startTransition(() => {
+ setSearchParams((prev) => {
+ if (debouncedSearch.trim()) {
+ prev.set("search", debouncedSearch);
+ } else {
+ prev.delete("search");
+ }
+
+ return prev;
+ });
+ });
+ }, [debouncedSearch, setSearchParams]);
+
const updateParam = (key: string, value: string) => {
startTransition(() => {
setSearchParams((prev) => {
@@ -194,9 +221,9 @@ export function RecipeList() {
placeholder="Search recipes..."
- value={search}
+ value={searchInput}
- onChange={(e) => updateParam("search", e.target.value)}
+ onChange={(e) => setSearchInput(e.target.value)}
style={{ padding: "8px", flex: "1 1 200px" }}
/>
diff --git a/client/src/hooks/useDebounce.test.ts b/client/src/hooks/useDebounce.test.ts
new file mode 100644
index 0000000..456df74
--- /dev/null
+++ b/client/src/hooks/useDebounce.test.ts
@@ -0,0 +1,49 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useDebounce } from "./useDebounce";
+
+describe("useDebounce", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("only returns the new value after the delay", () => {
+ const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
+ initialProps: { value: "a" },
+ });
+
+ rerender({ value: "ap" });
+ rerender({ value: "app" });
+ expect(result.current).toBe("a");
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(result.current).toBe("app");
+ });
+
+ it("restarts the timer while the value keeps changing", () => {
+ const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
+ initialProps: { value: "" },
+ });
+
+ rerender({ value: "p" });
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ rerender({ value: "pa" });
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(result.current).toBe("");
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+ expect(result.current).toBe("pa");
+ });
+});
diff --git a/client/src/hooks/useDebounce.ts b/client/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..5e55aad
--- /dev/null
+++ b/client/src/hooks/useDebounce.ts
@@ -0,0 +1,13 @@
+import { useEffect, useState } from "react";
+
+// Returns the value once it has stopped changing for `delay` ms.
+export function useDebounce(value: T, delay = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debounced;
+}
diff --git a/client/src/utils/ingredients.test.ts b/client/src/utils/ingredients.test.ts
new file mode 100644
index 0000000..a88f459
--- /dev/null
+++ b/client/src/utils/ingredients.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { splitIngredients } from "./ingredients";
+
+describe("splitIngredients", () => {
+ it("keeps notes after a comma with the ingredient they belong to", () => {
+ const text =
+ "8 small Granny Smith apples, or as needed, ½ cup unsalted butter, 1 (9 inch) double-crust pie pastry, thawed";
+
+ expect(splitIngredients(text)).toEqual([
+ "8 small Granny Smith apples, or as needed",
+ "½ cup unsalted butter",
+ "1 (9 inch) double-crust pie pastry, thawed",
+ ]);
+ });
+
+ it("keeps descriptions that span several commas together", () => {
+ expect(splitIngredients("4 apples, peeled, cored, and sliced, ¾ cup water")).toEqual([
+ "4 apples, peeled, cored, and sliced",
+ "¾ cup water",
+ ]);
+ });
+
+ it("starts a new item for ingredients listed without a quantity", () => {
+ expect(
+ splitIngredients(
+ "1 pinch dried thyme, salt and ground black pepper to taste, cooking spray",
+ ),
+ ).toEqual([
+ "1 pinch dried thyme",
+ "salt and ground black pepper to taste",
+ "cooking spray",
+ ]);
+ });
+
+ it("returns an empty list for missing text", () => {
+ expect(splitIngredients(null)).toEqual([]);
+ expect(splitIngredients("")).toEqual([]);
+ });
+});
diff --git a/client/src/utils/ingredients.ts b/client/src/utils/ingredients.ts
new file mode 100644
index 0000000..86cf81a
--- /dev/null
+++ b/client/src/utils/ingredients.ts
@@ -0,0 +1,48 @@
+// The dataset stores all ingredients in one comma-separated string, but commas
+// are also used inside a single ingredient ("8 apples, or as needed").
+// A comma only starts a new ingredient when the next part begins with a
+// quantity, or with an ingredient that is usually listed without one.
+
+const QUANTITY = /^[\d½¼¾⅓⅔⅛⅜⅝⅞⅕⅖⅗⅘⅙⅚]/;
+
+const NO_QUANTITY_STARTS = new Set([
+ "salt",
+ "sea",
+ "kosher",
+ "ground",
+ "freshly",
+ "pepper",
+ "cooking",
+ "water",
+ "ice",
+ "olive",
+ "vegetable",
+ "oil",
+ "aluminum",
+ "toothpicks",
+]);
+
+function startsNewIngredient(part: string): boolean {
+ if (QUANTITY.test(part)) return true;
+ const firstWord = part.split(/\s+/)[0]?.toLowerCase() ?? "";
+ return NO_QUANTITY_STARTS.has(firstWord);
+}
+
+export function splitIngredients(text: string | null | undefined): string[] {
+ if (!text) return [];
+
+ const items: string[] = [];
+
+ for (const raw of text.split(",")) {
+ const part = raw.trim();
+ if (!part) continue;
+
+ if (items.length === 0 || startsNewIngredient(part)) {
+ items.push(part);
+ } else {
+ items[items.length - 1] += `, ${part}`;
+ }
+ }
+
+ return items;
+}