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