diff --git a/web/src/item-details/Comment.test.tsx b/web/src/item-details/Comment.test.tsx
new file mode 100644
index 00000000..b6369bcb
--- /dev/null
+++ b/web/src/item-details/Comment.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+
+import { Comment as CommentModel } from '../models/comment';
+import { Comment } from './Comment';
+
+function buildComment(overrides: Partial = {}): CommentModel {
+ return {
+ id: 1,
+ level: 0,
+ user: 'kate',
+ time: 1500000000,
+ time_ago: '2 hours ago',
+ content: 'Top level comment
',
+ comments: [],
+ ...overrides,
+ };
+}
+
+function renderComment(comment: CommentModel) {
+ return render(
+
+
+
+ );
+}
+
+describe('Comment', () => {
+ it('renders the meta line, the user link and the html content', () => {
+ const { container } = renderComment(buildComment());
+
+ expect(screen.getByText('[-]')).toHaveClass('collapse');
+ expect(screen.getByRole('link', { name: 'kate' })).toHaveAttribute('href', '/user/kate');
+ expect(screen.getByText('2 hours ago')).toHaveClass('time');
+
+ const commentText = container.querySelector('.comment-tree .comment-text');
+ expect(commentText).not.toBeNull();
+ expect(commentText?.innerHTML).toBe('Top level comment
');
+ expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse');
+ });
+
+ it('renders nested comments recursively', () => {
+ const comment = buildComment({
+ content: 'level one',
+ comments: [
+ buildComment({
+ id: 2,
+ level: 1,
+ user: 'bob',
+ content: 'level two',
+ comments: [buildComment({ id: 3, level: 2, user: 'carol', content: 'level three' })],
+ }),
+ ],
+ });
+
+ const { container } = renderComment(comment);
+
+ expect(screen.getByText('level one')).toBeInTheDocument();
+ expect(screen.getByText('level two')).toBeInTheDocument();
+ expect(screen.getByText('level three')).toBeInTheDocument();
+ expect(container.querySelectorAll('.subtree')).toHaveLength(3);
+
+ const firstSubtree = container.querySelector('.subtree');
+ expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'bob' })).toBeInTheDocument();
+ expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'carol' })).toBeInTheDocument();
+ });
+
+ it('collapses and expands the comment content and its children', async () => {
+ const user = userEvent.setup();
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'bob', content: 'child' })],
+ });
+
+ const { container } = renderComment(comment);
+
+ expect(screen.getByText('parent')).toBeVisible();
+ expect(screen.getByText('child')).toBeVisible();
+
+ await user.click(screen.getAllByText('[-]')[0]);
+
+ expect(screen.getByText('[+]')).toBeInTheDocument();
+ expect(container.querySelector('.meta')).toHaveClass('meta-collapse');
+ expect(screen.getByText('parent')).not.toBeVisible();
+ expect(screen.getByText('child')).not.toBeVisible();
+ expect(screen.getByRole('link', { name: 'kate' })).toBeVisible();
+
+ await user.click(screen.getByText('[+]'));
+
+ expect(screen.getAllByText('[-]')[0]).toBeInTheDocument();
+ expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse');
+ expect(screen.getByText('parent')).toBeVisible();
+ });
+
+ it('collapses a child independently of its parent', async () => {
+ const user = userEvent.setup();
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'bob', content: 'child' })],
+ });
+
+ renderComment(comment);
+
+ await user.click(screen.getAllByText('[-]')[1]);
+
+ expect(screen.getByText('parent')).toBeVisible();
+ expect(screen.getByText('child')).not.toBeVisible();
+ });
+
+ it('renders the deleted state instead of the comment body', () => {
+ const { container } = renderComment(
+ buildComment({ deleted: true, content: 'should not be rendered', user: 'ghost' })
+ );
+
+ expect(screen.getByText('[deleted]')).toHaveClass('collapse');
+ expect(container.querySelector('.deleted-meta')?.textContent).toBe('[deleted] | Comment Deleted');
+ expect(screen.queryByText('should not be rendered')).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'ghost' })).not.toBeInTheDocument();
+ expect(container.querySelector('.comment-tree')).toBeNull();
+ });
+
+ it('renders deleted children inside a live comment tree', () => {
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'ghost', deleted: true, content: 'hidden' })],
+ });
+
+ renderComment(comment);
+
+ expect(screen.getByText('parent')).toBeInTheDocument();
+ expect(screen.getByText('[deleted]')).toBeInTheDocument();
+ expect(screen.queryByText('hidden')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/item-details/Comment.tsx b/web/src/item-details/Comment.tsx
new file mode 100644
index 00000000..77e6e03d
--- /dev/null
+++ b/web/src/item-details/Comment.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { NavLink } from 'react-router-dom';
+
+import { Comment as CommentModel } from '../models/comment';
+import './comment.scss';
+
+export interface CommentProps {
+ comment: CommentModel;
+}
+
+export function Comment({ comment }: CommentProps) {
+ const [collapse, setCollapse] = useState(false);
+
+ if (comment.deleted) {
+ return (
+
+
+ [deleted] | Comment Deleted
+
+
+ );
+ }
+
+ return (
+
+
+ setCollapse(!collapse)}>
+ [{collapse ? '+' : '-'}]
+ {' '}
+ {comment.user}
+ {comment.time_ago}
+
+
+
+
+
+ {comment.comments?.map((subComment) => (
+ -
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export default Comment;
diff --git a/web/src/item-details/comment.scss b/web/src/item-details/comment.scss
new file mode 100644
index 00000000..05174846
--- /dev/null
+++ b/web/src/item-details/comment.scss
@@ -0,0 +1,81 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.comment-list {
+ a {
+ font-weight: bold;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .meta {
+ font-size: 13px;
+ color: #696969;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ a {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ .time {
+ padding-left: 5px;
+ }
+
+ @media #{$mobile-only} {
+ font-size: 14px;
+ margin-bottom: 10px;
+ .time {
+ padding: 0;
+ float: right;
+ }
+ }
+ }
+
+ .meta-collapse {
+ margin-bottom: 20px;
+ }
+
+ .deleted-meta {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ margin: 30px 0;
+ a {
+ text-decoration: none;
+ }
+ }
+
+ .collapse {
+ font-size: 13px;
+ letter-spacing: 2px;
+ cursor: pointer;
+ }
+
+ .comment-tree {
+ margin-left: 24px;
+
+ @media #{$tablet-only} {
+ margin-left: 8px;
+ }
+ }
+
+ .comment-text {
+ font-size: 15px;
+ margin-top: 0;
+ margin-bottom: 20px;
+ word-wrap: break-word;
+ line-height: 1.5em;
+ }
+
+ .subtree {
+ margin-left: 0;
+ padding: 0;
+ list-style-type: none;
+ }
+}
diff --git a/web/src/item-details/itemDetails.scss b/web/src/item-details/itemDetails.scss
new file mode 100644
index 00000000..8748353d
--- /dev/null
+++ b/web/src/item-details/itemDetails.scss
@@ -0,0 +1,145 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.main-content {
+ position: relative;
+ width: 100%;
+ min-height: 100vh;
+ -webkit-transition: opacity 0.2s ease;
+ transition: opacity 0.2s ease;
+ box-sizing: border-box;
+ padding: 8px 0;
+ z-index: 0;
+}
+
+.item {
+ box-sizing: border-box;
+ padding: 10px 40px 0 40px;
+ z-index: 0;
+
+ @media #{$tablet-only} {
+ padding: 10px 20px 0 40px;
+ }
+
+ @media #{$mobile-only} {
+ padding: 110px 15px 0 15px;
+ }
+
+ .head-margin {
+ margin-bottom: 15px;
+ }
+
+ p {
+ margin: 2px 0;
+ }
+
+ .subject {
+ word-wrap: break-word;
+ margin-top: 20px;
+ }
+
+ a {
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ @media #{$mobile-only} {
+ .laptop {
+ display: none;
+ }
+ }
+
+ @media #{$laptop-only} {
+ .mobile {
+ display: none;
+ }
+ }
+
+ .title {
+ font-size: 16px;
+ font-family: Verdana, Geneva, sans-serif;
+
+ @media #{$mobile-only} {
+ font-size: 15px;
+ }
+ }
+
+ .title-block {
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin: 0 75px;
+ }
+
+ @media #{$mobile-only} {
+ .back-button {
+ position: absolute;
+ top: 52%;
+ width: 0.6rem;
+ height: 0.6rem;
+ background: transparent;
+ box-shadow: 0 0 0 lightgray;
+ transition: all 200ms ease;
+ left: 4%;
+ transform: translate3d(0, -50%, 0) rotate(-135deg);
+ }
+ }
+
+ .subtext {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .domain {
+ letter-spacing: 0.5px;
+ }
+
+ .item-details {
+ padding: 10px;
+ }
+
+ .item-header {
+ padding-bottom: 10px;
+
+ @media #{$mobile-only} {
+ padding: 10px 0 10px 0;
+ position: fixed;
+ width: 100%;
+ left: 0;
+ top: 62px;
+ }
+ }
+
+ .pollResults {
+ margin-bottom: 1em;
+ }
+
+ .pollContent {
+ * {
+ padding-bottom: 0;
+ margin-bottom: -1em;
+ margin-top: 1em;
+ }
+ .pollBar {
+ height: 10px;
+ margin-bottom: 1em;
+ }
+ }
+
+ ul {
+ list-style-type: none;
+ padding: 10px 0;
+ }
+
+ li {
+ display: list-item;
+ }
+}
diff --git a/web/src/pages/ItemDetailsPage.test.tsx b/web/src/pages/ItemDetailsPage.test.tsx
new file mode 100644
index 00000000..272f7be8
--- /dev/null
+++ b/web/src/pages/ItemDetailsPage.test.tsx
@@ -0,0 +1,267 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchItemContent } from '../api/hackerNews';
+import { SettingsProvider } from '../context/SettingsContext';
+import { Comment } from '../models/comment';
+import { Story } from '../models/story';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { ItemDetailsPage } from './ItemDetailsPage';
+
+vi.mock('../api/hackerNews', () => ({
+ fetchItemContent: vi.fn(),
+}));
+
+const fetchItemContentMock = vi.mocked(fetchItemContent);
+
+function buildComment(overrides: Partial = {}): Comment {
+ return {
+ id: 100,
+ level: 0,
+ user: 'kate',
+ time: 1500000000,
+ time_ago: '1 hour ago',
+ content: 'a comment',
+ comments: [],
+ ...overrides,
+ };
+}
+
+function buildStory(overrides: Partial = {}): Story {
+ return {
+ id: 42,
+ title: 'A React story',
+ points: 120,
+ user: 'alice',
+ time: 1500000000,
+ time_ago: '3 hours ago',
+ type: 'story',
+ url: 'https://example.com/story',
+ domain: 'example.com',
+ comments: [],
+ comments_count: 2,
+ ...overrides,
+ };
+}
+
+function GoToItem({ id }: { id: number }) {
+ const navigate = useNavigate();
+ return ;
+}
+
+function renderPage(initialEntries: string[] = ['/item/42'], initialIndex?: number) {
+ return render(
+
+
+
+ news feed
} />
+ } />
+
+
+
+ );
+}
+
+describe('ItemDetailsPage', () => {
+ beforeEach(() => {
+ stubMatchMedia(false);
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
+ fetchItemContentMock.mockReset();
+ });
+
+ it('shows the loader while the item is being fetched', () => {
+ fetchItemContentMock.mockReturnValue(new Promise(() => undefined));
+
+ const { container } = renderPage();
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(container.querySelector('.main-content .loading-section')).not.toBeNull();
+ expect(container.querySelector('.item')).toBeNull();
+ });
+
+ it('fetches the item id taken from the route and scrolls to the top', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ renderPage(['/item/42']);
+
+ await screen.findAllByText('A React story');
+ expect(fetchItemContentMock).toHaveBeenCalledWith(42);
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
+ });
+
+ it('shows the error message when the item cannot be loaded', async () => {
+ fetchItemContentMock.mockRejectedValue(new Error('offline'));
+
+ const { container } = renderPage();
+
+ expect(await screen.findByText('Could not load item comments.')).toBeInTheDocument();
+ expect(container.querySelector('.loading-section')).toBeNull();
+ expect(container.querySelector('.item')).toBeNull();
+ });
+
+ it('renders the mobile and laptop headers for a story with an external url', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory({ content: 'story body
' }));
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const mobileHeader = container.querySelector('.mobile.item-header') as HTMLElement;
+
+ const mobileTitle = mobileHeader.querySelector('a.title');
+ expect(mobileTitle).toHaveAttribute('href', 'https://example.com/story');
+ expect(mobileTitle).not.toHaveAttribute('target');
+ expect(mobileTitle).not.toHaveAttribute('rel');
+ expect(mobileHeader.querySelector('.title-block .back-button')).not.toBeNull();
+
+ const laptopHeader = container.querySelector('.laptop') as HTMLElement;
+ expect(laptopHeader).toHaveClass('item-header');
+ expect(laptopHeader).toHaveClass('head-margin');
+ expect(laptopHeader.querySelector('a.title')).toHaveAttribute('href', 'https://example.com/story');
+ expect(laptopHeader.querySelector('.domain')?.textContent).toBe('(example.com)');
+
+ const subtext = laptopHeader.querySelector('.subtext') as HTMLElement;
+ expect(subtext.textContent).toContain('120 points by');
+ expect(subtext.querySelector('a[href="/user/alice"]')).not.toBeNull();
+ expect(subtext.querySelector('.item-details')?.textContent).toContain('3 hours ago');
+ expect(subtext.querySelector('a[href="/item/42"]')?.textContent).toBe('2 comments');
+
+ expect(container.querySelector('.subject')?.innerHTML).toBe('story body
');
+ });
+
+ it('opens the story link in a new tab when the setting is enabled', async () => {
+ localStorage.setItem('openLinkInNewTab', 'true');
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ container.querySelectorAll('a.title').forEach((title) => {
+ expect(title).toHaveAttribute('target', '_blank');
+ expect(title).toHaveAttribute('rel', 'noopener');
+ });
+ });
+
+ it('links the title to the item itself when the story has no external url', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({ url: 'item?id=42', domain: undefined, comments_count: 0, content: undefined })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ container.querySelectorAll('a.title').forEach((title) => {
+ expect(title).toHaveAttribute('href', '/item/42');
+ });
+ expect(container.querySelector('.domain')).toBeNull();
+ expect(container.querySelector('.laptop')).not.toHaveClass('item-header');
+ expect(container.querySelector('.laptop')).not.toHaveClass('head-margin');
+ expect(container.querySelector('.subtext a[href="/item/42"]')?.textContent).toBe('discuss');
+ expect(container.querySelector('.subject')?.innerHTML).toBe('');
+ });
+
+ it('hides the points and comment count for job postings', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory({ type: 'job', comments_count: 0 }));
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const subtext = container.querySelector('.laptop .subtext') as HTMLElement;
+ expect(subtext.textContent?.trim()).toBe('3 hours ago');
+ expect(subtext.querySelector('.item-details')).toBeNull();
+ expect(subtext.querySelector('a')).toBeNull();
+ expect(container.querySelector('.laptop')).toHaveClass('item-header');
+ });
+
+ it('renders poll results with bars sized from the vote share', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({
+ type: 'poll',
+ poll: [
+ { points: 30, content: 'Option A
' },
+ { points: 10, content: 'Option B
' },
+ ],
+ poll_votes_count: 40,
+ })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const pollContents = container.querySelectorAll('.pollResults .pollContent');
+ expect(pollContents).toHaveLength(2);
+ expect(pollContents[0].textContent).toContain('Option A');
+ expect(pollContents[0].querySelector('.subtext')?.textContent).toBe('30 points');
+ expect(pollContents[0].querySelector('.pollBar')?.style.width).toBe('75%');
+ expect(pollContents[1].querySelector('.subtext')?.textContent).toBe('10 points');
+ expect(pollContents[1].querySelector('.pollBar')?.style.width).toBe('25%');
+ });
+
+ it('does not render poll results for a regular story', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ expect(container.querySelector('.pollResults')).toBeNull();
+ });
+
+ it('renders the comment list, including nested comments', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({
+ comments: [
+ buildComment({
+ id: 100,
+ content: 'first comment',
+ comments: [buildComment({ id: 101, user: 'bob', content: 'nested reply' })],
+ }),
+ buildComment({ id: 102, user: 'carol', content: 'second comment' }),
+ ],
+ })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ expect(container.querySelectorAll('.comment-list > li')).toHaveLength(2);
+ expect(screen.getByText('first comment')).toBeInTheDocument();
+ expect(screen.getByText('nested reply')).toBeInTheDocument();
+ expect(screen.getByText('second comment')).toBeInTheDocument();
+ });
+
+ it('goes back in history when the back button is clicked', async () => {
+ const user = userEvent.setup();
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage(['/', '/item/42'], 1);
+ await screen.findAllByText('A React story');
+
+ await user.click(container.querySelector('.back-button') as HTMLElement);
+
+ expect(await screen.findByText('news feed')).toBeInTheDocument();
+ });
+
+ it('refetches when the route id changes', async () => {
+ const user = userEvent.setup();
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ render(
+
+
+
+
+ } />
+
+
+
+ );
+ await screen.findAllByText('A React story');
+
+ fetchItemContentMock.mockResolvedValue(buildStory({ id: 7, title: 'Another story' }));
+ await user.click(screen.getByRole('button', { name: 'go' }));
+
+ await screen.findAllByText('Another story');
+ expect(fetchItemContentMock).toHaveBeenLastCalledWith(7);
+ });
+});
diff --git a/web/src/pages/ItemDetailsPage.tsx b/web/src/pages/ItemDetailsPage.tsx
index dcde57c0..a0ce996b 100644
--- a/web/src/pages/ItemDetailsPage.tsx
+++ b/web/src/pages/ItemDetailsPage.tsx
@@ -1,9 +1,141 @@
-/**
- * Placeholder for the ported `ItemDetailsComponent`, implemented in Phase 2c.
- * The item id comes from the `/item/:id` route via `useParams`.
- */
+import { useEffect, useState } from 'react';
+import { NavLink, useNavigate, useParams } from 'react-router-dom';
+
+import { fetchItemContent } from '../api/hackerNews';
+import { ErrorMessage } from '../components/ErrorMessage';
+import { Loader } from '../components/Loader';
+import { useSettings } from '../context/SettingsContext';
+import { Comment } from '../item-details/Comment';
+import { Story } from '../models/story';
+import { formatCommentCount } from '../utils/formatCommentCount';
+import '../item-details/itemDetails.scss';
+
export function ItemDetailsPage() {
- return null;
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { settings } = useSettings();
+ const [item, setItem] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ setItem(null);
+ setErrorMessage('');
+
+ fetchItemContent(Number(id)).then(
+ (story) => {
+ if (!cancelled) {
+ setItem(story);
+ }
+ },
+ () => {
+ if (!cancelled) {
+ setErrorMessage('Could not load item comments.');
+ }
+ }
+ );
+
+ window.scrollTo(0, 0);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [id]);
+
+ if (!item) {
+ return (
+
+ {errorMessage === '' ? : }
+
+ );
+ }
+
+ const hasUrl = item.url !== undefined && item.url.indexOf('http') === 0;
+ const isJob = item.type === 'job';
+ const target = settings.openLinkInNewTab ? '_blank' : undefined;
+ const rel = settings.openLinkInNewTab ? 'noopener' : undefined;
+ const laptopClassName = [
+ 'laptop',
+ item.comments_count > 0 || isJob ? 'item-header' : '',
+ item.content ? 'head-margin' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+
+
+
+ navigate(-1)}>
+ {hasUrl ? (
+
+ {item.title}
+
+ ) : (
+
+ {item.title}
+
+ )}
+
+
+
+ {hasUrl ? (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain})}
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {!isJob && (
+
+ {item.points} points by {item.user}
+
+ )}
+
+ {item.time_ago}
+ {!isJob && (
+
+ {' | '}
+ {formatCommentCount(item.comments_count)}
+
+ )}
+
+
+
+ {item.type === 'poll' && (
+
+ {item.poll?.map((pollResult, index) => (
+
+
+
{pollResult.points} points
+
+
+ ))}
+
+ )}
+
+
+ {item.comments?.map((comment) => (
+ -
+
+
+ ))}
+
+
+
+ );
}
export default ItemDetailsPage;