diff --git a/README.md b/README.md index c0f70e5..7f60b44 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RecipeApp (T13-Project-2) +# RecipePal (T13-Project-2) A searchable recipe catalog built by team 13 in IT2810 Web Development (NTNU, autumn 2026), Project 2. You can search recipes from Epicurious, narrow them @@ -55,6 +55,24 @@ and headings, labels on every input, `aria-live` result counts, an accessible mobile menu (`aria-expanded`, Escape and click outside close it) and smooth scrolling only when the user has not asked for reduced motion. +### Hero background video + +The home hero plays four short cooking clips from Pexels (credits in +`frontend/public/CREDITS.md`). Choices made for sustainability and speed: + +- Only the poster (about 10-20 kB JPG, explicit size, `fetchpriority="high"`) + is part of the initial load. The video is attached after the page has loaded + and the browser is idle. +- Each clip is 8 s, no audio, VP9 WebM at 720p (about 0.3-0.7 MB) with a 480p + variant (about 0.2-0.4 MB) for screens up to 700 px. One clip is loaded at a + time; the next is fetched only in the last 2 s of the current one. +- Playback pauses when the hero scrolls out of view or the tab is hidden, and + a visible button lets the user pause it (WCAG 2.2.2). +- Reduced motion, data saver and 2g connections get the poster only. +- Re-encode with ffmpeg: `-an -vf scale=1280:-2,fps=24 -c:v libvpx-vp9 +-crf 38 -b:v 800k -maxrate 1000k -g 48` (480p: `scale=854:-2`, `-b:v 450k`). + The 1080p originals are not stored in the repository. + ## Working in this repository `main` is protected — you cannot push to it directly. Work on a branch and diff --git a/frontend/index.html b/frontend/index.html index 1507f27..1b2c240 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ content="Search thousands of recipes, filter by category and rating, and find what you can cook with the ingredients you already have." /> - RecipeApp + RecipePal – recipe search
diff --git a/frontend/public/CREDITS.md b/frontend/public/CREDITS.md new file mode 100644 index 0000000..8f1c673 --- /dev/null +++ b/frontend/public/CREDITS.md @@ -0,0 +1,16 @@ +# Credits + +## Hero background videos (`hero/`) + +Stock footage from [Pexels](https://www.pexels.com), used under the +[Pexels License](https://www.pexels.com/license/) (free to use and modify, +attribution not required). Re-encoded to 8 s, 720p/480p, no audio. + +| File | Pexels video id | Page | +| --------- | --------------- | ------------------------------------- | +| `steam.*` | 3195369 | https://www.pexels.com/video/3195369/ | +| `fry.*` | 3195393 | https://www.pexels.com/video/3195393/ | +| `egg.*` | 4555813 | https://www.pexels.com/video/4555813/ | +| `oil.*` | 4912636 | https://www.pexels.com/video/4912636/ | + +The creators' names were not looked up, so the clips are credited by id. diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..77fc279 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/hero/egg-sm.webm b/frontend/public/hero/egg-sm.webm new file mode 100644 index 0000000..eba55b8 Binary files /dev/null and b/frontend/public/hero/egg-sm.webm differ diff --git a/frontend/public/hero/egg.jpg b/frontend/public/hero/egg.jpg new file mode 100644 index 0000000..a6d9ba6 Binary files /dev/null and b/frontend/public/hero/egg.jpg differ diff --git a/frontend/public/hero/egg.webm b/frontend/public/hero/egg.webm new file mode 100644 index 0000000..69169c6 Binary files /dev/null and b/frontend/public/hero/egg.webm differ diff --git a/frontend/public/hero/fry-sm.webm b/frontend/public/hero/fry-sm.webm new file mode 100644 index 0000000..65a3545 Binary files /dev/null and b/frontend/public/hero/fry-sm.webm differ diff --git a/frontend/public/hero/fry.jpg b/frontend/public/hero/fry.jpg new file mode 100644 index 0000000..c50a4ed Binary files /dev/null and b/frontend/public/hero/fry.jpg differ diff --git a/frontend/public/hero/fry.webm b/frontend/public/hero/fry.webm new file mode 100644 index 0000000..504af46 Binary files /dev/null and b/frontend/public/hero/fry.webm differ diff --git a/frontend/public/hero/oil-sm.webm b/frontend/public/hero/oil-sm.webm new file mode 100644 index 0000000..a40f58c Binary files /dev/null and b/frontend/public/hero/oil-sm.webm differ diff --git a/frontend/public/hero/oil.jpg b/frontend/public/hero/oil.jpg new file mode 100644 index 0000000..7f232bd Binary files /dev/null and b/frontend/public/hero/oil.jpg differ diff --git a/frontend/public/hero/oil.webm b/frontend/public/hero/oil.webm new file mode 100644 index 0000000..3ab9919 Binary files /dev/null and b/frontend/public/hero/oil.webm differ diff --git a/frontend/public/hero/steam-sm.webm b/frontend/public/hero/steam-sm.webm new file mode 100644 index 0000000..d335236 Binary files /dev/null and b/frontend/public/hero/steam-sm.webm differ diff --git a/frontend/public/hero/steam.jpg b/frontend/public/hero/steam.jpg new file mode 100644 index 0000000..79b8610 Binary files /dev/null and b/frontend/public/hero/steam.jpg differ diff --git a/frontend/public/hero/steam.webm b/frontend/public/hero/steam.webm new file mode 100644 index 0000000..6fc0acb Binary files /dev/null and b/frontend/public/hero/steam.webm differ diff --git a/frontend/src/components/Breadcrumbs.test.tsx b/frontend/src/components/Breadcrumbs.test.tsx new file mode 100644 index 0000000..b9204a6 --- /dev/null +++ b/frontend/src/components/Breadcrumbs.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { expect, test } from 'vitest' +import Breadcrumbs from './Breadcrumbs' + +test('renders links for parents and marks the last item as the current page', () => { + render( + + + , + ) + + const nav = screen.getByRole('navigation', { name: 'Breadcrumb' }) + expect(nav.querySelectorAll('ol > li')).toHaveLength(3) + expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute( + 'href', + '/', + ) + expect(screen.getByRole('link', { name: 'Recipes' })).toBeInTheDocument() + + const current = screen.getByText('Pork Tenderloin') + expect(current).toHaveAttribute('aria-current', 'page') + expect(screen.queryByRole('link', { name: 'Pork Tenderloin' })).toBeNull() +}) diff --git a/frontend/src/components/Breadcrumbs.tsx b/frontend/src/components/Breadcrumbs.tsx new file mode 100644 index 0000000..8309c04 --- /dev/null +++ b/frontend/src/components/Breadcrumbs.tsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router' +import '../styles/Breadcrumbs.css' + +export type Crumb = { + label: string + // Omit on the last crumb: it is the current page and not a link. + to?: string +} + +export default function Breadcrumbs({ items }: { items: Crumb[] }) { + return ( + + ) +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 237b4e0..e17cec3 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -11,7 +11,7 @@ export default function Footer() { return ( ) } diff --git a/frontend/src/components/HeroVideo.test.tsx b/frontend/src/components/HeroVideo.test.tsx new file mode 100644 index 0000000..6a3d21f --- /dev/null +++ b/frontend/src/components/HeroVideo.test.tsx @@ -0,0 +1,112 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import HeroVideo from './HeroVideo' + +const play = vi.fn(() => Promise.resolve()) +const pause = vi.fn() + +beforeEach(() => { + // jsdom does not implement media playback. + vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(play) + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(pause) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + play.mockClear() + pause.mockClear() +}) + +function stubEnvironment({ + reduced = false, + saveData = false, +}: { reduced?: boolean; saveData?: boolean } = {}) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('reduced-motion') && reduced, + })) + vi.stubGlobal('navigator', { connection: { saveData } }) +} + +const getVideo = (container: HTMLElement) => container.querySelector('video')! + +test('always renders a sized poster, then attaches a muted decorative video once idle', async () => { + stubEnvironment() + const { container } = render() + + const img = container.querySelector('img')! + expect(img).toHaveAttribute('alt', '') + expect(img).toHaveAttribute('width') + expect(img).toHaveAttribute('height') + + const video = await vi.waitFor(() => { + const el = getVideo(container) + expect(el).not.toBeNull() + return el + }) + expect(video.muted).toBe(true) + expect(video).toHaveAttribute('preload', 'metadata') + expect(video.getAttribute('src')).toContain('hero/steam') + expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true') + expect( + screen.getByRole('button', { name: 'Pause background video' }), + ).toBeInTheDocument() +}) + +test('advances to the next clip when one ends, wrapping around the playlist', async () => { + stubEnvironment() + const { container } = render() + await vi.waitFor(() => expect(getVideo(container)).not.toBeNull()) + + const order = ['steam', 'fry', 'egg', 'oil', 'steam'] + for (const [i, name] of order.entries()) { + expect(getVideo(container).getAttribute('src')).toContain(`hero/${name}`) + if (i < order.length - 1) fireEvent.ended(getVideo(container)) + } +}) + +test('the pause button stops and resumes playback', async () => { + stubEnvironment() + const { container } = render() + await vi.waitFor(() => expect(getVideo(container)).not.toBeNull()) + await vi.waitFor(() => expect(play).toHaveBeenCalled()) + + fireEvent.click( + screen.getByRole('button', { name: 'Pause background video' }), + ) + expect(pause).toHaveBeenCalled() + + play.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Play background video' })) + expect(play).toHaveBeenCalled() +}) + +test('pauses while the tab is hidden', async () => { + stubEnvironment() + const { container } = render() + await vi.waitFor(() => expect(getVideo(container)).not.toBeNull()) + + pause.mockClear() + vi.spyOn(document, 'hidden', 'get').mockReturnValue(true) + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + expect(pause).toHaveBeenCalled() +}) + +test('shows only a still poster when reduced motion is requested', () => { + stubEnvironment({ reduced: true }) + const { container } = render() + + expect(getVideo(container)).toBeNull() + expect(container.querySelector('img')).toHaveAttribute('alt', '') + expect(screen.queryByRole('button')).toBeNull() +}) + +test('shows only a still poster when the user has data saver on', () => { + stubEnvironment({ saveData: true }) + const { container } = render() + + expect(getVideo(container)).toBeNull() + expect(screen.queryByRole('button')).toBeNull() +}) diff --git a/frontend/src/components/HeroVideo.tsx b/frontend/src/components/HeroVideo.tsx new file mode 100644 index 0000000..c02e1ba --- /dev/null +++ b/frontend/src/components/HeroVideo.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react' +import '../styles/HeroVideo.css' + +// Four short Pexels cooking clips (credits: public/CREDITS.md), played in turn. +// Each has a 720p file, a 480p "-sm" file for small screens and a poster that +// is the clip's first frame, so the switch between clips shows no flash. +const CLIPS = ['steam', 'fry', 'egg', 'oil'] + +// Poster dimensions: only used to reserve the aspect ratio (no layout shift). +const POSTER_WIDTH = 960 +const POSTER_HEIGHT = 540 + +const SMALL_SCREEN = '(max-width: 700px)' + +function matches(query: string) { + return ( + typeof window.matchMedia === 'function' && window.matchMedia(query).matches + ) +} + +// Motion is skipped (poster only) when the user asks for reduced motion or +// is on a data-saving / very slow connection. Saving data is the point of a +// green web app; a decorative video is the first thing to give up. +function shouldSkipVideo() { + if (matches('(prefers-reduced-motion: reduce)')) return true + const connection = ( + navigator as Navigator & { + connection?: { saveData?: boolean; effectiveType?: string } + } + ).connection + return ( + connection?.saveData === true || + connection?.effectiveType === '2g' || + connection?.effectiveType === 'slow-2g' + ) +} + +function videoUrl(base: string, clip: string) { + const suffix = matches(SMALL_SCREEN) ? '-sm' : '' + return `${base}hero/${clip}${suffix}.webm` +} + +// Runs `callback` once the page has finished loading and the browser is idle, +// so the video never competes with first paint or LCP. Returns a cleanup. +function whenIdle(callback: () => void) { + let cancelled = false + const run = () => { + if (cancelled) return + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(() => !cancelled && callback(), { + timeout: 2000, + }) + } else { + setTimeout(() => !cancelled && callback(), 0) + } + } + if (document.readyState === 'complete') run() + else window.addEventListener('load', run, { once: true }) + return () => { + cancelled = true + window.removeEventListener('load', run) + } +} + +// Decorative background for the hero section. The poster image is always +// rendered (it is what paints first and is the only thing most visitors on +// slow links ever download). The video is attached after load + idle, only +// plays while the hero is on screen and the tab visible, and only one clip +// is loaded at a time (the next is warmed up near the end of the current one). +// A pause button satisfies WCAG 2.2.2 (moving content > 5 s must be pausable). +export default function HeroVideo() { + const base = import.meta.env.BASE_URL + const videoRef = useRef(null) + const rootRef = useRef(null) + const preloadedFor = useRef(null) + + const [skip] = useState(shouldSkipVideo) + const [index, setIndex] = useState(0) + const [ready, setReady] = useState(false) // page loaded and idle + const [inView, setInView] = useState(true) + const [tabVisible, setTabVisible] = useState(!document.hidden) + const [userPaused, setUserPaused] = useState(false) + + useEffect(() => (skip ? undefined : whenIdle(() => setReady(true))), [skip]) + + useEffect(() => { + const root = rootRef.current + if (skip || !root || typeof IntersectionObserver === 'undefined') return + const observer = new IntersectionObserver(([entry]) => + setInView(entry.isIntersecting), + ) + observer.observe(root) + return () => observer.disconnect() + }, [skip]) + + useEffect(() => { + const onChange = () => setTabVisible(!document.hidden) + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) + }, []) + + // One place decides whether the video runs, so scrolling away, hiding the + // tab and the user's own pause cannot fight each other. + const shouldPlay = ready && inView && tabVisible && !userPaused + useEffect(() => { + const video = videoRef.current + if (!video) return + if (shouldPlay) void video.play?.()?.catch?.(() => setUserPaused(true)) + else video.pause?.() + }, [shouldPlay, index, ready]) + + const clip = CLIPS[index] + const poster = `${base}hero/${clip}.jpg` + + // Warm the HTTP cache for the next clip only when this one is nearly done. + const preloadNext = () => { + const video = videoRef.current + if (!video || preloadedFor.current === index) return + if (video.duration - video.currentTime > 2) return + preloadedFor.current = index + const next = document.createElement('video') + next.preload = 'auto' + next.src = videoUrl(base, CLIPS[(index + 1) % CLIPS.length]) + } + + return ( + <> + + {/* Outside the aria-hidden wrapper so assistive tech can reach it. */} + {!skip && ( + + )} + + ) +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 26becca..5337373 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -40,7 +40,7 @@ export default function Navbar() { return (
- RecipeApp + RecipePal