diff --git a/react-app/index.html b/react-app/index.html
new file mode 100644
index 00000000..1082c308
--- /dev/null
+++ b/react-app/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Hacker News PWA - React
+
+
+
+
+
+
diff --git a/react-app/package.json b/react-app/package.json
new file mode 100644
index 00000000..38bd9c7e
--- /dev/null
+++ b/react-app/package.json
@@ -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"
+ }
+}
diff --git a/react-app/src/App.tsx b/react-app/src/App.tsx
new file mode 100644
index 00000000..35b086dc
--- /dev/null
+++ b/react-app/src/App.tsx
@@ -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 Loading...
;
+}
+
+function App() {
+ return (
+
+
+
+ }>
+
+ {/* Default redirect to news feed */}
+ } />
+
+ {/* Feed routes with pagination */}
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Item details route (lazy loaded) */}
+ } />
+
+ {/* User profile route (lazy loaded) */}
+ } />
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/react-app/src/context/SettingsContext.tsx b/react-app/src/context/SettingsContext.tsx
new file mode 100644
index 00000000..4447b290
--- /dev/null
+++ b/react-app/src/context/SettingsContext.tsx
@@ -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(undefined);
+
+// Provider props
+interface SettingsProviderProps {
+ children: ReactNode;
+}
+
+// Settings Provider component
+export function SettingsProvider({ children }: SettingsProviderProps) {
+ const [settings, setSettings] = useState(() => 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 (
+
+ {children}
+
+ );
+}
+
+// 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;
+}
diff --git a/react-app/src/hooks/useHackerNews.ts b/react-app/src/hooks/useHackerNews.ts
new file mode 100644
index 00000000..bf4bdc02
--- /dev/null
+++ b/react-app/src/hooks/useHackerNews.ts
@@ -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({
+ 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({
+ 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({
+ queryKey: ['user', id],
+ queryFn: () => fetchUser(id),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ enabled: !!id,
+ });
+}
diff --git a/react-app/src/main.tsx b/react-app/src/main.tsx
new file mode 100644
index 00000000..9707d827
--- /dev/null
+++ b/react-app/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/react-app/src/pages/Feed.tsx b/react-app/src/pages/Feed.tsx
new file mode 100644
index 00000000..da4de90b
--- /dev/null
+++ b/react-app/src/pages/Feed.tsx
@@ -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 Loading...
;
+ }
+
+ if (error) {
+ return Error: {error.message}
;
+ }
+
+ if (!stories || stories.length === 0) {
+ return No stories found
;
+ }
+
+ return (
+
+
{feedType.charAt(0).toUpperCase() + feedType.slice(1)} - Page {pageNumber}
+
+ {stories.map((story) => (
+ -
+ {story.title}
+ ({story.points} points by {story.user})
+
+ ))}
+
+
+ );
+}
diff --git a/react-app/src/pages/ItemDetails.tsx b/react-app/src/pages/ItemDetails.tsx
new file mode 100644
index 00000000..116a3f8b
--- /dev/null
+++ b/react-app/src/pages/ItemDetails.tsx
@@ -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 Loading...
;
+ }
+
+ if (error) {
+ return Error: {error.message}
;
+ }
+
+ if (!item) {
+ return Item not found
;
+ }
+
+ return (
+
+
{item.title}
+
+ {item.points} points by {item.user} | {item.comments_count} comments
+
+ {item.url && (
+
+
+ {item.domain}
+
+
+ )}
+
+ );
+}
diff --git a/react-app/src/pages/UserProfile.tsx b/react-app/src/pages/UserProfile.tsx
new file mode 100644
index 00000000..3d808e27
--- /dev/null
+++ b/react-app/src/pages/UserProfile.tsx
@@ -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 Loading...
;
+ }
+
+ if (error) {
+ return Error: {error.message}
;
+ }
+
+ if (!user) {
+ return User not found
;
+ }
+
+ return (
+
+
{user.id}
+
Karma: {user.karma}
+
Created: {user.created}
+ {user.about && (
+
+ )}
+
+ );
+}
diff --git a/react-app/src/pages/index.ts b/react-app/src/pages/index.ts
new file mode 100644
index 00000000..c697c2e9
--- /dev/null
+++ b/react-app/src/pages/index.ts
@@ -0,0 +1,3 @@
+export { Feed } from './Feed';
+export { ItemDetails } from './ItemDetails';
+export { UserProfile } from './UserProfile';
diff --git a/react-app/src/services/hackerNewsApi.ts b/react-app/src/services/hackerNewsApi.ts
new file mode 100644
index 00000000..906cde8b
--- /dev/null
+++ b/react-app/src/services/hackerNewsApi.ts
@@ -0,0 +1,75 @@
+import { Story, User, PollResult } from '../types';
+
+const BASE_URL = 'https://node-hnapi.herokuapp.com';
+
+/**
+ * Fetch a feed of stories from the Hacker News API
+ * @param feedType - The type of feed (news, newest, show, ask, jobs)
+ * @param page - The page number for pagination
+ * @returns Promise resolving to an array of Story objects
+ */
+export async function fetchFeed(feedType: string, page: number): Promise {
+ const response = await fetch(`${BASE_URL}/${feedType}?page=${page}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${feedType} feed: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+/**
+ * Fetch a single item's content by ID
+ * @param id - The item ID
+ * @returns Promise resolving to a Story object
+ */
+export async function fetchItemContent(id: number): Promise {
+ const response = await fetch(`${BASE_URL}/item/${id}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch item ${id}: ${response.statusText}`);
+ }
+ const story: Story = await response.json();
+
+ // Handle poll type items
+ if (story.type === 'poll' && story.poll && story.poll.length > 0) {
+ const numberOfPollOptions = story.poll.length;
+ story.poll_votes_count = 0;
+
+ // Fetch poll options in parallel
+ const pollPromises = Array.from({ length: numberOfPollOptions }, (_, i) =>
+ fetchPollContent(story.id + i + 1)
+ );
+
+ const pollResults = await Promise.all(pollPromises);
+ pollResults.forEach((pollResult, index) => {
+ story.poll[index] = pollResult;
+ story.poll_votes_count += pollResult.points;
+ });
+ }
+
+ return story;
+}
+
+/**
+ * Fetch poll content by ID
+ * @param id - The poll item ID
+ * @returns Promise resolving to a PollResult object
+ */
+export async function fetchPollContent(id: number): Promise {
+ const response = await fetch(`${BASE_URL}/item/${id}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch poll ${id}: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+/**
+ * Fetch user data by ID
+ * @param id - The user ID (username)
+ * @returns Promise resolving to a User object
+ */
+export async function fetchUser(id: string): Promise {
+ const response = await fetch(`${BASE_URL}/user/${id}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch user ${id}: ${response.statusText}`);
+ }
+ return response.json();
+}
diff --git a/react-app/src/types/index.ts b/react-app/src/types/index.ts
new file mode 100644
index 00000000..7b5fd84e
--- /dev/null
+++ b/react-app/src/types/index.ts
@@ -0,0 +1,61 @@
+// Feed type for items
+export type FeedType = 'poll' | 'story' | 'job';
+
+// Poll result type
+export interface PollResult {
+ points: number;
+ content: string;
+}
+
+// Comment type (recursive)
+export interface Comment {
+ id: number;
+ level: number;
+ user: string;
+ time: number;
+ time_ago: string;
+ content: string;
+ deleted: boolean;
+ comments: Comment[];
+}
+
+// Story/Item type
+export interface Story {
+ id: number;
+ title: string;
+ points: number;
+ user: string;
+ time: number;
+ time_ago: number;
+ type: FeedType;
+ url: string;
+ domain: string;
+ comments: Comment[];
+ comments_count: number;
+ poll: PollResult[];
+ poll_votes_count: number;
+ deleted: boolean;
+ dead: boolean;
+}
+
+// User type
+export interface User {
+ id: string;
+ created_time: number;
+ created: string;
+ karma: number;
+ avg: number;
+ about: string;
+}
+
+// Settings type
+export interface Settings {
+ showSettings: boolean;
+ openLinkInNewTab: boolean;
+ theme: 'default' | 'night' | 'amoled';
+ titleFontSize: string;
+ listSpacing: string;
+}
+
+// Feed route types
+export type FeedRouteType = 'news' | 'newest' | 'show' | 'ask' | 'jobs';
diff --git a/react-app/src/vite-env.d.ts b/react-app/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/react-app/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/react-app/tsconfig.json b/react-app/tsconfig.json
new file mode 100644
index 00000000..97d67b8b
--- /dev/null
+++ b/react-app/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/react-app/tsconfig.node.json b/react-app/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/react-app/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts
new file mode 100644
index 00000000..ea3940fa
--- /dev/null
+++ b/react-app/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 3000,
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+});