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
13 changes: 13 additions & 0 deletions react-app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hacker News PWA - React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions react-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "react-hn",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "tsc --noEmit",
"preview": "vite preview"
},
"keywords": ["hacker-news", "react", "typescript", "vite"],
"author": "",
"license": "MIT",
"description": "Hacker News PWA built with React and TypeScript",
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"@vitejs/plugin-react": "^5.1.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"devDependencies": {
"@types/react": "^19.2.11",
"@types/react-dom": "^19.2.3"
}
}
56 changes: 56 additions & 0 deletions react-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SettingsProvider } from './context/SettingsContext';
import { Feed } from './pages';

// Lazy load item details and user profile pages
const ItemDetails = lazy(() => import('./pages/ItemDetails').then(module => ({ default: module.ItemDetails })));
const UserProfile = lazy(() => import('./pages/UserProfile').then(module => ({ default: module.UserProfile })));

// Create a client for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});

// Loading component for Suspense fallback
function Loading() {
return <div>Loading...</div>;
}

function App() {
return (
<QueryClientProvider client={queryClient}>
<SettingsProvider>
<BrowserRouter>
<Suspense fallback={<Loading />}>
<Routes>
{/* Default redirect to news feed */}
<Route path="/" element={<Navigate to="/news/1" replace />} />

{/* Feed routes with pagination */}
<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" />} />

{/* Item details route (lazy loaded) */}
<Route path="/item/:id" element={<ItemDetails />} />

{/* User profile route (lazy loaded) */}
<Route path="/user/:id" element={<UserProfile />} />
</Routes>
</Suspense>
</BrowserRouter>
</SettingsProvider>
</QueryClientProvider>
);
}

export default App;
134 changes: 134 additions & 0 deletions react-app/src/context/SettingsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { Settings } from '../types';

// Default settings
const defaultSettings: Settings = {
showSettings: false,
openLinkInNewTab: false,
theme: 'default',
titleFontSize: '16',
listSpacing: '0',
};

// Load settings from localStorage
function loadSettingsFromStorage(): Settings {
const openLinkInNewTab = localStorage.getItem('openLinkInNewTab');
const theme = localStorage.getItem('theme') as Settings['theme'] | null;
const titleFontSize = localStorage.getItem('titleFontSize');
const listSpacing = localStorage.getItem('listSpacing');

return {
showSettings: false,
openLinkInNewTab: openLinkInNewTab ? JSON.parse(openLinkInNewTab) : defaultSettings.openLinkInNewTab,
theme: theme || defaultSettings.theme,
titleFontSize: titleFontSize || defaultSettings.titleFontSize,
listSpacing: listSpacing || defaultSettings.listSpacing,
};
}

// Context type
interface SettingsContextType {
settings: Settings;
toggleSettings: () => void;
toggleOpenLinksInNewTab: () => void;
setTheme: (theme: Settings['theme']) => void;
setFont: (fontSize: string) => void;
setSpacing: (listSpace: string) => void;
}

// Create context
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);

// Provider props
interface SettingsProviderProps {
children: ReactNode;
}

// Settings Provider component
export function SettingsProvider({ children }: SettingsProviderProps) {
const [settings, setSettings] = useState<Settings>(() => loadSettingsFromStorage());

// Handle system preferred color scheme changes
const handleSystemPreferredColorSchemeChange = useCallback((event: MediaQueryListEvent) => {
// Only apply system preference if no theme is saved
if (!localStorage.getItem('theme')) {
const newTheme = event.matches ? 'night' : 'default';
setSettings(prev => ({ ...prev, theme: newTheme }));
}
}, []);

// Initialize theme based on system preference if no saved theme
useEffect(() => {
const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');

// Subscribe to system color scheme changes
darkColorSchemeMedia.addEventListener('change', handleSystemPreferredColorSchemeChange);

// Initialize theme if not saved
if (!localStorage.getItem('theme')) {
const initialTheme = darkColorSchemeMedia.matches ? 'night' : 'default';
setSettings(prev => ({ ...prev, theme: initialTheme }));
}

// Cleanup listener on unmount
return () => {
darkColorSchemeMedia.removeEventListener('change', handleSystemPreferredColorSchemeChange);
};
}, [handleSystemPreferredColorSchemeChange]);

// Toggle settings panel visibility
const toggleSettings = useCallback(() => {
setSettings(prev => ({ ...prev, showSettings: !prev.showSettings }));
}, []);

// Toggle open links in new tab setting
const toggleOpenLinksInNewTab = useCallback(() => {
setSettings(prev => {
const newValue = !prev.openLinkInNewTab;
localStorage.setItem('openLinkInNewTab', JSON.stringify(newValue));
return { ...prev, openLinkInNewTab: newValue };
});
}, []);

// Set theme
const setTheme = useCallback((theme: Settings['theme']) => {
localStorage.setItem('theme', theme);
setSettings(prev => ({ ...prev, theme }));
}, []);

// Set font size
const setFont = useCallback((fontSize: string) => {
localStorage.setItem('titleFontSize', fontSize);
setSettings(prev => ({ ...prev, titleFontSize: fontSize }));
}, []);

// Set list spacing
const setSpacing = useCallback((listSpace: string) => {
localStorage.setItem('listSpacing', listSpace);
setSettings(prev => ({ ...prev, listSpacing: listSpace }));
}, []);

const value: SettingsContextType = {
settings,
toggleSettings,
toggleOpenLinksInNewTab,
setTheme,
setFont,
setSpacing,
};

return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}

// Custom hook to use settings context
export function useSettings(): SettingsContextType {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}
42 changes: 42 additions & 0 deletions react-app/src/hooks/useHackerNews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { fetchFeed, fetchItemContent, fetchUser } from '../services/hackerNewsApi';
import { Story, User, FeedRouteType } from '../types';

/**
* Hook to fetch a feed of stories
* @param feedType - The type of feed (news, newest, show, ask, jobs)
* @param page - The page number for pagination
*/
export function useFeed(feedType: FeedRouteType, page: number) {
return useQuery<Story[], Error>({
queryKey: ['feed', feedType, page],
queryFn: () => fetchFeed(feedType, page),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

/**
* Hook to fetch a single item's content
* @param id - The item ID
*/
export function useItem(id: number) {
return useQuery<Story, Error>({
queryKey: ['item', id],
queryFn: () => fetchItemContent(id),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: id > 0,
});
}

/**
* Hook to fetch user data
* @param id - The user ID (username)
*/
export function useUser(id: string) {
return useQuery<User, Error>({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!id,
});
}
9 changes: 9 additions & 0 deletions react-app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
40 changes: 40 additions & 0 deletions react-app/src/pages/Feed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useParams } from 'react-router-dom';
import { useFeed } from '../hooks/useHackerNews';
import { FeedRouteType } from '../types';

interface FeedProps {
feedType: FeedRouteType;
}

export function Feed({ feedType }: FeedProps) {
const { page = '1' } = useParams<{ page: string }>();
const pageNumber = parseInt(page, 10) || 1;

const { data: stories, isLoading, error } = useFeed(feedType, pageNumber);

if (isLoading) {
return <div>Loading...</div>;
}

if (error) {
return <div>Error: {error.message}</div>;
}

if (!stories || stories.length === 0) {
return <div>No stories found</div>;
}

return (
<div>
<h1>{feedType.charAt(0).toUpperCase() + feedType.slice(1)} - Page {pageNumber}</h1>
<ul>
{stories.map((story) => (
<li key={story.id}>
<a href={story.url || `/item/${story.id}`}>{story.title}</a>
<span> ({story.points} points by {story.user})</span>
</li>
))}
</ul>
</div>
);
}
37 changes: 37 additions & 0 deletions react-app/src/pages/ItemDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useParams } from 'react-router-dom';
import { useItem } from '../hooks/useHackerNews';

export function ItemDetails() {
const { id } = useParams<{ id: string }>();
const itemId = parseInt(id || '0', 10);

const { data: item, isLoading, error } = useItem(itemId);

if (isLoading) {
return <div>Loading...</div>;
}

if (error) {
return <div>Error: {error.message}</div>;
}

if (!item) {
return <div>Item not found</div>;
}

return (
<div>
<h1>{item.title}</h1>
<p>
{item.points} points by {item.user} | {item.comments_count} comments
</p>
{item.url && (
<p>
<a href={item.url} target="_blank" rel="noopener noreferrer">
{item.domain}
</a>
</p>
)}
</div>
);
}
31 changes: 31 additions & 0 deletions react-app/src/pages/UserProfile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useParams } from 'react-router-dom';
import { useUser } from '../hooks/useHackerNews';

export function UserProfile() {
const { id } = useParams<{ id: string }>();

const { data: user, isLoading, error } = useUser(id || '');

if (isLoading) {
return <div>Loading...</div>;
}

if (error) {
return <div>Error: {error.message}</div>;
}

if (!user) {
return <div>User not found</div>;
}

return (
<div>
<h1>{user.id}</h1>
<p>Karma: {user.karma}</p>
<p>Created: {user.created}</p>
{user.about && (
<div dangerouslySetInnerHTML={{ __html: user.about }} />
)}
</div>
);
}
Loading