Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions react-hn/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { SettingsProvider } from './context/SettingsContext';
import { useSettings } from './context/useSettings';
import { Header } from './components/layout/Header';
import { Footer } from './components/layout/Footer';
import { Feed } from './components/feeds/Feed';
import { ItemDetails } from './components/item-details/ItemDetails';
import { UserProfile } from './components/user/UserProfile';
import './styles/global.scss';
import './styles/App.scss';

function AppShell() {
const { settings } = useSettings();

return (
<div className={settings.theme}>
<div className="body-cover"></div>
<div className="wrapper">
<Header />
<Routes>
<Route path="/" element={<Navigate to="/news/1" replace />} />
<Route path="/news/:page" element={<Feed feedType="news" />} />
<Route path="/newest/:page" element={<Feed feedType="newest" />} />
<Route path="/show/:page" element={<Feed feedType="show" />} />
<Route path="/ask/:page" element={<Feed feedType="ask" />} />
<Route path="/jobs/:page" element={<Feed feedType="jobs" />} />
<Route path="/item/:id" element={<ItemDetails />} />
<Route path="/user/:id" element={<UserProfile />} />
</Routes>
<Footer />
</div>
</div>
);
}

export function App() {
return (
<BrowserRouter>
<SettingsProvider>
<AppShell />
</SettingsProvider>
</BrowserRouter>
);
}
Empty file removed react-hn/src/api/.gitkeep
Empty file.
42 changes: 42 additions & 0 deletions react-hn/src/api/hn-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Story, User, PollResult } from '../models/types';

const BASE_URL = 'https://node-hnapi.herokuapp.com';

export async function fetchFeed(feedType: string, page: number): Promise<Story[]> {
const res = await fetch(`${BASE_URL}/${feedType}?page=${page}`);
if (!res.ok) throw new Error(`Failed to fetch ${feedType} feed`);
return res.json() as Promise<Story[]>;
}

export async function fetchItemContent(id: number): Promise<Story> {
const res = await fetch(`${BASE_URL}/item/${id}`);
if (!res.ok) throw new Error(`Failed to fetch item ${id}`);
const story = (await res.json()) as Story;

if (story.type === 'poll' && story.poll) {
const numberOfPollOptions = story.poll.length;
story.poll_votes_count = 0;
const pollPromises = Array.from({ length: numberOfPollOptions }, (_, i) =>
fetchPollContent(story.id + i + 1)
);
const pollResults = await Promise.all(pollPromises);
pollResults.forEach((result, i) => {
story.poll![i] = result;
story.poll_votes_count! += result.points;
});
}

return story;
}

export async function fetchPollContent(id: number): Promise<PollResult> {
const res = await fetch(`${BASE_URL}/item/${id}`);
if (!res.ok) throw new Error(`Failed to fetch poll item ${id}`);
return res.json() as Promise<PollResult>;
}

export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`${BASE_URL}/user/${id}`);
if (!res.ok) throw new Error(`Failed to fetch user ${id}`);
return res.json() as Promise<User>;
}
Empty file.
84 changes: 84 additions & 0 deletions react-hn/src/components/feeds/Feed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect, useState, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import { fetchFeed } from '../../api/hn-api';
import { Loader } from '../shared/Loader';
import { ErrorMessage } from '../shared/ErrorMessage';
import { FeedItem } from './FeedItem';
import type { Story } from '../../models/types';
import '../../styles/Feed.scss';

interface FeedProps {
feedType: string;
}

export function Feed({ feedType }: FeedProps) {
const { page } = useParams<{ page: string }>();
const pageNum = page ? parseInt(page, 10) : 1;
const [items, setItems] = useState<Story[] | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const requestRef = useRef(0);

useEffect(() => {
const requestId = ++requestRef.current;
let cancelled = false;

fetchFeed(feedType, pageNum)
.then(data => {
if (!cancelled && requestId === requestRef.current) {
setItems(data);
setErrorMessage('');
window.scrollTo(0, 0);
}
})
.catch(() => {
if (!cancelled && requestId === requestRef.current) {
setItems(null);
setErrorMessage(`Could not load ${feedType} stories.`);
}
});

return () => { cancelled = true; };
}, [feedType, pageNum]);

const listStart = ((pageNum - 1) * 30) + 1;

return (
<div className="main-content feed">
{!items && !errorMessage && <Loader />}
{!items && errorMessage !== '' && <ErrorMessage message={errorMessage} />}

{items && (
<div>
{feedType === 'jobs' && (
<p className="job-header">
These are jobs at startups that were funded by Y Combinator.
You can also get a job at a YC startup through{' '}
<a href="https://triplebyte.com/?ref=yc_jobs">Triplebyte</a>.
</p>
)}
{feedType !== 'new' && (
<ol className={feedType !== 'jobs' ? 'list-margin' : undefined} start={listStart}>
{items.map(item => (
<li key={item.id} className="post">
<FeedItem item={item} />
</li>
))}
</ol>
)}
<div className="nav">
{listStart !== 1 && (
<Link to={`/${feedType}/${pageNum - 1}`} className="prev">
‹ Prev
</Link>
)}
{items.length === 30 && (
<Link to={`/${feedType}/${pageNum + 1}`} className="more">
More ›
</Link>
)}
</div>
</div>
)}
</div>
);
}
78 changes: 78 additions & 0 deletions react-hn/src/components/feeds/FeedItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Link } from 'react-router-dom';
import { useSettings } from '../../context/useSettings';
import { formatComment } from '../../utils/formatComment';
import type { Story } from '../../models/types';
import '../../styles/FeedItem.scss';

interface FeedItemProps {
item: Story;
}

export function FeedItem({ item }: FeedItemProps) {
const { settings } = useSettings();
const hasUrl = item.url ? item.url.indexOf('http') === 0 : false;

return (
<div className="feed-item" style={{ marginBottom: `${settings.listSpacing}px` }}>
{hasUrl ? (
<p>
<a
className="title"
style={{ fontSize: `${settings.titleFontSize}px` }}
href={item.url}
target={settings.openLinkInNewTab ? '_blank' : undefined}
rel={settings.openLinkInNewTab ? 'noopener' : undefined}
>
{item.title}
</a>
{item.domain && <span className="domain"> ({item.domain})</span>}
</p>
) : (
<p>
<Link
className="title"
style={{ fontSize: `${settings.titleFontSize}px` }}
to={`/item/${item.id}`}
>
{item.title}
</Link>
</p>
)}
<div className="subtext-palm">
{item.type !== 'job' && (
<div className="details">
<span className="name">
<Link to={`/user/${item.user}`}>{item.user}</Link>
</span>
<span className="right">{item.points} ★</span>
</div>
)}
<div className="details">
{item.time_ago}
{item.type !== 'job' && (
<Link to={`/item/${item.id}`} className="comment-number">
{' '}• {formatComment(item.comments_count)}
</Link>
)}
</div>
</div>
<div className="subtext-laptop">
{item.type !== 'job' && (
<span>
{item.points} points by{' '}
<Link to={`/user/${item.user}`}>{item.user}</Link>
</span>
)}
<span className={item.type !== 'job' ? 'item-details' : undefined}>
{item.time_ago}
{item.type !== 'job' && (
<span>
{' '}|{' '}
<Link to={`/item/${item.id}`}>{formatComment(item.comments_count)}</Link>
</span>
)}
</span>
</div>
</div>
);
}
Empty file.
48 changes: 48 additions & 0 deletions react-hn/src/components/item-details/Comment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { Comment as CommentType } from '../../models/types';
import '../../styles/Comment.scss';

interface CommentProps {
comment: CommentType;
}

export function Comment({ comment }: CommentProps) {
const [collapsed, setCollapsed] = useState(false);

if (comment.deleted) {
return (
<div className="comment-component">
<div className="deleted-meta">
<span className="collapse">[deleted]</span> | Comment Deleted
</div>
</div>
);
}

return (
<div className="comment-component">
<div className={`meta${collapsed ? ' meta-collapse' : ''}`}>
<span className="collapse" onClick={() => setCollapsed(!collapsed)}>
[{collapsed ? '+' : '-'}]
</span>{' '}
<Link to={`/user/${comment.user}`}>{comment.user}</Link>
<span className="time">{comment.time_ago}</span>
</div>
<div className="comment-tree">
{!collapsed && (
<div>
<p className="comment-text" dangerouslySetInnerHTML={{ __html: comment.content ?? '' }} />
<ul className="subtree">
{comment.comments.map(subComment => (
<li key={subComment.id}>
<Comment comment={subComment} />
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
Loading