diff --git a/src/react/components/ErrorMessage.scss b/src/react/components/ErrorMessage.scss
new file mode 100644
index 00000000..f05d1f59
--- /dev/null
+++ b/src/react/components/ErrorMessage.scss
@@ -0,0 +1,115 @@
+@import "../scss/media";
+@import "../scss/theme_variables";
+
+.error-section {
+ height: 300px;
+ margin: 200px;
+
+ @media #{$mobile-only} {
+ height: 0;
+ display: block;
+ position: relative;
+ margin: 30vh 0;
+ }
+
+ p {
+ text-align: center;
+ padding: 0 25px;
+
+ &.strong {
+ margin-top: 25px;
+ font-weight: bold;
+ }
+ }
+
+ .skull {
+ width: $skull-size;
+ height: $skull-size;
+ position: relative;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+
+ .head {
+ width: 100%;
+ height: 75%;
+ border-radius: 15% / 20%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ &:before, &:after {
+ content: "";
+ position: absolute;
+ border-radius: 50%;
+ width: 20%;
+ height: 30%;
+ bottom: 10%;
+ }
+ &:before {
+ left: 10%;
+ }
+ &:after {
+ right: 10%;
+ }
+ .crack {
+ width: 10%;
+ height: 10%;
+ position: absolute;
+ top: 0;
+ right: 25%;
+ transform: skew(-15deg);
+
+ &:before {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: $skull-size / 15;
+ border-right: $skull-size / 20 solid transparent;
+ border-left: $skull-size / 40 solid transparent;
+ }
+ }
+ }
+ .mouth {
+ width: 40%;
+ height: 25%;
+ position: absolute;
+ top: 75%;
+ left: 30%;
+ border-radius: 0 0 $skull-size / 10 $skull-size / 10;
+ &:before {
+ content: "";
+ position: absolute;
+ width: 15%;
+ height: 50%;
+ border-radius: 50% / 30%;
+ left: 42.5%;
+ top: -25%;
+ }
+ .teeth {
+ position: absolute;
+ bottom: 0;
+ left: 45%;
+ width: 10%;
+ height: 50%;
+ margin-bottom: -5%;
+ border-radius: 50% / 20%;
+
+ &:before, &:after {
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ border-radius: 50% / 20%;
+ }
+ &:before {
+ left: -250%;
+ }
+ &:after {
+ right: -250%;
+ }
+ }
+ }
+ }
+}
diff --git a/src/react/components/ErrorMessage.tsx b/src/react/components/ErrorMessage.tsx
new file mode 100644
index 00000000..830d8440
--- /dev/null
+++ b/src/react/components/ErrorMessage.tsx
@@ -0,0 +1,24 @@
+import './ErrorMessage.scss';
+
+interface ErrorMessageProps {
+ message: string;
+}
+
+export default function ErrorMessage({ message }: ErrorMessageProps) {
+ return (
+
+
+
{message}
+
+ If you are offline viewing, you'll need to visit this page with a network connection first before it can work offline.
+
+
+ );
+}
diff --git a/src/react/components/Feed.scss b/src/react/components/Feed.scss
new file mode 100644
index 00000000..f747b1ba
--- /dev/null
+++ b/src/react/components/Feed.scss
@@ -0,0 +1,108 @@
+@import "../scss/media";
+@import "../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/components/Feed.tsx b/src/react/components/Feed.tsx
index a17d97f4..296583ca 100644
--- a/src/react/components/Feed.tsx
+++ b/src/react/components/Feed.tsx
@@ -1,5 +1,12 @@
-import { useParams } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import ErrorMessage from './ErrorMessage';
+import Item from './Item';
+import Loader from './Loader';
+import { fetchFeed } from '../api/hackernews';
import { FeedName } from '../models/feed-name.type';
+import { Story } from '../models/story';
+import './Feed.scss';
interface FeedProps {
feedName: FeedName;
@@ -7,11 +14,72 @@ interface FeedProps {
export default function Feed({ feedName }: FeedProps) {
const { page } = useParams<'page'>();
+ const pageNum = page ? +page : 1;
+ const [items, setItems] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [listStart, setListStart] = useState();
+
+ useEffect(() => {
+ let ignore = false;
+
+ setErrorMessage('');
+ fetchFeed(feedName, pageNum)
+ .then(nextItems => {
+ if (ignore) {
+ return;
+ }
+
+ setItems(nextItems);
+ setListStart((pageNum - 1) * 30 + 1);
+ window.scrollTo(0, 0);
+ })
+ .catch(() => {
+ if (ignore) {
+ return;
+ }
+
+ setErrorMessage('Could not load ' + feedName + ' stories.');
+ });
+
+ return () => {
+ ignore = true;
+ };
+ }, [feedName, pageNum]);
return (
-
- {feedName}
- Page {page}
-
+
+ {!items && !errorMessage &&
}
+ {!items && errorMessage &&
}
+ {items && (
+
+ {feedName === 'jobs' && (
+
+ These are jobs at startups that were funded by Y Combinator.
+ You can also get a job at a YC startup through{' '}
+ Triplebyte.
+
+ )}
+
+ {items.map(item => (
+ -
+
+
+ ))}
+
+
+ {listStart !== 1 && (
+
+ ‹ Prev
+
+ )}
+ {items.length === 30 && (
+
+ More ›
+
+ )}
+
+
+ )}
+
);
}
diff --git a/src/react/components/Item.scss b/src/react/components/Item.scss
new file mode 100644
index 00000000..b0c4e604
--- /dev/null
+++ b/src/react/components/Item.scss
@@ -0,0 +1,68 @@
+@import "../scss/media";
+@import "../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/components/Item.tsx b/src/react/components/Item.tsx
new file mode 100644
index 00000000..36e96d18
--- /dev/null
+++ b/src/react/components/Item.tsx
@@ -0,0 +1,93 @@
+import { NavLink } from 'react-router-dom';
+import { useSettings } from '../context/SettingsContext';
+import { Story } from '../models/story';
+import { formatComments } from '../utils/comments';
+import './Item.scss';
+
+interface ItemProps {
+ item: Story;
+}
+
+function internalLinkClassName({ isActive }: { isActive: boolean }): string {
+ return isActive ? 'active' : '';
+}
+
+export default function Item({ item }: 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})}
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {item.type !== 'job' && (
+
+
+
+ {item.user}
+
+
+ {item.points} ★
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+ `comment-number${isActive ? ' active' : ''}`}
+ >
+ {' • '}
+ {formatComments(item.comments_count)}
+
+ )}
+
+
+
+ {item.type !== 'job' && (
+
+ {item.points} points by{' '}
+
+ {item.user}
+
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' | '}
+
+ {formatComments(item.comments_count)}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/react/components/Loader.scss b/src/react/components/Loader.scss
new file mode 100644
index 00000000..0b75f225
--- /dev/null
+++ b/src/react/components/Loader.scss
@@ -0,0 +1,109 @@
+@import "../scss/media";
+@import "../scss/theme_variables";
+
+.loader {
+ -webkit-animation: load1 1s infinite ease-in-out;
+ animation: load1 1s infinite ease-in-out;
+ width: 1em;
+ height: 4em;
+ &:before, &:after {
+ -webkit-animation: load1 1s infinite ease-in-out;
+ animation: load1 1s infinite ease-in-out;
+ width: 1em;
+ height: 4em;
+ }
+ &:before, &:after {
+ position: absolute;
+ top: 0;
+ content: '';
+ }
+ &:before {
+ left: -1.5em;
+ -webkit-animation-delay: -0.32s;
+ animation-delay: -0.32s;
+ }
+}
+
+.loading-section {
+ height: 70px;
+ margin: 40px 0 40px 40px;
+
+ @media #{$mobile-only} {
+ display: block;
+ position: relative;
+ margin: 45vh 0;
+ }
+}
+
+.loader {
+ text-indent: -9999em;
+ margin: 20px 20px;
+ position: relative;
+ font-size: 11px;
+ -webkit-transform: translateZ(0);
+ -ms-transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-animation-delay: -0.16s;
+ animation-delay: -0.16s;
+ &:after {
+ left: 1.5em;
+ }
+
+ @media #{$mobile-only} {
+ margin: 20px auto;
+ }
+}
+
+@-webkit-keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 2em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 3em;
+ }
+}
+
+@keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 2em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 3em;
+ }
+}
+
+@media #{$mobile-only} {
+ @-webkit-keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 4em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 5em;
+ }
+ }
+
+ @keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 3em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 4em;
+ }
+ }
+}
diff --git a/src/react/components/Loader.tsx b/src/react/components/Loader.tsx
new file mode 100644
index 00000000..05ca5a18
--- /dev/null
+++ b/src/react/components/Loader.tsx
@@ -0,0 +1,9 @@
+import './Loader.scss';
+
+export default function Loader() {
+ return (
+
+ );
+}
diff --git a/src/react/routes.tsx b/src/react/routes.tsx
index 115c537a..814193e4 100644
--- a/src/react/routes.tsx
+++ b/src/react/routes.tsx
@@ -1,13 +1,13 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
-import { FeedName } from './models/feed-name.type';
import App from './App';
import Feed from './components/Feed';
+import Loader from './components/Loader';
const ItemDetails = lazy(() => import('./components/ItemDetails'));
const User = lazy(() => import('./components/User'));
-const lazyFallback = Loading...
;
+const lazyFallback = ;
export default function AppRoutes() {
return (