From 9e3e2da4d2acc065a5e4d1be16ca51b78d506140 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:46:16 +0000
Subject: [PATCH] feature: port the app shell, router, header, footer and
settings modal to React
Co-Authored-By: Vibha Seshadri
---
web/src/App.test.tsx | 69 +++++++++++----
web/src/App.tsx | 29 +++++--
web/src/core/Footer.test.tsx | 17 ++++
web/src/core/Footer.tsx | 16 ++++
web/src/core/Header.test.tsx | 80 ++++++++++++++++++
web/src/core/Header.tsx | 51 +++++++++++
web/src/core/Settings.test.tsx | 141 +++++++++++++++++++++++++++++++
web/src/core/Settings.tsx | 100 ++++++++++++++++++++++
web/src/core/footer.scss | 23 +++++
web/src/core/header.scss | 149 +++++++++++++++++++++++++++++++++
web/src/core/settings.scss | 75 +++++++++++++++++
web/src/main.tsx | 7 +-
web/src/routes.test.tsx | 94 +++++++++++++++++++++
web/src/routes.tsx | 31 +++++++
14 files changed, 861 insertions(+), 21 deletions(-)
create mode 100644 web/src/core/Footer.test.tsx
create mode 100644 web/src/core/Footer.tsx
create mode 100644 web/src/core/Header.test.tsx
create mode 100644 web/src/core/Header.tsx
create mode 100644 web/src/core/Settings.test.tsx
create mode 100644 web/src/core/Settings.tsx
create mode 100644 web/src/core/footer.scss
create mode 100644 web/src/core/header.scss
create mode 100644 web/src/core/settings.scss
create mode 100644 web/src/routes.test.tsx
create mode 100644 web/src/routes.tsx
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index de1a37e59..9323ceac6 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,40 +1,81 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
import { SettingsProvider } from './context/SettingsContext';
import { stubMatchMedia } from './testUtils/matchMedia';
+function renderApp(initialEntry = '/news/1') {
+ vi.stubGlobal('scrollTo', vi.fn());
+
+ return render(
+
+
+
+ }>
+ news feed
} />
+ show feed} />
+
+
+
+
+ );
+}
+
afterEach(() => {
vi.unstubAllGlobals();
});
describe('App', () => {
- it('wraps its children in the themed shell', () => {
+ it('renders the themed shell with the header, the routed page and the footer', () => {
stubMatchMedia(false);
- const { container } = render(
-
-
- content
-
-
- );
+ const { container } = renderApp();
expect(container.querySelector('.default .body-cover')).not.toBeNull();
- expect(screen.getByText('content').parentElement).toHaveClass('wrapper');
+ expect(screen.getByText('news feed').parentElement).toHaveClass('wrapper');
+ expect(container.querySelector('.wrapper #header')).not.toBeNull();
+ expect(container.querySelector('.wrapper #footer')).not.toBeNull();
});
it('applies the theme coming from the settings', () => {
localStorage.setItem('theme', 'amoledblack');
stubMatchMedia(false);
- const { container } = render(
-
-
-
- );
+ const { container } = renderApp();
expect(container.querySelector('.amoledblack')).not.toBeNull();
});
+
+ it('sends a Google Analytics pageview on the initial render and on every navigation', async () => {
+ stubMatchMedia(false);
+ const ga = vi.fn();
+ vi.stubGlobal('ga', ga);
+
+ renderApp();
+
+ expect(ga.mock.calls).toEqual([
+ ['set', 'page', '/news/1'],
+ ['send', 'pageview'],
+ ]);
+
+ ga.mockClear();
+ await userEvent.click(screen.getByRole('link', { name: 'show' }));
+
+ expect(screen.getByText('show feed')).toBeInTheDocument();
+ expect(ga.mock.calls).toEqual([
+ ['set', 'page', '/show/1'],
+ ['send', 'pageview'],
+ ]);
+ });
+
+ it('does not throw when Google Analytics is not loaded', () => {
+ stubMatchMedia(false);
+ vi.stubGlobal('ga', undefined);
+
+ expect(() => renderApp()).not.toThrow();
+ expect(screen.getByText('news feed')).toBeInTheDocument();
+ });
});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ed63d3622..c47d2f5cd 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,19 +1,38 @@
-import { ReactNode } from 'react';
+import { useEffect } from 'react';
+import { Outlet, useLocation } from 'react-router-dom';
import { useSettings } from './context/SettingsContext';
+import { Footer } from './core/Footer';
+import { Header } from './core/Header';
import './App.scss';
-export interface AppProps {
- children?: ReactNode;
+declare global {
+ interface Window {
+ ga?: (...args: unknown[]) => void;
+ }
}
-export function App({ children }: AppProps) {
+export function App() {
const { settings } = useSettings();
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ if (typeof window.ga !== 'function') {
+ return;
+ }
+
+ window.ga('set', 'page', pathname);
+ window.ga('send', 'pageview');
+ }, [pathname]);
return (
);
}
diff --git a/web/src/core/Footer.test.tsx b/web/src/core/Footer.test.tsx
new file mode 100644
index 000000000..91e7c892e
--- /dev/null
+++ b/web/src/core/Footer.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { Footer } from './Footer';
+
+describe('Footer', () => {
+ it('links to the project on GitHub', () => {
+ const { container } = render();
+
+ expect(container.querySelector('#footer p')?.textContent).toBe('Show this project some ❤ on GitHub');
+
+ const link = screen.getByRole('link', { name: 'GitHub' });
+ expect(link).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener');
+ });
+});
diff --git a/web/src/core/Footer.tsx b/web/src/core/Footer.tsx
new file mode 100644
index 000000000..c91ef8941
--- /dev/null
+++ b/web/src/core/Footer.tsx
@@ -0,0 +1,16 @@
+import './footer.scss';
+
+export function Footer() {
+ return (
+
+ );
+}
+
+export default Footer;
diff --git a/web/src/core/Header.test.tsx b/web/src/core/Header.test.tsx
new file mode 100644
index 000000000..7b26e8838
--- /dev/null
+++ b/web/src/core/Header.test.tsx
@@ -0,0 +1,80 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider } from '../context/SettingsContext';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { Header } from './Header';
+
+const scrollTo = vi.fn();
+
+beforeEach(() => {
+ scrollTo.mockClear();
+ vi.stubGlobal('scrollTo', scrollTo);
+});
+
+function renderHeader(initialEntry = '/news/1') {
+ stubMatchMedia(false);
+
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Header', () => {
+ it('links the logo to the first news page and the nav to the other feeds', () => {
+ const { container } = renderHeader();
+
+ expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).toHaveAttribute('href', '/news/1');
+ expect(screen.getByRole('link', { name: 'new' })).toHaveAttribute('href', '/newest/1');
+ expect(screen.getByRole('link', { name: 'show' })).toHaveAttribute('href', '/show/1');
+ expect(screen.getByRole('link', { name: 'ask' })).toHaveAttribute('href', '/ask/1');
+ expect(screen.getByRole('link', { name: 'jobs' })).toHaveAttribute('href', '/jobs/1');
+ expect(container.querySelector('.header-nav')?.textContent).toBe('new | show | ask | jobs');
+ });
+
+ it('marks the link of the current feed as active', () => {
+ renderHeader('/ask/1');
+
+ expect(screen.getByRole('link', { name: 'ask' })).toHaveClass('active');
+ expect(screen.getByRole('link', { name: 'show' })).not.toHaveClass('active');
+ expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).not.toHaveClass('active');
+ });
+
+ it('scrolls back to the top when a link is clicked', async () => {
+ renderHeader();
+ await userEvent.click(screen.getByRole('link', { name: 'new' }));
+
+ expect(scrollTo).toHaveBeenCalledWith(0, 0);
+ });
+
+ it('opens and closes the settings modal from the cog', async () => {
+ renderHeader();
+
+ expect(document.getElementById('popup1')).toBeNull();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ expect(document.getElementById('popup1')).not.toBeNull();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ expect(document.getElementById('popup1')).toBeNull();
+ });
+
+ it('closes the settings modal from its close button', async () => {
+ const { container } = renderHeader();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ await userEvent.click(container.querySelector('.close')!);
+
+ expect(document.getElementById('popup1')).toBeNull();
+ });
+});
diff --git a/web/src/core/Header.tsx b/web/src/core/Header.tsx
new file mode 100644
index 000000000..6395905cf
--- /dev/null
+++ b/web/src/core/Header.tsx
@@ -0,0 +1,51 @@
+import { NavLink } from 'react-router-dom';
+
+import { useSettings } from '../context/SettingsContext';
+import { Settings } from './Settings';
+import './header.scss';
+
+function scrollTop() {
+ window.scrollTo(0, 0);
+}
+
+export function Header() {
+ const { settings, toggleSettings } = useSettings();
+
+ return (
+
+
+ {settings.showSettings && }
+
+ );
+}
+
+export default Header;
diff --git a/web/src/core/Settings.test.tsx b/web/src/core/Settings.test.tsx
new file mode 100644
index 000000000..786c92232
--- /dev/null
+++ b/web/src/core/Settings.test.tsx
@@ -0,0 +1,141 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider, useSettings } from '../context/SettingsContext';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { Settings } from './Settings';
+
+function SettingsHarness() {
+ const { settings, toggleSettings } = useSettings();
+
+ return (
+ <>
+
+ {`theme: ${settings.theme}`}
+ {settings.showSettings && }
+ >
+ );
+}
+
+function renderSettings() {
+ stubMatchMedia(false);
+
+ return render(
+
+
+
+ );
+}
+
+async function openSettings() {
+ await userEvent.click(screen.getByRole('button', { name: 'open settings' }));
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Settings', () => {
+ it('renders the modal markup', async () => {
+ const { container } = renderSettings();
+ await openSettings();
+
+ const overlay = container.querySelector('#popup1');
+ expect(overlay).toHaveClass('overlay');
+ expect(overlay?.querySelector('.popup h1')?.textContent).toBe('Settings');
+ expect(overlay?.querySelector('.close')?.textContent).toBe('×');
+ expect(overlay?.querySelectorAll('.control-section')).toHaveLength(3);
+ expect(screen.getByText('Select a theme')).toBeInTheDocument();
+ expect(screen.getByText('Change Font')).toBeInTheDocument();
+ expect(screen.getByText('Links')).toBeInTheDocument();
+ });
+
+ it('closes the modal from the × button', async () => {
+ const { container } = renderSettings();
+ await openSettings();
+
+ await userEvent.click(container.querySelector('.close')!);
+
+ expect(container.querySelector('#popup1')).toBeNull();
+ });
+
+ it('checks the radio of the current theme and updates the theme when another one is picked', async () => {
+ renderSettings();
+ await openSettings();
+
+ expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Night' }));
+
+ expect(screen.getByText('theme: night')).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Night' })).toBeChecked();
+ expect(localStorage.getItem('theme')).toBe('night');
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Black (AMOLED)' }));
+
+ expect(screen.getByText('theme: amoledblack')).toBeInTheDocument();
+ expect(localStorage.getItem('theme')).toBe('amoledblack');
+ });
+
+ it('updates the title font size on every keystroke', async () => {
+ renderSettings();
+ await openSettings();
+
+ const fontSize = screen.getByLabelText('Font size:');
+ expect(fontSize).toHaveValue(16);
+
+ await userEvent.clear(fontSize);
+ await userEvent.type(fontSize, '20');
+
+ expect(fontSize).toHaveValue(20);
+ expect(localStorage.getItem('titleFontSize')).toBe('20');
+ });
+
+ it('updates the list spacing on every keystroke', async () => {
+ renderSettings();
+ await openSettings();
+
+ const listSpacing = screen.getByLabelText('List spacing:');
+ expect(listSpacing).toHaveValue(0);
+
+ await userEvent.clear(listSpacing);
+ await userEvent.type(listSpacing, '5');
+
+ expect(listSpacing).toHaveValue(5);
+ expect(localStorage.getItem('listSpacing')).toBe('5');
+ });
+
+ it('toggles opening links in a new tab', async () => {
+ renderSettings();
+ await openSettings();
+
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).not.toBeChecked();
+
+ await userEvent.click(checkbox);
+
+ expect(checkbox).toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('true');
+
+ await userEvent.click(checkbox);
+
+ expect(checkbox).not.toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('false');
+ });
+
+ it('reflects the settings restored from localStorage', async () => {
+ localStorage.setItem('theme', 'amoledblack');
+ localStorage.setItem('titleFontSize', '22');
+ localStorage.setItem('listSpacing', '3');
+ localStorage.setItem('openLinkInNewTab', 'true');
+
+ renderSettings();
+ await openSettings();
+
+ expect(screen.getByRole('radio', { name: 'Black (AMOLED)' })).toBeChecked();
+ expect(screen.getByLabelText('Font size:')).toHaveValue(22);
+ expect(screen.getByLabelText('List spacing:')).toHaveValue(3);
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/web/src/core/Settings.tsx b/web/src/core/Settings.tsx
new file mode 100644
index 000000000..0f0e1fb88
--- /dev/null
+++ b/web/src/core/Settings.tsx
@@ -0,0 +1,100 @@
+import { ChangeEvent } from 'react';
+
+import { useSettings } from '../context/SettingsContext';
+import './settings.scss';
+
+export function Settings() {
+ const { settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing } = useSettings();
+
+ const changeTitleFont = (event: ChangeEvent) => setFont(event.target.value);
+ const changeSpacing = (event: ChangeEvent) => setSpacing(event.target.value);
+
+ return (
+
+ );
+}
+
+export default Settings;
diff --git a/web/src/core/footer.scss b/web/src/core/footer.scss
new file mode 100644
index 000000000..cbed29086
--- /dev/null
+++ b/web/src/core/footer.scss
@@ -0,0 +1,23 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+#footer {
+ position: relative;
+ padding: 10px;
+ height: 60px;
+ letter-spacing: 0.7px;
+ text-align: center;
+
+ a {
+ font-weight: bold;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
diff --git a/web/src/core/header.scss b/web/src/core/header.scss
new file mode 100644
index 000000000..fdf935d7f
--- /dev/null
+++ b/web/src/core/header.scss
@@ -0,0 +1,149 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+#header {
+ color: #fff;
+ padding: 6px 0;
+ line-height: 18px;
+ vertical-align: middle;
+ position: relative;
+ z-index: 1;
+ width: 100%;
+
+ @media #{$mobile-only} {
+ height: 50px;
+ position: fixed;
+ top: 0;
+ }
+
+ a {
+ display: inline;
+ }
+}
+
+.home-link {
+ width: 50px;
+ height: 66px;
+}
+
+.logo-inner {
+ width: 32px;
+ position: absolute;
+ left: 17px;
+ top: 18px;
+ z-index: -1;
+ height: 32px;
+ border-radius: 50%;
+
+ @media #{$mobile-only} {
+ left: 16px;
+ top: 12px;
+ }
+}
+
+.logo {
+ width: 50px;
+ padding: 3px 8px 0;
+
+ @media #{$mobile-only} {
+ width: 45px;
+ padding: 0 0 0 10px;
+ }
+}
+
+h1 {
+ font-weight: normal;
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0;
+ font-size: 16px;
+
+ a {
+ color: #fff;
+ text-decoration: none;
+ }
+}
+
+.name {
+ margin-right: 30px;
+ margin-bottom: 2px;
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
+
+.header-text {
+ position: absolute;
+ width: inherit;
+ height: 20px;
+ left: 10px;
+ top: 27px;
+ z-index: -1;
+
+ @media #{$mobile-only} {
+ top: 22px;
+ }
+}
+
+.left {
+ position: absolute;
+ left: 60px;
+ font-size: 16px;
+
+ @media #{$mobile-only} {
+ width: 100%;
+ left: 0;
+ }
+}
+
+.header-nav {
+ display: inline-block;
+ margin-left: 20px;
+
+ @media #{$mobile-only} {
+ margin-left: 60px;
+ }
+
+ a {
+ color: hsla(0, 0%, 100%, 0.9);
+ text-decoration: none;
+ margin: 0 5px;
+ letter-spacing: 1.8px;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+
+ .active {
+ color: #fff;
+ }
+}
+
+.info {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ height: 100%;
+
+ @media #{$mobile-only} {
+ right: 10px;
+ }
+
+ img {
+ opacity: 0.8;
+ width: 25px;
+ margin-top: 21.5px;
+ display: block;
+
+ &:hover {
+ opacity: 1;
+ cursor: pointer;
+ }
+
+ @media #{$mobile-only} {
+ margin-top: 15px;
+ }
+ }
+}
diff --git a/web/src/core/settings.scss b/web/src/core/settings.scss
new file mode 100644
index 000000000..b64ba767b
--- /dev/null
+++ b/web/src/core/settings.scss
@@ -0,0 +1,75 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ opacity: 1;
+ z-index: 1;
+}
+
+.popup {
+ margin: 70px auto;
+ padding: 30px;
+ border-radius: 5px;
+ width: 30%;
+ position: relative;
+ h1 {
+ margin-top: 0;
+ margin-bottom: 0px;
+ color: #fff;
+ text-align: center;
+ letter-spacing: 1px;
+ }
+ h2 {
+ padding-top: 10px;
+ }
+ hr {
+ width: 40%;
+ margin-bottom: 20px;
+ }
+ .close {
+ position: absolute;
+ top: 12px;
+ right: 20px;
+ font-size: 30px;
+ font-weight: bold;
+ text-decoration: none;
+ color: rgba(255, 255, 255, 0.8);
+ &:hover {
+ color: #fff;
+ cursor: pointer;
+ }
+ }
+ .content {
+ max-height: 30%;
+ color: #fff;
+ letter-spacing: 1px;
+ overflow: auto;
+ }
+ input[type='number'] {
+ display: block;
+ width: 80%;
+ height: 20px;
+ margin-bottom: 15px;
+ border-radius: 5px;
+ padding: 2px;
+ }
+}
+
+.control-section {
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid white;
+}
+
+@media screen and (max-width: 700px) {
+ .box,
+ .popup {
+ width: 70%;
+ }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 6bd618ffc..7542c32c7 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,14 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
-import { App } from './App';
import { SettingsProvider } from './context/SettingsContext';
+import { AppRoutes } from './routes';
import './styles/global.scss';
createRoot(document.getElementById('root') as HTMLElement).render(
-
+
+
+
);
diff --git a/web/src/routes.test.tsx b/web/src/routes.test.tsx
new file mode 100644
index 000000000..b179868ac
--- /dev/null
+++ b/web/src/routes.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, useParams } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider } from './context/SettingsContext';
+import { FeedPageProps } from './pages/FeedPage';
+import { AppRoutes, feedTypes } from './routes';
+import { stubMatchMedia } from './testUtils/matchMedia';
+
+vi.mock('./pages/FeedPage', () => {
+ const FeedPage = ({ feedType }: FeedPageProps) => {
+ const { page } = useParams();
+ return {`feed ${feedType} page ${page}`}
;
+ };
+ return { FeedPage, default: FeedPage };
+});
+
+vi.mock('./pages/ItemDetailsPage', () => {
+ const ItemDetailsPage = () => {
+ const { id } = useParams();
+ return {`item ${id}`}
;
+ };
+ return { ItemDetailsPage, default: ItemDetailsPage };
+});
+
+vi.mock('./pages/UserPage', () => {
+ const UserPage = () => {
+ const { id } = useParams();
+ return {`user ${id}`}
;
+ };
+ return { UserPage, default: UserPage };
+});
+
+function renderRoutes(initialEntry: string) {
+ stubMatchMedia(false);
+ vi.stubGlobal('scrollTo', vi.fn());
+
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('routes', () => {
+ it('redirects the root path to the first page of the news feed', () => {
+ renderRoutes('/');
+
+ expect(screen.getByText('feed news page 1')).toBeInTheDocument();
+ });
+
+ it.each(feedTypes)('renders the %s feed with its page parameter', (feedType) => {
+ renderRoutes(`/${feedType}/3`);
+
+ expect(screen.getByText(`feed ${feedType} page 3`)).toBeInTheDocument();
+ });
+
+ it('renders the item details page for /item/:id', () => {
+ renderRoutes('/item/12345');
+
+ expect(screen.getByText('item 12345')).toBeInTheDocument();
+ });
+
+ it('renders the user page for /user/:id', () => {
+ renderRoutes('/user/pg');
+
+ expect(screen.getByText('user pg')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['home', 'feed news page 1'],
+ ['new', 'feed newest page 1'],
+ ['show', 'feed show page 1'],
+ ['ask', 'feed ask page 1'],
+ ['jobs', 'feed jobs page 1'],
+ ])('navigates to the right feed when the %s header link is clicked', async (linkName, expectedText) => {
+ renderRoutes('/item/1');
+
+ const link =
+ linkName === 'home'
+ ? screen.getByRole('img', { name: 'Logo' }).closest('a')!
+ : screen.getByRole('link', { name: linkName });
+ await userEvent.click(link);
+
+ expect(screen.getByText(expectedText)).toBeInTheDocument();
+ });
+});
diff --git a/web/src/routes.tsx b/web/src/routes.tsx
new file mode 100644
index 000000000..6d7730080
--- /dev/null
+++ b/web/src/routes.tsx
@@ -0,0 +1,31 @@
+/* eslint-disable react-refresh/only-export-components */
+import { Navigate, RouteObject, useRoutes } from 'react-router-dom';
+
+import { App } from './App';
+import { FeedPage } from './pages/FeedPage';
+import { ItemDetailsPage } from './pages/ItemDetailsPage';
+import { UserPage } from './pages/UserPage';
+
+export const feedTypes = ['news', 'newest', 'show', 'ask', 'jobs'] as const;
+
+export const routes: RouteObject[] = [
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ ...feedTypes.map((feedType) => ({
+ path: `${feedType}/:page`,
+ element: ,
+ })),
+ { path: 'item/:id', element: },
+ { path: 'user/:id', element: },
+ ],
+ },
+];
+
+export function AppRoutes() {
+ return useRoutes(routes);
+}
+
+export default routes;