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) => ( +
  1. +
    + +
    +
  2. + ))} +
+ )} +
+ {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; +}