From 3cc2d16c800db4ecb9eeaae9e4672c937a78ccb5 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Fri, 19 Jun 2026 16:26:27 +0200 Subject: [PATCH 1/6] support cloudflare pages alongside github pages - drive base path from a single VITE_BASE_URL env (defaults to root '/') - derive router base, PWA manifest paths and prerender routes from it - make the nitro preset overridable via SERVER_PRESET - add wrangler.toml so the static output deploys to cloudflare pages --- app.config.ts | 27 ++++++++++++++++----------- scripts/getPrerenderRoutes.ts | 10 +++++++--- src/app.tsx | 4 +++- wrangler.toml | 3 +++ 4 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 wrangler.toml diff --git a/app.config.ts b/app.config.ts index 52ea252..cdbaf0a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,6 +3,10 @@ import { visualizer } from "rollup-plugin-visualizer"; import { VitePWA } from "vite-plugin-pwa"; import { getPrerenderRoutes } from "./scripts/getPrerenderRoutes.ts"; +// Single source of truth for the deploy base path (trailing slash included). +// GitHub Pages (project site) sets it to "/cookmark/"; Cloudflare Pages (root) uses "/". +const basePath = process.env.VITE_BASE_URL ?? "/"; + export default defineConfig({ vite: { plugins: [ @@ -54,18 +58,18 @@ export default defineConfig({ theme_color: "#ffffff", background_color: "#ffffff", display: "standalone", - scope: "/cookmark/", - start_url: "/cookmark/", + scope: basePath, + start_url: basePath, categories: ["food", "lifestyle"], icons: [ { - src: "/cookmark/web-app-manifest-192x192.png", + src: `${basePath}web-app-manifest-192x192.png`, sizes: "192x192", type: "image/png", purpose: "maskable any", }, { - src: "/cookmark/web-app-manifest-512x512.png", + src: `${basePath}web-app-manifest-512x512.png`, sizes: "512x512", type: "image/png", purpose: "maskable any", @@ -76,8 +80,8 @@ export default defineConfig({ name: "Search Recipes", short_name: "Search", description: "Search for recipes", - url: "/cookmark/", - icons: [{ src: "/cookmark/favicon-96x96.png", sizes: "96x96" }], + url: basePath, + icons: [{ src: `${basePath}favicon-96x96.png`, sizes: "96x96" }], }, ], }, @@ -85,12 +89,13 @@ export default defineConfig({ ], }, server: { - preset: "github-pages", - baseURL: process.env.GITHUB_REPOSITORY - ? `/${process.env.GITHUB_REPOSITORY.split("/")[1]}/` - : "/", + // "github-pages" emits a fully static site (the prerendered output also + // serves as-is from Cloudflare Pages). Override with SERVER_PRESET when a + // host-specific preset is needed (e.g. "cloudflare-pages" once R2/Functions land). + preset: process.env.SERVER_PRESET ?? "github-pages", + baseURL: basePath, prerender: { - routes: getPrerenderRoutes() as string[], + routes: getPrerenderRoutes(basePath) as string[], }, }, }); diff --git a/scripts/getPrerenderRoutes.ts b/scripts/getPrerenderRoutes.ts index 0505a94..715cb16 100644 --- a/scripts/getPrerenderRoutes.ts +++ b/scripts/getPrerenderRoutes.ts @@ -1,15 +1,19 @@ import * as fs from "node:fs"; import * as path from "node:path"; -export const getPrerenderRoutes = (): ReadonlyArray => { +export const getPrerenderRoutes = (basePath = "/"): ReadonlyArray => { + // Strip the trailing slash so routes join cleanly; "/" collapses to "". + const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + const homeRoute = base === "" ? "/" : base; + const dataDir = path.join(process.cwd(), "data"); if (!fs.existsSync(dataDir)) { - return ["/cookmark"]; + return [homeRoute]; } const files = fs.readdirSync(dataDir).filter((f) => f.endsWith(".json")); const recipeSlugs = files.map((file) => file.replace(".json", "")); - return ["/cookmark", ...recipeSlugs.map((slug) => `/cookmark/recipe/${slug}`)]; + return [homeRoute, ...recipeSlugs.map((slug) => `${base}/recipe/${slug}`)]; }; diff --git a/src/app.tsx b/src/app.tsx index b43997d..bbef4b9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -7,10 +7,12 @@ import { InstallPrompt } from "./components/InstallPrompt/InstallPrompt.jsx"; export default function App() { const base = import.meta.env.VITE_BASE_URL ?? "/"; + // Router base must not have a trailing slash; "/" collapses to "" (root). + const routerBase = base.endsWith("/") ? base.slice(0, -1) : base; return ( ( Cookmark diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..0ec4d28 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,3 @@ +name = "cookmark" +compatibility_date = "2026-06-19" +pages_build_output_dir = ".output/public" From 244929996a2ff71dd4797df6e63936c832ab2da6 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Sun, 21 Jun 2026 16:37:47 +0200 Subject: [PATCH 2/6] stream recipe videos privately from r2 via a worker - migrate deploy to a cloudflare worker with static assets (cloudflare_module preset) - add MEDIA r2 binding and a range-aware /media/[slug] streaming route - detect available videos at build and point recipe video_url at /media/ - adapt the player to each video's own aspect ratio (vertical reel or landscape) - serve from the access-protected custom domain; disable workers.dev - ignore local data/ and videos/ (sourced from the data branch / R2) --- .github/workflows/deploy.yml | 1 + .gitignore | 7 ++ app.config.ts | 35 +++++- biome.json | 10 +- scripts/getVideoSlugs.ts | 18 +++ .../RecipeVideo/RecipeVideo.module.css | 25 +++-- src/components/RecipeVideo/RecipeVideo.tsx | 1 + src/routes/media/[slug].ts | 104 ++++++++++++++++++ src/utils/loadRecipes.ts | 11 +- wrangler.toml | 3 - 10 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 scripts/getVideoSlugs.ts create mode 100644 src/routes/media/[slug].ts delete mode 100644 wrangler.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c6afcaa..c0e286b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,7 @@ concurrency: env: VITE_BASE_URL: /cookmark/ + SERVER_PRESET: github-pages jobs: build: diff --git a/.gitignore b/.gitignore index c877917..de6959d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ gitignore .DS_Store Thumbs.db bundle-report.html + +# local media source (uploaded to R2, not committed) +videos/ + +# local recipe data (sourced from the data branch / R2, not committed) +data/ +data.local-backup/ diff --git a/app.config.ts b/app.config.ts index cdbaf0a..b13a16a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -2,13 +2,20 @@ import { defineConfig } from "@solidjs/start/config"; import { visualizer } from "rollup-plugin-visualizer"; import { VitePWA } from "vite-plugin-pwa"; import { getPrerenderRoutes } from "./scripts/getPrerenderRoutes.ts"; +import { getVideoSlugs } from "./scripts/getVideoSlugs.ts"; // Single source of truth for the deploy base path (trailing slash included). // GitHub Pages (project site) sets it to "/cookmark/"; Cloudflare Pages (root) uses "/". const basePath = process.env.VITE_BASE_URL ?? "/"; +// Baked into the bundle so recipe pages know which slugs have a video to stream. +const videoSlugs = getVideoSlugs(); + export default defineConfig({ vite: { + define: { + __VIDEO_SLUGS__: JSON.stringify(videoSlugs), + }, plugins: [ visualizer({ filename: "bundle-report.html", @@ -89,13 +96,33 @@ export default defineConfig({ ], }, server: { - // "github-pages" emits a fully static site (the prerendered output also - // serves as-is from Cloudflare Pages). Override with SERVER_PRESET when a - // host-specific preset is needed (e.g. "cloudflare-pages" once R2/Functions land). - preset: process.env.SERVER_PRESET ?? "github-pages", + // Default target is a Cloudflare Worker with Static Assets: it serves the + // prerendered site for free and runs dynamic routes (e.g. media streamed + // from R2). GitHub Pages builds pass SERVER_PRESET=github-pages for a pure + // static site (no server runtime). + preset: process.env.SERVER_PRESET ?? "cloudflare_module", baseURL: basePath, prerender: { routes: getPrerenderRoutes(basePath) as string[], }, + // The media route streams from R2 at runtime — never prerender it. + routeRules: { + "/media/**": { prerender: false }, + }, + // Merged into the wrangler config nitro generates at .output/server. + // The ASSETS binding and `main` are added automatically by the preset. + cloudflare: { + // Generate .output/server/wrangler.json (+ deploy redirect) and enable + // nodejs_compat so `wrangler deploy` works from the project root. + deployConfig: true, + wrangler: { + name: "cookmark", + compatibility_date: "2025-07-15", + // Serve the app from the Access-protected custom domain. + routes: [{ pattern: "cookmark.kiralivan.eu", custom_domain: true }], + // Private bucket holding the recipe videos (videos/.mp4). + r2_buckets: [{ binding: "MEDIA", bucket_name: "cookmark" }], + }, + }, }, }); diff --git a/biome.json b/biome.json index 78e98f6..500223e 100644 --- a/biome.json +++ b/biome.json @@ -4,7 +4,15 @@ "root": true, "files": { "ignoreUnknown": true, - "includes": ["src/**/*", "!.output", "!.vinxi", "!.claude", "!data", "!bundle-report.html"] + "includes": [ + "src/**/*", + "!.output", + "!.vinxi", + "!.wrangler", + "!.claude", + "!data", + "!bundle-report.html" + ] }, "linter": { "rules": { diff --git a/scripts/getVideoSlugs.ts b/scripts/getVideoSlugs.ts new file mode 100644 index 0000000..e8ac89d --- /dev/null +++ b/scripts/getVideoSlugs.ts @@ -0,0 +1,18 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Slugs that have a video available, derived at build time from the local +// ./videos source (uploaded to R2 separately). Used to decide which recipe +// pages render a player. Returns [] when the folder is absent (e.g. CI). +export const getVideoSlugs = (): ReadonlyArray => { + const videosDir = path.join(process.cwd(), "videos"); + + if (!fs.existsSync(videosDir)) { + return []; + } + + return fs + .readdirSync(videosDir) + .filter((file) => file.endsWith(".mp4")) + .map((file) => file.replace(".mp4", "")); +}; diff --git a/src/components/RecipeVideo/RecipeVideo.module.css b/src/components/RecipeVideo/RecipeVideo.module.css index 8d526e3..0048fbd 100644 --- a/src/components/RecipeVideo/RecipeVideo.module.css +++ b/src/components/RecipeVideo/RecipeVideo.module.css @@ -24,21 +24,22 @@ background-color: var(--color-neutral-900); } +/* Center the player and let it take whatever aspect ratio the video has + (vertical reel or landscape), capped so tall reels don't dominate. */ .videoContainer { - position: relative; + display: flex; + justify-content: center; width: 100%; - aspect-ratio: 16 / 9; - background-color: var(--color-neutral-100); - border-radius: var(--radius-xl); - border: 1px solid var(--color-neutral-100); - overflow: hidden; - box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); } .video { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: min(80vh, 640px); + border-radius: var(--radius-xl); + border: 1px solid var(--color-neutral-100); + background-color: var(--color-neutral-900); + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); } diff --git a/src/components/RecipeVideo/RecipeVideo.tsx b/src/components/RecipeVideo/RecipeVideo.tsx index e46636b..83fcbfc 100644 --- a/src/components/RecipeVideo/RecipeVideo.tsx +++ b/src/components/RecipeVideo/RecipeVideo.tsx @@ -18,6 +18,7 @@ const RecipeVideo: Component = (props) => ( class={styles.video} src={props.src} controls + playsinline preload="metadata" aria-label={`Video for ${props.title}`} /> diff --git a/src/routes/media/[slug].ts b/src/routes/media/[slug].ts new file mode 100644 index 0000000..a3284ba --- /dev/null +++ b/src/routes/media/[slug].ts @@ -0,0 +1,104 @@ +import type { APIEvent } from "@solidjs/start/server"; + +// Minimal shape of the R2 bindings we use (avoids depending on +// @cloudflare/workers-types). The Worker runtime provides the real objects. +type R2Metadata = { + size: number; + writeHttpMetadata: (headers: Headers) => void; +}; + +type R2ObjectBody = R2Metadata & { + body: ReadableStream; +}; + +type R2Bucket = { + head: (key: string) => Promise; + get: ( + key: string, + options?: { range: { offset: number; length: number } }, + ) => Promise; +}; + +type MediaEnv = { + MEDIA?: R2Bucket; +}; + +// nitro's cloudflare-module runtime sets globalThis.__env__ to the Worker env +// on every request. Only read it inside the request lifecycle. +const getMediaBucket = (): R2Bucket | undefined => + (globalThis as unknown as { __env__?: MediaEnv }).__env__?.MEDIA; + +const buildBaseHeaders = (meta: R2Metadata): Headers => { + const headers = new Headers(); + meta.writeHttpMetadata(headers); + headers.set("accept-ranges", "bytes"); + headers.set("cache-control", "private, max-age=3600"); + if (!headers.has("content-type")) { + headers.set("content-type", "video/mp4"); + } + return headers; +}; + +const parseRange = ( + rangeHeader: string, + total: number, +): { start: number; end: number } | undefined => { + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match) { + return undefined; + } + const [, startStr, endStr] = match; + if (startStr === "") { + const suffix = Number.parseInt(endStr, 10); + if (Number.isNaN(suffix)) { + return undefined; + } + return { start: Math.max(total - suffix, 0), end: total - 1 }; + } + const start = Number.parseInt(startStr, 10); + const end = endStr === "" ? total - 1 : Math.min(Number.parseInt(endStr, 10), total - 1); + return { start, end }; +}; + +export const GET = async (event: APIEvent): Promise => { + const bucket = getMediaBucket(); + if (!bucket) { + return new Response("Media storage unavailable", { status: 500 }); + } + + const key = `videos/${event.params.slug}.mp4`; + const meta = await bucket.head(key); + if (!meta) { + return new Response("Not found", { status: 404 }); + } + + const baseHeaders = buildBaseHeaders(meta); + const rangeHeader = event.request.headers.get("range"); + + if (rangeHeader) { + const range = parseRange(rangeHeader, meta.size); + if (!range || range.start > range.end || range.start >= meta.size) { + return new Response("Range Not Satisfiable", { + status: 416, + headers: { "content-range": `bytes */${meta.size}` }, + }); + } + const length = range.end - range.start + 1; + const object = await bucket.get(key, { range: { offset: range.start, length } }); + if (!object) { + return new Response("Not found", { status: 404 }); + } + const headers = new Headers(baseHeaders); + headers.set("content-range", `bytes ${range.start}-${range.end}/${meta.size}`); + headers.set("content-length", String(length)); + return new Response(object.body, { status: 206, headers }); + } + + const object = await bucket.get(key); + if (!object) { + return new Response("Not found", { status: 404 }); + } + const headers = new Headers(baseHeaders); + headers.set("content-length", String(meta.size)); + return new Response(object.body, { status: 200, headers }); +}; diff --git a/src/utils/loadRecipes.ts b/src/utils/loadRecipes.ts index c79950a..ce43793 100644 --- a/src/utils/loadRecipes.ts +++ b/src/utils/loadRecipes.ts @@ -1,5 +1,10 @@ import type { Recipe, RecipeData } from "~/types/Recipe.ts"; +// Injected at build time (see app.config.ts) with the slugs that have a video. +declare const __VIDEO_SLUGS__: ReadonlyArray; + +const videoSlugs = new Set(__VIDEO_SLUGS__); + const FALLBACK_CREATED_AT = "1970-01-01T00:00:00.000Z"; const recipeModules = import.meta.glob("../../data/*.json", { @@ -46,7 +51,11 @@ export const getRecipeDataById = (id: string): RecipeData | undefined => { export const getRecipeDataBySlug = (slug: string): RecipeData | undefined => { const recipeEntries = Object.entries(recipeModules); const entry = recipeEntries.find(([path]) => extractSlugFromPath(path) === slug); - return entry ? entry[1] : undefined; + if (!entry) { + return undefined; + } + // Point at the same-origin streaming route only when a video exists. + return videoSlugs.has(slug) ? { ...entry[1], video_url: `/media/${slug}` } : entry[1]; }; export const getRecipeIdBySlug = (slug: string): string | undefined => { diff --git a/wrangler.toml b/wrangler.toml deleted file mode 100644 index 0ec4d28..0000000 --- a/wrangler.toml +++ /dev/null @@ -1,3 +0,0 @@ -name = "cookmark" -compatibility_date = "2026-06-19" -pages_build_output_dir = ".output/public" From 572b2769b6f57362dd600f7514622a9752ec2d1f Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Sun, 21 Jun 2026 17:07:34 +0200 Subject: [PATCH 3/6] add per-user favourites backed by cloudflare kv --- app.config.ts | 8 +- src/app.tsx | 5 +- .../FavoriteButton/FavoriteButton.module.css | 34 +++++++++ .../FavoriteButton/FavoriteButton.tsx | 40 ++++++++++ src/components/FilterDrawer/FilterDrawer.tsx | 9 ++- src/components/FilterPanel/FilterPanel.tsx | 16 ++++ .../RecipeList/RecipeList.module.css | 2 +- src/components/RecipeList/RecipeList.tsx | 1 + .../RecipeList/RecipeListItem.module.css | 22 +++++- src/components/RecipeList/RecipeListItem.tsx | 4 + src/constants/strings.ts | 6 ++ src/contexts/FavoritesContext.tsx | 73 +++++++++++++++++++ src/lib/access.ts | 29 ++++++++ src/lib/favoritesStore.ts | 41 +++++++++++ src/routes/api/favorites/[slug].ts | 35 +++++++++ src/routes/api/favorites/index.ts | 12 +++ src/routes/index.tsx | 21 +++++- src/routes/recipe/[slug].tsx | 2 + src/styles/variables.css | 3 + 19 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 src/components/FavoriteButton/FavoriteButton.module.css create mode 100644 src/components/FavoriteButton/FavoriteButton.tsx create mode 100644 src/contexts/FavoritesContext.tsx create mode 100644 src/lib/access.ts create mode 100644 src/lib/favoritesStore.ts create mode 100644 src/routes/api/favorites/[slug].ts create mode 100644 src/routes/api/favorites/index.ts diff --git a/app.config.ts b/app.config.ts index b13a16a..60907f7 100644 --- a/app.config.ts +++ b/app.config.ts @@ -105,9 +105,10 @@ export default defineConfig({ prerender: { routes: getPrerenderRoutes(basePath) as string[], }, - // The media route streams from R2 at runtime — never prerender it. + // Runtime-only routes (R2 streaming, per-user favourites) — never prerender. routeRules: { "/media/**": { prerender: false }, + "/api/**": { prerender: false }, }, // Merged into the wrangler config nitro generates at .output/server. // The ASSETS binding and `main` are added automatically by the preset. @@ -118,10 +119,15 @@ export default defineConfig({ wrangler: { name: "cookmark", compatibility_date: "2025-07-15", + // Per-user favourites trust the Access JWT, so the app must only be + // reachable through the Access-fronted custom domain — not workers.dev. + workers_dev: false, // Serve the app from the Access-protected custom domain. routes: [{ pattern: "cookmark.kiralivan.eu", custom_domain: true }], // Private bucket holding the recipe videos (videos/.mp4). r2_buckets: [{ binding: "MEDIA", bucket_name: "cookmark" }], + // Per-user favourite recipe slugs, keyed by Access email. + kv_namespaces: [{ binding: "FAVORITES", id: "9be88640af5546f5b2341287ae842757" }], }, }, }, diff --git a/src/app.tsx b/src/app.tsx index bbef4b9..0b7d8da 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,6 +4,7 @@ import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; import "./app.css"; import { InstallPrompt } from "./components/InstallPrompt/InstallPrompt.jsx"; +import { FavoritesProvider } from "./contexts/FavoritesContext.jsx"; export default function App() { const base = import.meta.env.VITE_BASE_URL ?? "/"; @@ -27,7 +28,9 @@ export default function App() { href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" /> - {props.children} + + {props.children} + diff --git a/src/components/FavoriteButton/FavoriteButton.module.css b/src/components/FavoriteButton/FavoriteButton.module.css new file mode 100644 index 0000000..787ceae --- /dev/null +++ b/src/components/FavoriteButton/FavoriteButton.module.css @@ -0,0 +1,34 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-1); + border: none; + background: transparent; + color: var(--color-neutral-400); + cursor: pointer; + border-radius: var(--radius-full); + transition: + color 0.15s ease, + transform 0.15s ease; +} + +.button:hover { + color: var(--color-neutral-700); +} + +.button.active { + color: var(--color-favorite); +} + +.button .material-symbols-outlined { + font-size: 1.5rem; +} + +.button.active .material-symbols-outlined { + font-variation-settings: "FILL" 1; +} + +.button:active { + transform: scale(0.9); +} diff --git a/src/components/FavoriteButton/FavoriteButton.tsx b/src/components/FavoriteButton/FavoriteButton.tsx new file mode 100644 index 0000000..e7ef955 --- /dev/null +++ b/src/components/FavoriteButton/FavoriteButton.tsx @@ -0,0 +1,40 @@ +import type { Component } from "solid-js"; +import { strings } from "~/constants/strings.ts"; +import { useFavorites } from "~/contexts/FavoritesContext.tsx"; +import styles from "./FavoriteButton.module.css"; + +type FavoriteButtonProps = { + slug: string; + name: string; +}; + +const FavoriteButton: Component = (props) => { + const { isFavorite, toggle } = useFavorites(); + const active = () => isFavorite(props.slug); + + // Cards are links, so prevent the click from navigating. + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + toggle(props.slug); + }; + + return ( + + ); +}; + +export default FavoriteButton; diff --git a/src/components/FilterDrawer/FilterDrawer.tsx b/src/components/FilterDrawer/FilterDrawer.tsx index 7c208e0..a523e18 100644 --- a/src/components/FilterDrawer/FilterDrawer.tsx +++ b/src/components/FilterDrawer/FilterDrawer.tsx @@ -13,15 +13,20 @@ type FilterDrawerProps = { difficultyFilter: DifficultyFilter; timeFilter: TimeFilter; tagFilter: TagFilter; + favoritesOnly: boolean; onDifficultyChange: (difficulty: DifficultyFilter) => void; onTimeChange: (time: TimeFilter) => void; onTagChange: (tag: TagFilter) => void; + onFavoritesOnlyChange: (enabled: boolean) => void; onClearAll: () => void; }; const FilterDrawer: Component = (props) => { const hasAnyFilter = () => - props.difficultyFilter.length > 0 || props.timeFilter.length > 0 || props.tagFilter.length > 0; + props.difficultyFilter.length > 0 || + props.timeFilter.length > 0 || + props.tagFilter.length > 0 || + props.favoritesOnly; return ( @@ -47,9 +52,11 @@ const FilterDrawer: Component = (props) => { difficultyFilter={props.difficultyFilter} timeFilter={props.timeFilter} tagFilter={props.tagFilter} + favoritesOnly={props.favoritesOnly} onDifficultyChange={props.onDifficultyChange} onTimeChange={props.onTimeChange} onTagChange={props.onTagChange} + onFavoritesOnlyChange={props.onFavoritesOnlyChange} /> diff --git a/src/components/FilterPanel/FilterPanel.tsx b/src/components/FilterPanel/FilterPanel.tsx index 1e70029..82f6d0e 100644 --- a/src/components/FilterPanel/FilterPanel.tsx +++ b/src/components/FilterPanel/FilterPanel.tsx @@ -10,9 +10,11 @@ type FilterPanelProps = { difficultyFilter: DifficultyFilter; timeFilter: TimeFilter; tagFilter: TagFilter; + favoritesOnly: boolean; onDifficultyChange: (difficulty: DifficultyFilter) => void; onTimeChange: (time: TimeFilter) => void; onTagChange: (tag: TagFilter) => void; + onFavoritesOnlyChange: (enabled: boolean) => void; }; const toggleValue = (current: ReadonlyArray, value: T): T[] => @@ -61,6 +63,20 @@ const FilterPanel: Component = (props) => { return (
+
+

{strings.favorites.sectionTitle}

+
+ +
+
= (props) => { {strings.recipeList.name} {strings.recipeList.difficulty} {strings.recipeList.time} +
diff --git a/src/components/RecipeList/RecipeListItem.module.css b/src/components/RecipeList/RecipeListItem.module.css index da7f725..9f042ee 100644 --- a/src/components/RecipeList/RecipeListItem.module.css +++ b/src/components/RecipeList/RecipeListItem.module.css @@ -1,6 +1,6 @@ .listItem { display: grid; - grid-template-columns: 1fr 120px 100px; + grid-template-columns: 1fr 120px 100px 48px; gap: var(--space-4); align-items: center; width: 100%; @@ -67,6 +67,12 @@ text-align: right; } +.favorite { + display: flex; + align-items: center; + justify-content: center; +} + @media (max-width: 768px) { .listItem { grid-template-columns: 1fr auto; @@ -76,17 +82,29 @@ } .title { - grid-column: 1 / -1; + grid-column: 1; + grid-row: 1; font-size: var(--text-md); } + .favorite { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + .difficulty { + grid-column: 1; + grid-row: 2; justify-self: start; font-size: 10px; padding: var(--space-1) var(--space-2); } .time { + grid-column: 2; + grid-row: 2; + text-align: right; font-size: var(--text-xs); } } diff --git a/src/components/RecipeList/RecipeListItem.tsx b/src/components/RecipeList/RecipeListItem.tsx index 7b028a2..447a69b 100644 --- a/src/components/RecipeList/RecipeListItem.tsx +++ b/src/components/RecipeList/RecipeListItem.tsx @@ -1,5 +1,6 @@ import { A, useLocation } from "@solidjs/router"; import type { Component } from "solid-js"; +import FavoriteButton from "~/components/FavoriteButton/FavoriteButton.tsx"; import styles from "./RecipeListItem.module.css"; type RecipeListItemProps = { @@ -23,6 +24,9 @@ const RecipeListItem: Component = (props) => { {props.difficulty} {props.time} +
+ +
); }; diff --git a/src/constants/strings.ts b/src/constants/strings.ts index c0b3e9c..3ec211c 100644 --- a/src/constants/strings.ts +++ b/src/constants/strings.ts @@ -75,6 +75,12 @@ export const strings = { clearAll: "Clear all", filtersButton: "Filters", }, + favorites: { + sectionTitle: "Favourites", + only: "Favourites only", + add: (name: string) => `Add ${name} to favourites`, + remove: (name: string) => `Remove ${name} from favourites`, + }, recipeList: { name: "Recipe Name", difficulty: "Difficulty", diff --git a/src/contexts/FavoritesContext.tsx b/src/contexts/FavoritesContext.tsx new file mode 100644 index 0000000..316e25e --- /dev/null +++ b/src/contexts/FavoritesContext.tsx @@ -0,0 +1,73 @@ +import { + type Accessor, + createContext, + createSignal, + onMount, + type ParentComponent, + useContext, +} from "solid-js"; + +type FavoritesContextValue = { + favorites: Accessor>; + isFavorite: (slug: string) => boolean; + toggle: (slug: string) => void; +}; + +const FavoritesContext = createContext(); + +export const FavoritesProvider: ParentComponent = (props) => { + const [favorites, setFavorites] = createSignal>(new Set()); + + // Runs on the client after hydration — favourites are per-user runtime data. + onMount(async () => { + try { + const response = await fetch("/api/favorites"); + if (response.ok) { + const data = (await response.json()) as { slugs?: ReadonlyArray }; + setFavorites(new Set(data.slugs ?? [])); + } + } catch { + // Leave favourites empty if the fetch fails. + } + }); + + const isFavorite = (slug: string): boolean => favorites().has(slug); + + const toggle = (slug: string): void => { + const wasFavorite = favorites().has(slug); + + const apply = (add: boolean) => { + const next = new Set(favorites()); + if (add) { + next.add(slug); + } else { + next.delete(slug); + } + setFavorites(next); + }; + + // Optimistic update, reverted if the request fails. + apply(!wasFavorite); + fetch(`/api/favorites/${slug}`, { method: wasFavorite ? "DELETE" : "PUT" }) + .then((response) => { + if (!response.ok) { + apply(wasFavorite); + } + }) + .catch(() => apply(wasFavorite)); + }; + + return ( + + {props.children} + + ); +}; + +export const useFavorites = (): FavoritesContextValue => { + const context = useContext(FavoritesContext); + if (!context) { + throw new Error("useFavorites must be used within a FavoritesProvider"); + } + return context; +}; diff --git a/src/lib/access.ts b/src/lib/access.ts new file mode 100644 index 0000000..0e7e2a1 --- /dev/null +++ b/src/lib/access.ts @@ -0,0 +1,29 @@ +// Identity from Cloudflare Access. Access already blocks unauthenticated +// requests at the edge, so reading the email claim from the forwarded JWT is +// sufficient for keying per-user data (no signature verification needed here). +const FALLBACK_USER = "shared"; + +const decodeJwtPayload = (token: string): Record | undefined => { + const parts = token.split("."); + if (parts.length < 2) { + return undefined; + } + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + return JSON.parse(atob(padded)) as Record; + } catch { + return undefined; + } +}; + +export const getUserEmail = (request: Request): string => { + const token = request.headers.get("cf-access-jwt-assertion"); + if (token) { + const email = decodeJwtPayload(token)?.email; + if (typeof email === "string" && email.length > 0) { + return email; + } + } + return FALLBACK_USER; +}; diff --git a/src/lib/favoritesStore.ts b/src/lib/favoritesStore.ts new file mode 100644 index 0000000..3b7cca7 --- /dev/null +++ b/src/lib/favoritesStore.ts @@ -0,0 +1,41 @@ +// Minimal KV shape (avoids depending on @cloudflare/workers-types). The Worker +// runtime provides the real binding via globalThis.__env__. +type KVNamespace = { + get: (key: string) => Promise; + put: (key: string, value: string) => Promise; +}; + +type FavoritesEnv = { + FAVORITES?: KVNamespace; +}; + +export const getFavoritesKv = (): KVNamespace | undefined => + (globalThis as unknown as { __env__?: FavoritesEnv }).__env__?.FAVORITES; + +const keyFor = (email: string): string => `favorites:${email}`; + +export const readFavorites = async ( + kv: KVNamespace, + email: string, +): Promise> => { + const raw = await kv.get(keyFor(email)); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) + ? parsed.filter((slug): slug is string => typeof slug === "string") + : []; + } catch { + return []; + } +}; + +export const writeFavorites = async ( + kv: KVNamespace, + email: string, + slugs: ReadonlyArray, +): Promise => { + await kv.put(keyFor(email), JSON.stringify(slugs)); +}; diff --git a/src/routes/api/favorites/[slug].ts b/src/routes/api/favorites/[slug].ts new file mode 100644 index 0000000..eee218b --- /dev/null +++ b/src/routes/api/favorites/[slug].ts @@ -0,0 +1,35 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { getUserEmail } from "~/lib/access.ts"; +import { getFavoritesKv, readFavorites, writeFavorites } from "~/lib/favoritesStore.ts"; + +export const PUT = async (event: APIEvent): Promise => { + const kv = getFavoritesKv(); + if (!kv) { + return new Response("Favorites unavailable", { status: 500 }); + } + const email = getUserEmail(event.request); + const slug = event.params.slug; + const current = await readFavorites(kv, email); + if (!current.includes(slug)) { + await writeFavorites(kv, email, [...current, slug]); + } + return new Response(null, { status: 204 }); +}; + +export const DELETE = async (event: APIEvent): Promise => { + const kv = getFavoritesKv(); + if (!kv) { + return new Response("Favorites unavailable", { status: 500 }); + } + const email = getUserEmail(event.request); + const slug = event.params.slug; + const current = await readFavorites(kv, email); + if (current.includes(slug)) { + await writeFavorites( + kv, + email, + current.filter((entry) => entry !== slug), + ); + } + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/api/favorites/index.ts b/src/routes/api/favorites/index.ts new file mode 100644 index 0000000..c07c01c --- /dev/null +++ b/src/routes/api/favorites/index.ts @@ -0,0 +1,12 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { getUserEmail } from "~/lib/access.ts"; +import { getFavoritesKv, readFavorites } from "~/lib/favoritesStore.ts"; + +export const GET = async (event: APIEvent): Promise => { + const kv = getFavoritesKv(); + if (!kv) { + return new Response("Favorites unavailable", { status: 500 }); + } + const slugs = await readFavorites(kv, getUserEmail(event.request)); + return Response.json({ slugs }); +}; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 497cd82..672e467 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -15,6 +15,7 @@ import { DEFAULT_SORT, type SortValue, sortValues } from "~/constants/sortOption import { strings } from "~/constants/strings.ts"; import { type TagFilter, type TagValue, tagValues } from "~/constants/tagOptions.ts"; import { type TimeFilter, type TimeValue, timeValues } from "~/constants/timeOptions.ts"; +import { useFavorites } from "~/contexts/FavoritesContext.tsx"; import type { Recipe } from "~/types/Recipe.ts"; import { loadRecipes } from "~/utils/loadRecipes.ts"; import styles from "./index.module.css"; @@ -37,6 +38,9 @@ const Home = () => { const recipes: Recipe[] = loadRecipes(); const [searchParams, setSearchParams] = useSearchParams(); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = createSignal(false); + const favorites = useFavorites(); + + const favoritesOnly = createMemo(() => searchParams.fav === "1"); const difficultyFilter = createMemo( (): DifficultyFilter => @@ -148,13 +152,22 @@ const Home = () => { }); }) .filter((recipe) => tags.length === 0 || tags.some((tag) => recipe.tags.includes(tag))) + .filter((recipe) => !favoritesOnly() || favorites.isFavorite(recipe.url_slug)) .sort(getSortComparator(sort)); }); const activeFilterCount = createMemo( - () => difficultyFilter().length + timeFilter().length + tagFilter().length, + () => + difficultyFilter().length + + timeFilter().length + + tagFilter().length + + (favoritesOnly() ? 1 : 0), ); + const handleFavoritesOnlyChange = (enabled: boolean) => { + setSearchParams({ ...searchParams, fav: enabled ? "1" : undefined, page: undefined }); + }; + const handleDifficultyFilter = (difficulty: DifficultyFilter) => { setSearchParams({ ...searchParams, @@ -177,6 +190,7 @@ const Home = () => { difficulty: undefined, time: undefined, tag: undefined, + fav: undefined, page: undefined, }); }; @@ -188,6 +202,7 @@ const Home = () => { difficulty: undefined, time: undefined, tag: undefined, + fav: undefined, page: undefined, }); }; @@ -239,9 +254,11 @@ const Home = () => { difficultyFilter={difficultyFilter()} timeFilter={timeFilter()} tagFilter={tagFilter()} + favoritesOnly={favoritesOnly()} onDifficultyChange={handleDifficultyFilter} onTimeChange={handleTimeFilter} onTagChange={handleTagFilter} + onFavoritesOnlyChange={handleFavoritesOnlyChange} />
@@ -292,9 +309,11 @@ const Home = () => { difficultyFilter={difficultyFilter()} timeFilter={timeFilter()} tagFilter={tagFilter()} + favoritesOnly={favoritesOnly()} onDifficultyChange={handleDifficultyFilter} onTimeChange={handleTimeFilter} onTagChange={handleTagFilter} + onFavoritesOnlyChange={handleFavoritesOnlyChange} onClearAll={handleClearAllFilters} /> diff --git a/src/routes/recipe/[slug].tsx b/src/routes/recipe/[slug].tsx index 21419cc..864579a 100644 --- a/src/routes/recipe/[slug].tsx +++ b/src/routes/recipe/[slug].tsx @@ -1,5 +1,6 @@ import { A, useLocation, useParams } from "@solidjs/router"; import { type Component, For, Show } from "solid-js"; +import FavoriteButton from "~/components/FavoriteButton/FavoriteButton.tsx"; import RecipeVideo from "~/components/RecipeVideo/RecipeVideo.tsx"; import { strings } from "~/constants/strings.ts"; import { getRecipeDataBySlug } from "~/utils/loadRecipes.ts"; @@ -78,6 +79,7 @@ const RecipePage: Component = () => { Cookmark
+ Date: Sun, 21 Jun 2026 17:12:49 +0200 Subject: [PATCH 4/6] mock favourites context in recipe list tests --- src/components/RecipeList/RecipeList.test.tsx | 8 ++++++++ src/components/RecipeList/RecipeListItem.test.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/RecipeList/RecipeList.test.tsx b/src/components/RecipeList/RecipeList.test.tsx index ba3630e..508ba87 100644 --- a/src/components/RecipeList/RecipeList.test.tsx +++ b/src/components/RecipeList/RecipeList.test.tsx @@ -15,6 +15,14 @@ vi.mock("@solidjs/router", () => ({ useLocation: () => ({ search: "" }), })); +vi.mock("~/contexts/FavoritesContext.tsx", () => ({ + useFavorites: () => ({ + favorites: () => new Set(), + isFavorite: () => false, + toggle: () => {}, + }), +})); + describe("", () => { beforeEach(() => { window.scrollTo = vi.fn(); diff --git a/src/components/RecipeList/RecipeListItem.test.tsx b/src/components/RecipeList/RecipeListItem.test.tsx index 3ff6db6..9e2732a 100644 --- a/src/components/RecipeList/RecipeListItem.test.tsx +++ b/src/components/RecipeList/RecipeListItem.test.tsx @@ -12,6 +12,14 @@ vi.mock("@solidjs/router", () => ({ useLocation: () => ({ search: "" }), })); +vi.mock("~/contexts/FavoritesContext.tsx", () => ({ + useFavorites: () => ({ + favorites: () => new Set(), + isFavorite: () => false, + toggle: () => {}, + }), +})); + describe("", () => { it("renders recipe item with all props", () => { const { getByText } = render(() => ( From 40ed889b22e9362e8c1ae912caee899cb4971cc0 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Sun, 21 Jun 2026 17:12:49 +0200 Subject: [PATCH 5/6] bump transitive deps to clear form-data and esbuild advisories --- package-lock.json | 224 +++++++++++++++++++++++----------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c7cc26..10aa39f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8164,17 +8164,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -10376,9 +10376,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -10393,9 +10393,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -10410,9 +10410,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -10427,9 +10427,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -10444,9 +10444,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -10461,9 +10461,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -10478,9 +10478,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -10495,9 +10495,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -10512,9 +10512,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -10529,9 +10529,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -10546,9 +10546,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -10563,9 +10563,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -10580,9 +10580,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -10597,9 +10597,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -10614,9 +10614,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -10631,9 +10631,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -10648,9 +10648,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -10665,9 +10665,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -10682,9 +10682,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -10699,9 +10699,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -10716,9 +10716,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -10733,9 +10733,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -10750,9 +10750,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -10767,9 +10767,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -10784,9 +10784,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -10801,9 +10801,9 @@ } }, "node_modules/nitropack/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -10841,9 +10841,9 @@ "license": "MIT" }, "node_modules/nitropack/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10854,32 +10854,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/nitropack/node_modules/fresh": { From 1fc9c6bf52f7ef5e3a03d2da0c949719ac02577c Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Sun, 21 Jun 2026 17:20:12 +0200 Subject: [PATCH 6/6] increase recipe page size from 10 to 15 --- src/components/RecipeList/RecipeList.test.tsx | 30 +++++++++---------- src/components/RecipeList/RecipeList.tsx | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/RecipeList/RecipeList.test.tsx b/src/components/RecipeList/RecipeList.test.tsx index 508ba87..9cb2c77 100644 --- a/src/components/RecipeList/RecipeList.test.tsx +++ b/src/components/RecipeList/RecipeList.test.tsx @@ -39,7 +39,7 @@ describe("", () => { }; it("renders first page of recipes with pagination", () => { - const mockRecipes: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ + const mockRecipes: ReadonlyArray = Array.from({ length: 20 }, (_, i) => ({ id: `${i + 1}`, url_slug: `recipe_${i + 1}`, name: `Recipe ${i + 1}`, @@ -57,15 +57,15 @@ describe("", () => { )); expect(getByText("Recipe 1")).toBeInTheDocument(); - expect(getByText("Recipe 5")).toBeInTheDocument(); expect(getByText("Recipe 10")).toBeInTheDocument(); + expect(getByText("Recipe 15")).toBeInTheDocument(); - expect(queryByText("Recipe 11")).not.toBeInTheDocument(); - expect(queryByText("Recipe 15")).not.toBeInTheDocument(); + expect(queryByText("Recipe 16")).not.toBeInTheDocument(); + expect(queryByText("Recipe 20")).not.toBeInTheDocument(); }); it("calls onPageChange when next is clicked", async () => { - const mockRecipes: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ + const mockRecipes: ReadonlyArray = Array.from({ length: 20 }, (_, i) => ({ id: `${i + 1}`, url_slug: `recipe_${i + 1}`, name: `Recipe ${i + 1}`, @@ -90,7 +90,7 @@ describe("", () => { }); it("renders correct page when currentPage prop changes", () => { - const mockRecipes: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ + const mockRecipes: ReadonlyArray = Array.from({ length: 20 }, (_, i) => ({ id: `${i + 1}`, url_slug: `recipe_${i + 1}`, name: `Recipe ${i + 1}`, @@ -109,12 +109,12 @@ describe("", () => { )); expect(screen.queryByText("Recipe 1")).not.toBeInTheDocument(); - expect(screen.getByText("Recipe 11")).toBeInTheDocument(); - expect(screen.getByText("Recipe 15")).toBeInTheDocument(); + expect(screen.getByText("Recipe 16")).toBeInTheDocument(); + expect(screen.getByText("Recipe 20")).toBeInTheDocument(); }); - it("does not show pagination for 10 or fewer recipes", () => { - const mockRecipes: ReadonlyArray = Array.from({ length: 10 }, (_, i) => ({ + it("does not show pagination for 15 or fewer recipes", () => { + const mockRecipes: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ id: `${i + 1}`, url_slug: `recipe_${i + 1}`, name: `Recipe ${i + 1}`, @@ -130,7 +130,7 @@ describe("", () => { render(() => ); expect(screen.getByText("Recipe 1")).toBeInTheDocument(); - expect(screen.getByText("Recipe 10")).toBeInTheDocument(); + expect(screen.getByText("Recipe 15")).toBeInTheDocument(); expect(screen.queryByLabelText("Next")).not.toBeInTheDocument(); }); @@ -159,7 +159,7 @@ describe("", () => { unmount(); cleanup(); - const mockRecipes2: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ + const mockRecipes2: ReadonlyArray = Array.from({ length: 20 }, (_, i) => ({ id: `${i + 10}`, url_slug: `new_recipe_${i + 10}`, name: `New Recipe ${i + 10}`, @@ -177,8 +177,8 @@ describe("", () => { )); expect(screen.getByText("New Recipe 10")).toBeInTheDocument(); - expect(screen.getByText("New Recipe 19")).toBeInTheDocument(); - expect(screen.queryByText("New Recipe 20")).not.toBeInTheDocument(); + expect(screen.getByText("New Recipe 24")).toBeInTheDocument(); + expect(screen.queryByText("New Recipe 25")).not.toBeInTheDocument(); }); it("renders empty list when no recipes provided", () => { @@ -208,7 +208,7 @@ describe("", () => { }); it("scrolls to top when page changes", async () => { - const mockRecipes: ReadonlyArray = Array.from({ length: 15 }, (_, i) => ({ + const mockRecipes: ReadonlyArray = Array.from({ length: 20 }, (_, i) => ({ id: `${i + 1}`, url_slug: `recipe_${i + 1}`, name: `Recipe ${i + 1}`, diff --git a/src/components/RecipeList/RecipeList.tsx b/src/components/RecipeList/RecipeList.tsx index cca330b..bbaa2a3 100644 --- a/src/components/RecipeList/RecipeList.tsx +++ b/src/components/RecipeList/RecipeList.tsx @@ -12,7 +12,7 @@ type RecipeListProps = { onResetAll?: () => void; }; -const ITEMS_PER_PAGE = 10; +const ITEMS_PER_PAGE = 15; const RecipeList: Component = (props) => { const paginatedRecipes = createMemo(() => {