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
40 changes: 40 additions & 0 deletions src-react/shared/api/hackernewsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { PollResult, Story, User } from '../models';

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

async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
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<T>;
}

export function fetchFeed(feedType: string, page: number): Promise<Story[]> {
return fetchJson<Story[]>(`${baseUrl}/${feedType}?page=${page}`);
}

export async function fetchItemContent(id: number): Promise<Story> {
const story = await fetchJson<Story>(`${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<PollResult> {
return fetchJson<PollResult>(`${baseUrl}/item/${id}`);
}

export function fetchUser(id: string): Promise<User> {
return fetchJson<User>(`${baseUrl}/user/${id}`);
}
10 changes: 10 additions & 0 deletions src-react/shared/models/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface Comment {
id: number;
level: number;
user: string;
time: number;
time_ago: string;
content: string;
deleted: boolean;
comments: Comment[];
}
3 changes: 3 additions & 0 deletions src-react/shared/models/feedType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type FeedType = 'poll' | 'story' | 'job';

export type FeedName = 'news' | 'newest' | 'show' | 'ask' | 'jobs';
6 changes: 6 additions & 0 deletions src-react/shared/models/index.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 4 additions & 0 deletions src-react/shared/models/pollResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface PollResult {
points: number;
content: string;
}
7 changes: 7 additions & 0 deletions src-react/shared/models/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface Settings {
showSettings: boolean;
openLinkInNewTab: boolean;
theme: string;
titleFontSize: string;
listSpacing: string;
}
23 changes: 23 additions & 0 deletions src-react/shared/models/story.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src-react/shared/models/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface User {
id: string;
crated_time: number;
created: string;
karma: number;
avg: number;
about: string;
}
123 changes: 123 additions & 0 deletions src-react/shared/settings/SettingsContext.tsx
Original file line number Diff line number Diff line change
@@ -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<SettingsContextValue | undefined>(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',
};
}
Comment on lines +17 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Saved theme applied only after first paint

getInitialSettings hardcodes theme: 'default' and reads the persisted theme only inside a later useEffect (SettingsContext.tsx). The Angular service applied it in the constructor before render, so users with a saved night theme can see a brief default-theme flash.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate. getInitialSettings deliberately doesn't read theme synchronously because the effect that applies it also subscribes to prefers-color-scheme, and the saved value has to win over the media query — doing it in one place avoided duplicating that precedence logic. The flash is real though; the fix is to seed theme from localStorage in the initializer and let the effect only handle the media-query default when nothing is saved. Flagging to the requester rather than folding a behavior tweak into this PR unprompted.


export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(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 <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}

export function useSettings(): SettingsContextValue {
const context = useContext(SettingsContext);

if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}

return context;
}
7 changes: 7 additions & 0 deletions src-react/shared/utils/formatComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function formatComments(count: number): string {
if (count > 0) {
return `${count} ${count === 1 ? 'comment' : 'comments'}`;
}

return 'discuss';
}