From de7c2bf8879455674f9c7fd6effae971aecb9832 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:36:48 +0000 Subject: [PATCH] feature: add React core logic Co-Authored-By: Charity Quinn --- src-react/shared/api/hackernewsApi.ts | 40 ++++++ src-react/shared/models/comment.ts | 10 ++ src-react/shared/models/feedType.ts | 3 + src-react/shared/models/index.ts | 6 + src-react/shared/models/pollResult.ts | 4 + src-react/shared/models/settings.ts | 7 + src-react/shared/models/story.ts | 23 ++++ src-react/shared/models/user.ts | 8 ++ src-react/shared/settings/SettingsContext.tsx | 123 ++++++++++++++++++ src-react/shared/utils/formatComments.ts | 7 + 10 files changed, 231 insertions(+) create mode 100644 src-react/shared/api/hackernewsApi.ts create mode 100644 src-react/shared/models/comment.ts create mode 100644 src-react/shared/models/feedType.ts create mode 100644 src-react/shared/models/index.ts create mode 100644 src-react/shared/models/pollResult.ts create mode 100644 src-react/shared/models/settings.ts create mode 100644 src-react/shared/models/story.ts create mode 100644 src-react/shared/models/user.ts create mode 100644 src-react/shared/settings/SettingsContext.tsx create mode 100644 src-react/shared/utils/formatComments.ts diff --git a/src-react/shared/api/hackernewsApi.ts b/src-react/shared/api/hackernewsApi.ts new file mode 100644 index 000000000..a275008d4 --- /dev/null +++ b/src-react/shared/api/hackernewsApi.ts @@ -0,0 +1,40 @@ +import type { PollResult, Story, User } from '../models'; + +const baseUrl = 'https://node-hnapi.herokuapp.com'; + +async function fetchJson(url: string, options?: RequestInit): Promise { + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}: ${response.statusText}`); + } + + return response.json() as Promise; +} + +export function fetchFeed(feedType: string, page: number): Promise { + return fetchJson(`${baseUrl}/${feedType}?page=${page}`); +} + +export async function fetchItemContent(id: number): Promise { + const story = await fetchJson(`${baseUrl}/item/${id}`); + + if (story.type === 'poll') { + const pollResults = await Promise.all( + story.poll.map((_, index) => fetchPollContent(story.id + index + 1)), + ); + + story.poll = pollResults; + story.poll_votes_count = pollResults.reduce((total, pollResult) => total + pollResult.points, 0); + } + + return story; +} + +export function fetchPollContent(id: number): Promise { + return fetchJson(`${baseUrl}/item/${id}`); +} + +export function fetchUser(id: string): Promise { + return fetchJson(`${baseUrl}/user/${id}`); +} diff --git a/src-react/shared/models/comment.ts b/src-react/shared/models/comment.ts new file mode 100644 index 000000000..4a8da4f4d --- /dev/null +++ b/src-react/shared/models/comment.ts @@ -0,0 +1,10 @@ +export interface Comment { + id: number; + level: number; + user: string; + time: number; + time_ago: string; + content: string; + deleted: boolean; + comments: Comment[]; +} diff --git a/src-react/shared/models/feedType.ts b/src-react/shared/models/feedType.ts new file mode 100644 index 000000000..ae9923742 --- /dev/null +++ b/src-react/shared/models/feedType.ts @@ -0,0 +1,3 @@ +export type FeedType = 'poll' | 'story' | 'job'; + +export type FeedName = 'news' | 'newest' | 'show' | 'ask' | 'jobs'; diff --git a/src-react/shared/models/index.ts b/src-react/shared/models/index.ts new file mode 100644 index 000000000..b52b9713c --- /dev/null +++ b/src-react/shared/models/index.ts @@ -0,0 +1,6 @@ +export type { Comment } from './comment'; +export type { FeedName, FeedType } from './feedType'; +export type { PollResult } from './pollResult'; +export type { Settings } from './settings'; +export type { Story } from './story'; +export type { User } from './user'; diff --git a/src-react/shared/models/pollResult.ts b/src-react/shared/models/pollResult.ts new file mode 100644 index 000000000..86a8a60b9 --- /dev/null +++ b/src-react/shared/models/pollResult.ts @@ -0,0 +1,4 @@ +export interface PollResult { + points: number; + content: string; +} diff --git a/src-react/shared/models/settings.ts b/src-react/shared/models/settings.ts new file mode 100644 index 000000000..cf08c432b --- /dev/null +++ b/src-react/shared/models/settings.ts @@ -0,0 +1,7 @@ +export interface Settings { + showSettings: boolean; + openLinkInNewTab: boolean; + theme: string; + titleFontSize: string; + listSpacing: string; +} diff --git a/src-react/shared/models/story.ts b/src-react/shared/models/story.ts new file mode 100644 index 000000000..611335c6c --- /dev/null +++ b/src-react/shared/models/story.ts @@ -0,0 +1,23 @@ +import type { Comment } from './comment'; +import type { FeedType } from './feedType'; +import type { PollResult } from './pollResult'; + +export interface Story { + id: number; + title: string; + points: number; + user: string; + time: number; + time_ago: number; + type: FeedType; + url: string; + domain: string; + content?: string; + text?: string; + comments: Comment[]; + comments_count: number; + poll: PollResult[]; + poll_votes_count: number; + deleted: boolean; + dead: boolean; +} diff --git a/src-react/shared/models/user.ts b/src-react/shared/models/user.ts new file mode 100644 index 000000000..3d95727ff --- /dev/null +++ b/src-react/shared/models/user.ts @@ -0,0 +1,8 @@ +export interface User { + id: string; + crated_time: number; + created: string; + karma: number; + avg: number; + about: string; +} diff --git a/src-react/shared/settings/SettingsContext.tsx b/src-react/shared/settings/SettingsContext.tsx new file mode 100644 index 000000000..98872aa7d --- /dev/null +++ b/src-react/shared/settings/SettingsContext.tsx @@ -0,0 +1,123 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; + +import type { Settings } from '../models'; + +interface SettingsContextValue { + settings: Settings; + toggleSettings: () => void; + toggleOpenLinksInNewTab: () => void; + setTheme: (theme: string) => void; + setFont: (fontSize: string) => void; + setSpacing: (listSpace: string) => void; +} + +const SettingsContext = createContext(undefined); + +function getInitialSettings(): Settings { + const openLinkInNewTab = localStorage.getItem('openLinkInNewTab'); + const titleFontSize = localStorage.getItem('titleFontSize'); + const listSpacing = localStorage.getItem('listSpacing'); + + return { + showSettings: false, + openLinkInNewTab: openLinkInNewTab ? JSON.parse(openLinkInNewTab) : false, + theme: 'default', + titleFontSize: titleFontSize || '16', + listSpacing: listSpacing || '0', + }; +} + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(getInitialSettings); + + const toggleSettings = useCallback(() => { + setSettings((current) => ({ + ...current, + showSettings: !current.showSettings, + })); + }, []); + + const toggleOpenLinksInNewTab = useCallback(() => { + setSettings((current) => { + const openLinkInNewTab = !current.openLinkInNewTab; + localStorage.setItem('openLinkInNewTab', JSON.stringify(openLinkInNewTab)); + + return { + ...current, + openLinkInNewTab, + }; + }); + }, []); + + const setTheme = useCallback((theme: string) => { + setSettings((current) => ({ + ...current, + theme, + })); + localStorage.setItem('theme', theme); + }, []); + + const setFont = useCallback((titleFontSize: string) => { + setSettings((current) => ({ + ...current, + titleFontSize, + })); + localStorage.setItem('titleFontSize', titleFontSize); + }, []); + + const setSpacing = useCallback((listSpacing: string) => { + setSettings((current) => ({ + ...current, + listSpacing, + })); + localStorage.setItem('listSpacing', listSpacing); + }, []); + + useEffect(() => { + const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + const handleSystemPreferredColorSchemeChange = (event: MediaQueryListEvent) => { + setTheme(event.matches ? 'night' : 'default'); + }; + const savedTheme = localStorage.getItem('theme'); + + if (savedTheme) { + setSettings((current) => ({ + ...current, + theme: savedTheme, + })); + } else { + setTheme(darkColorSchemeMedia.matches ? 'night' : 'default'); + } + + darkColorSchemeMedia.addEventListener('change', handleSystemPreferredColorSchemeChange); + + return () => { + darkColorSchemeMedia.removeEventListener('change', handleSystemPreferredColorSchemeChange); + }; + }, [setTheme]); + + const value = useMemo( + () => ({ + settings, + toggleSettings, + toggleOpenLinksInNewTab, + setTheme, + setFont, + setSpacing, + }), + [settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing], + ); + + return {children}; +} + +export function useSettings(): SettingsContextValue { + const context = useContext(SettingsContext); + + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + + return context; +} diff --git a/src-react/shared/utils/formatComments.ts b/src-react/shared/utils/formatComments.ts new file mode 100644 index 000000000..3fb8a6efb --- /dev/null +++ b/src-react/shared/utils/formatComments.ts @@ -0,0 +1,7 @@ +export function formatComments(count: number): string { + if (count > 0) { + return `${count} ${count === 1 ? 'comment' : 'comments'}`; + } + + return 'discuss'; +}