Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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."
/>
<meta name="theme-color" content="#1F2A24" />
<title>RecipeApp</title>
<title>RecipePal – recipe search</title>
</head>
<body>
<div id="root"></div>
Expand Down
16 changes: 16 additions & 0 deletions frontend/public/CREDITS.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion frontend/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/hero/egg-sm.webm
Binary file not shown.
Binary file added frontend/public/hero/egg.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/hero/egg.webm
Binary file not shown.
Binary file added frontend/public/hero/fry-sm.webm
Binary file not shown.
Binary file added frontend/public/hero/fry.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/hero/fry.webm
Binary file not shown.
Binary file added frontend/public/hero/oil-sm.webm
Binary file not shown.
Binary file added frontend/public/hero/oil.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/hero/oil.webm
Binary file not shown.
Binary file added frontend/public/hero/steam-sm.webm
Binary file not shown.
Binary file added frontend/public/hero/steam.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/hero/steam.webm
Binary file not shown.
30 changes: 30 additions & 0 deletions frontend/src/components/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<Breadcrumbs
items={[
{ label: 'Home', to: '/' },
{ label: 'Recipes', to: '/' },
{ label: 'Pork Tenderloin' },
]}
/>
</MemoryRouter>,
)

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()
})
26 changes: 26 additions & 0 deletions frontend/src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav aria-label="Breadcrumb" className="breadcrumbs">
<ol>
{items.map((item) => (
<li key={item.label}>
{item.to ? (
<Link to={item.to}>{item.label}</Link>
) : (
<span aria-current="page">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
)
}
4 changes: 2 additions & 2 deletions frontend/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function Footer() {
return (
<footer className="footer">
<section className="footer-section">
<h2>RecipeApp</h2>
<h2>RecipePal</h2>
<p>Find recipes based on ingredients you already have.</p>
</section>

Expand All @@ -30,7 +30,7 @@ export default function Footer() {
<p>Recipes from the Epicurious dataset</p>
</section>

<p className="footer-copyright">RecipeApp © 2026</p>
<p className="footer-copyright">RecipePal © 2026</p>
</footer>
)
}
112 changes: 112 additions & 0 deletions frontend/src/components/HeroVideo.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<HeroVideo />)

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(<HeroVideo />)
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(<HeroVideo />)
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(<HeroVideo />)
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(<HeroVideo />)

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(<HeroVideo />)

expect(getVideo(container)).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})
Loading