diff --git a/src-react/App.tsx b/src-react/App.tsx
index 11adefa1..d455b443 100644
--- a/src-react/App.tsx
+++ b/src-react/App.tsx
@@ -1,8 +1,14 @@
+import { Navigate, Route, Routes } from 'react-router-dom';
+
import { useSettings } from './shared/settings/SettingsContext';
import Footer from './core/footer/Footer';
import Header from './core/header/Header';
+import Feed from './feeds/feed/Feed';
+import type { FeedName } from './shared/models';
import './App.scss';
+const feedNames: FeedName[] = ['news', 'newest', 'show', 'ask', 'jobs'];
+
function App() {
const { settings } = useSettings();
@@ -11,7 +17,16 @@ function App() {
-
+
+ } />
+ {feedNames.map((feedName) => (
+ }
+ />
+ ))}
+
diff --git a/src-react/feeds/feed/Feed.scss b/src-react/feeds/feed/Feed.scss
new file mode 100644
index 00000000..febcd8cd
--- /dev/null
+++ b/src-react/feeds/feed/Feed.scss
@@ -0,0 +1,108 @@
+@import "../../shared/scss/media";
+@import "../../shared/scss/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/src-react/feeds/feed/Feed.tsx b/src-react/feeds/feed/Feed.tsx
new file mode 100644
index 00000000..ed8a3433
--- /dev/null
+++ b/src-react/feeds/feed/Feed.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+
+import ErrorMessage from '../../shared/components/error-message/ErrorMessage';
+import Loader from '../../shared/components/loader/Loader';
+import type { FeedName, Story } from '../../shared/models';
+import { fetchFeed } from '../../shared/api/hackernewsApi';
+import Item from '../item/Item';
+import './Feed.scss';
+
+interface FeedProps {
+ feedType: FeedName;
+}
+
+function Feed({ feedType }: FeedProps) {
+ const params = useParams<{ page?: string }>();
+ const pageNum = params.page ? Number(params.page) : 1;
+ const [items, setItems] = useState();
+ const [errorMessage, setErrorMessage] = useState('');
+ const [listStart, setListStart] = useState(0);
+
+ useEffect(() => {
+ let ignore = false;
+ setErrorMessage('');
+
+ fetchFeed(feedType, pageNum)
+ .then((nextItems) => {
+ if (ignore) {
+ return;
+ }
+
+ setItems(nextItems);
+ setListStart((pageNum - 1) * 30 + 1);
+ window.scrollTo(0, 0);
+ })
+ .catch(() => {
+ if (!ignore) {
+ setErrorMessage(`Could not load ${feedType} stories.`);
+ }
+ });
+
+ return () => {
+ ignore = true;
+ };
+ }, [feedType, pageNum]);
+
+ return (
+
+ {!items && !errorMessage &&
}
+ {!items && errorMessage !== '' &&
}
+ {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 as string !== 'new' && (
+
+ {items.map((item) => (
+ -
+
+
+ ))}
+
+ )}
+
+ {listStart !== 1 && (
+
+ ‹ Prev
+
+ )}
+ {items.length === 30 && (
+
+ More ›
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default Feed;
diff --git a/src-react/feeds/item/Item.scss b/src-react/feeds/item/Item.scss
new file mode 100644
index 00000000..7f985c9c
--- /dev/null
+++ b/src-react/feeds/item/Item.scss
@@ -0,0 +1,68 @@
+@import "../../shared/scss/media";
+@import "../../shared/scss/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/src-react/feeds/item/Item.tsx b/src-react/feeds/item/Item.tsx
new file mode 100644
index 00000000..21a6553e
--- /dev/null
+++ b/src-react/feeds/item/Item.tsx
@@ -0,0 +1,80 @@
+import { Link } from 'react-router-dom';
+
+import { useSettings } from '../../shared/settings/SettingsContext';
+import type { Story } from '../../shared/models';
+import { formatComments } from '../../shared/utils/formatComments';
+import './Item.scss';
+
+interface ItemProps {
+ item: Story;
+ className?: string;
+}
+
+function Item({ item, className }: ItemProps) {
+ const { settings } = useSettings();
+ const hasUrl = item.url?.indexOf('http') === 0;
+ const titleStyle = { fontSize: `${settings.titleFontSize}px` };
+
+ return (
+
+ {hasUrl && (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain})}
+
+ )}
+ {!hasUrl && (
+
+
+ {item.title}
+
+
+ )}
+
+ {item.type !== 'job' && (
+
+
+ {item.user}
+
+ {item.points} ★
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' • '}
+ {formatComments(item.comments_count)}
+
+ )}
+
+
+
+ {item.type !== 'job' && (
+
+ {item.points} points by {item.user}
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' | '}
+ {formatComments(item.comments_count)}
+
+ )}
+
+
+
+ );
+}
+
+export default Item;