diff --git a/src/components/Item.scss b/src/components/Item.scss new file mode 100644 index 00000000..1e7cd4ad --- /dev/null +++ b/src/components/Item.scss @@ -0,0 +1,69 @@ +@use "../app/shared/scss/media" as *; + +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/components/Item.tsx b/src/components/Item.tsx new file mode 100644 index 00000000..6dff707a --- /dev/null +++ b/src/components/Item.tsx @@ -0,0 +1,83 @@ +import { Link } from 'react-router-dom'; +import type { Story } from '../types/Story'; +import { useSettings } from '../hooks/useSettings'; +import { formatCommentCount } from '../utils/formatCommentCount'; +import './Item.scss'; + +interface ItemProps { + item: Story; + index: number; +} + +export function Item({ item }: ItemProps) { + const { settings } = useSettings(); + const hasUrl = item.url?.indexOf('http') === 0; + + return ( +
+ {hasUrl ? ( +

+ + {item.title} + + {item.domain && ({item.domain})} +

+ ) : ( +

+ + {item.title} + +

+ )} + +
+ {item.type !== 'job' && ( +
+ + {item.user} + + {item.points} ★ +
+ )} +
+ {item.time_ago} + {item.type !== 'job' && ( + + {' '}• {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/src/pages/Feed.scss b/src/pages/Feed.scss new file mode 100644 index 00000000..248e396d --- /dev/null +++ b/src/pages/Feed.scss @@ -0,0 +1,104 @@ +@use "../app/shared/scss/media" as *; + +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; + transition: background-color 0.2s ease; + } +} + +.list-margin { + @media #{$mobile-only} { + margin-top: 55px; + } +} + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + transition: opacity 0.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/pages/Feed.tsx b/src/pages/Feed.tsx new file mode 100644 index 00000000..90defbc5 --- /dev/null +++ b/src/pages/Feed.tsx @@ -0,0 +1,113 @@ +import { useEffect, useReducer } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { fetchFeed } from '../api/hackerNewsApi'; +import type { Story } from '../types/Story'; +import { Item } from '../components/Item'; +import { Loader } from '../components/Loader'; +import { ErrorMessage } from '../components/ErrorMessage'; +import './Feed.scss'; + +interface FeedProps { + feedType: string; +} + +interface FeedState { + items: Story[] | null; + errorMessage: string; +} + +type FeedAction = + | { type: 'loading' } + | { type: 'loaded'; items: Story[] } + | { type: 'error'; message: string }; + +function feedReducer(_state: FeedState, action: FeedAction): FeedState { + switch (action.type) { + case 'loading': + return { items: null, errorMessage: '' }; + case 'loaded': + return { items: action.items, errorMessage: '' }; + case 'error': + return { items: null, errorMessage: action.message }; + } +} + +export function Feed({ feedType }: FeedProps) { + const { page } = useParams<{ page: string }>(); + const pageNum = page ? parseInt(page, 10) : 1; + const [state, dispatch] = useReducer(feedReducer, { + items: null, + errorMessage: '', + }); + + useEffect(() => { + let cancelled = false; + dispatch({ type: 'loading' }); + + fetchFeed(feedType, pageNum) + .then((data) => { + if (!cancelled) { + dispatch({ type: 'loaded', items: data }); + window.scrollTo(0, 0); + } + }) + .catch(() => { + if (!cancelled) { + dispatch({ + type: 'error', + message: `Could not load ${feedType} stories.`, + }); + } + }); + + return () => { cancelled = true; }; + }, [feedType, pageNum]); + + const { items, errorMessage } = state; + const listStart = (pageNum - 1) * 30 + 1; + + 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 !== 'new' && ( +
    + {items.map((item, index) => ( +
  1. + +
  2. + ))} +
+ )} + +
+ {listStart !== 1 && ( + + ‹ Prev + + )} + {items.length === 30 && ( + + More › + + )} +
+
+ )} +
+ ); +}