From 64e79a483100dc59ab222af32ae6a86a6a9296f2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:51:44 +0000 Subject: [PATCH] feature: add React feed and item components Co-Authored-By: Paul Abboud --- src/react/components/ErrorMessage.scss | 115 +++++++++++++++++++++++++ src/react/components/ErrorMessage.tsx | 24 ++++++ src/react/components/Feed.scss | 108 +++++++++++++++++++++++ src/react/components/Feed.tsx | 78 +++++++++++++++-- src/react/components/Item.scss | 68 +++++++++++++++ src/react/components/Item.tsx | 93 ++++++++++++++++++++ src/react/components/Loader.scss | 109 +++++++++++++++++++++++ src/react/components/Loader.tsx | 9 ++ src/react/routes.tsx | 4 +- 9 files changed, 601 insertions(+), 7 deletions(-) create mode 100644 src/react/components/ErrorMessage.scss create mode 100644 src/react/components/ErrorMessage.tsx create mode 100644 src/react/components/Feed.scss create mode 100644 src/react/components/Item.scss create mode 100644 src/react/components/Item.tsx create mode 100644 src/react/components/Loader.scss create mode 100644 src/react/components/Loader.tsx diff --git a/src/react/components/ErrorMessage.scss b/src/react/components/ErrorMessage.scss new file mode 100644 index 000000000..f05d1f593 --- /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 000000000..830d84404 --- /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 000000000..f747b1ba6 --- /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 a17d97f4b..296583ca2 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 => ( +
  1. + +
  2. + ))} +
+
+ {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 000000000..b0c4e6041 --- /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 000000000..36e96d18c --- /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 000000000..0b75f2254 --- /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 000000000..05ca5a18d --- /dev/null +++ b/src/react/components/Loader.tsx @@ -0,0 +1,9 @@ +import './Loader.scss'; + +export default function Loader() { + return ( +
+
Loading...
+
+ ); +} diff --git a/src/react/routes.tsx b/src/react/routes.tsx index 115c537a5..814193e40 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 (