diff --git a/react-app/src/components/feeds/Item.scss b/react-app/src/components/feeds/Item.scss
new file mode 100644
index 00000000..d34b2c48
--- /dev/null
+++ b/react-app/src/components/feeds/Item.scss
@@ -0,0 +1,68 @@
+@import "../../styles/media";
+@import "../../styles/theme_variables";
+
+p {
+ margin: 2px 0;
+
+ @media #{$mobile-only} {
+ margin-bottom: 5px;
+ margin-top: 0;
+ }
+ }
+
+ a {
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ .title {
+ font-size: 16px;
+ font-family: Verdana, Geneva, sans-serif;
+ }
+
+ .subtext-laptop {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ };
+ }
+ @media #{$mobile-only} {
+ display: none;
+ }
+ }
+
+ .subtext-palm {
+ font-size: 13px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ };
+ }
+
+ .details {
+ margin-top: 5px;
+
+ .right {
+ float: right;
+ }
+ }
+ @media #{$laptop-only} {
+ display: none;
+ }
+ }
+
+ .domain {
+ color: #696969;
+ letter-spacing: 0.5px;
+ }
+
+ .item-details {
+ padding: 10px;
+ }
diff --git a/react-app/src/components/feeds/Item.test.tsx b/react-app/src/components/feeds/Item.test.tsx
new file mode 100644
index 00000000..9ba50dd7
--- /dev/null
+++ b/react-app/src/components/feeds/Item.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+
+import { SettingsProvider } from '../../context/SettingsContext';
+import type { Story } from '../../models/story';
+import Item, { hasUrl } from './Item';
+
+const story: Story = {
+ id: 1,
+ title: 'A linked story',
+ points: 42,
+ user: 'pg',
+ time: 0,
+ time_ago: 0,
+ type: 'story',
+ url: 'https://example.com/post',
+ domain: 'example.com',
+ comments_count: 3,
+};
+
+function renderItem(item: Story) {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('Item', () => {
+ it('detects external urls', () => {
+ expect(hasUrl(story)).toBe(true);
+ expect(hasUrl({ ...story, url: 'item?id=1' })).toBe(false);
+ expect(hasUrl({ ...story, url: undefined })).toBe(false);
+ });
+
+ it('links external stories to their url and shows the domain', () => {
+ renderItem(story);
+
+ const link = screen.getByRole('link', { name: 'A linked story' });
+ expect(link).toHaveAttribute('href', 'https://example.com/post');
+ expect(link).not.toHaveAttribute('target');
+ expect(screen.getByText('(example.com)')).toBeInTheDocument();
+ expect(screen.getAllByText(/3 comments/).length).toBe(2);
+ });
+
+ it('links self posts to the item page', () => {
+ renderItem({ ...story, url: undefined, domain: undefined });
+
+ expect(screen.getByRole('link', { name: 'A linked story' })).toHaveAttribute(
+ 'href',
+ '/item/1'
+ );
+ });
+
+ it('hides points and comments for jobs', () => {
+ renderItem({ ...story, type: 'job' });
+
+ expect(screen.queryByText('pg')).not.toBeInTheDocument();
+ expect(screen.queryByText(/comments/)).not.toBeInTheDocument();
+ });
+});
diff --git a/react-app/src/components/feeds/Item.tsx b/react-app/src/components/feeds/Item.tsx
new file mode 100644
index 00000000..bc67c48a
--- /dev/null
+++ b/react-app/src/components/feeds/Item.tsx
@@ -0,0 +1,94 @@
+import { Link, NavLink } from 'react-router-dom';
+
+import { useSettings } from '../../context/SettingsContext';
+import type { Story } from '../../models/story';
+import { formatCommentCount } from '../../utils/format-comment-count';
+
+import './Item.scss';
+
+export interface ItemProps {
+ item: Story;
+}
+
+export function hasUrl(item: Story): boolean {
+ return item.url?.startsWith('http') ?? false;
+}
+
+function activeClass({ isActive }: { isActive: boolean }) {
+ return isActive ? 'active' : '';
+}
+
+export default function Item({ item }: ItemProps) {
+ const { settings } = useSettings();
+ const titleStyle = { fontSize: `${settings.titleFontSize}px` };
+ const newTabProps = settings.openLinkInNewTab
+ ? { target: '_blank', rel: 'noopener' }
+ : {};
+
+ return (
+
+ {hasUrl(item) ? (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain})}
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {item.type !== 'job' && (
+
+
+
+ {item.user}
+
+
+ {item.points} ★
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ isActive ? 'comment-number active' : 'comment-number'
+ }
+ >
+ {' '}
+ • {formatCommentCount(item.comments_count)}
+
+ )}
+
+
+
+ {item.type !== 'job' && (
+
+ {item.points} points by{' '}
+
+ {item.user}
+
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' '}
+ |{' '}
+
+ {formatCommentCount(item.comments_count)}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/react-app/src/pages/Feed.scss b/react-app/src/pages/Feed.scss
new file mode 100644
index 00000000..a26f5968
--- /dev/null
+++ b/react-app/src/pages/Feed.scss
@@ -0,0 +1,108 @@
+@import "../styles/media";
+@import "../styles/theme_variables";
+
+a {
+ text-decoration: none;
+ font-weight: bold;
+
+ &:hover {
+ text-decoration: underline;
+ };
+}
+
+ol {
+ padding: 0 40px;
+ margin: 0;
+
+ @media #{$mobile-only} {
+ box-sizing: border-box;
+ list-style: none;
+ padding: 0 10px;
+ }
+
+ li {
+ position: relative;
+ -webkit-transition: background-color .2s ease;
+ transition: background-color .2s ease;
+ }
+}
+
+.list-margin {
+ @media #{$mobile-only} {
+ margin-top: 55px;
+ }
+}
+
+.main-content {
+ position: relative;
+ width: 100%;
+ min-height: 100vh;
+ -webkit-transition: opacity .2s ease;
+ transition: opacity .2s ease;
+ box-sizing: border-box;
+ padding: 8px 0;
+ z-index: 0;
+}
+
+.post {
+ padding: 10px 0 10px 5px;
+ transition: background-color 0.2s ease;
+ border-bottom: 1px solid #CECECB;
+
+ .itemNum {
+ color: #696969;
+ position: absolute;
+ width: 30px;
+ text-align: right;
+ left: 0;
+ top: 4px;
+ }
+}
+
+.item-block {
+ display: block;
+}
+
+
+.nav {
+ padding: 10px 40px;
+ margin-top: 10px;
+ font-size: 17px;
+
+ a {
+ @media #{$mobile-only} {
+ text-decoration: none;
+ }
+ }
+
+ @media #{$mobile-only} {
+ margin: 20px 0;
+ text-align: center;
+ padding: 10px 80px;
+ height: 20px;
+ }
+
+ .prev {
+ padding-right: 20px;
+
+ @media #{$mobile-only} {
+ float: left;
+ padding-right: 0;
+ }
+ }
+
+ .more {
+ @media #{$mobile-only} {
+ float: right;
+ }
+ }
+}
+
+.job-header {
+ font-size: 15px;
+ padding: 0 40px 10px;
+
+ @media #{$mobile-only} {
+ padding: 60px 15px 25px 15px;
+ }
+}
diff --git a/react-app/src/pages/Feed.test.tsx b/react-app/src/pages/Feed.test.tsx
new file mode 100644
index 00000000..c7ba7169
--- /dev/null
+++ b/react-app/src/pages/Feed.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider } from '../context/SettingsContext';
+import type { Story } from '../models/story';
+import Feed from './Feed';
+
+function makeStory(id: number): Story {
+ return {
+ id,
+ title: `Story ${id}`,
+ points: 1,
+ user: 'pg',
+ time: 0,
+ time_ago: 0,
+ type: 'story',
+ url: 'https://example.com',
+ comments_count: 0,
+ };
+}
+
+function renderFeed(feedType: string, path: string) {
+ return render(
+
+
+
+ } />
+
+
+
+ );
+}
+
+function mockFeed(items: Story[]) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.resolve(new Response(JSON.stringify(items))))
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Feed', () => {
+ it('renders the fetched stories and a More link on a full page', async () => {
+ mockFeed(Array.from({ length: 30 }, (_, index) => makeStory(index + 1)));
+
+ renderFeed('news', '/news/2');
+
+ await waitFor(() => expect(screen.getByText('Story 1')).toBeInTheDocument());
+ expect(screen.getByRole('list')).toHaveAttribute('start', '31');
+ expect(screen.getByRole('link', { name: '‹ Prev' })).toHaveAttribute('href', '/news/1');
+ expect(screen.getByRole('link', { name: 'More ›' })).toHaveAttribute('href', '/news/3');
+ });
+
+ it('shows an error message when the request fails', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.reject(new Error('offline')))
+ );
+
+ renderFeed('news', '/news/1');
+
+ await waitFor(() =>
+ expect(screen.getByText('Could not load news stories.')).toBeInTheDocument()
+ );
+ });
+
+ it('shows the jobs header on the jobs feed', async () => {
+ mockFeed([makeStory(1)]);
+
+ renderFeed('jobs', '/jobs/1');
+
+ await waitFor(() => expect(screen.getByText(/Y Combinator/)).toBeInTheDocument());
+ expect(screen.queryByRole('link', { name: '‹ Prev' })).not.toBeInTheDocument();
+ });
+});
diff --git a/react-app/src/pages/Feed.tsx b/react-app/src/pages/Feed.tsx
new file mode 100644
index 00000000..214164d2
--- /dev/null
+++ b/react-app/src/pages/Feed.tsx
@@ -0,0 +1,75 @@
+import { useEffect } from 'react';
+import { Link, useParams } from 'react-router-dom';
+
+import Item from '../components/feeds/Item';
+import ErrorMessage from '../components/shared/ErrorMessage';
+import Loader from '../components/shared/Loader';
+import { useFetch } from '../hooks/useFetch';
+import type { Story } from '../models/story';
+import { fetchFeed } from '../services/hackernews-api';
+
+import './Feed.scss';
+
+export interface FeedProps {
+ feedType: string;
+}
+
+export default function Feed({ feedType }: FeedProps) {
+ const params = useParams();
+ const page = params.page ? Number(params.page) : 1;
+
+ const { data: items, error, loading } = useFetch(
+ (signal) => fetchFeed(feedType, page, signal),
+ [feedType, page]
+ );
+
+ useEffect(() => {
+ if (items) {
+ window.scrollTo(0, 0);
+ }
+ }, [items]);
+
+ const listStart = (page - 1) * 30 + 1;
+
+ return (
+
+ {loading &&
}
+ {!items && error &&
}
+
+ {items && (
+
+ {feedType === 'jobs' && (
+
+ These are jobs at startups that were funded by Y Combinator. You can
+ also get a job at a YC startup through{' '}
+ Triplebyte.
+
+ )}
+ {feedType !== 'new' && (
+
+ {items.map((item) => (
+ -
+
+
+
+
+ ))}
+
+ )}
+
+ {listStart !== 1 && (
+
+ ‹ Prev
+
+ )}
+ {items.length === 30 && (
+
+ More ›
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/react-app/src/test-setup.ts b/react-app/src/test-setup.ts
index bb02c60c..6a4900f6 100644
--- a/react-app/src/test-setup.ts
+++ b/react-app/src/test-setup.ts
@@ -1 +1,15 @@
import '@testing-library/jest-dom/vitest';
+
+if (!window.matchMedia) {
+ window.matchMedia = (media: string) =>
+ ({
+ matches: false,
+ media,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => true,
+ }) as unknown as MediaQueryList;
+}