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, + }, +});