diff --git a/src/react/README.md b/src/react/README.md
new file mode 100644
index 00000000..3b12b5b2
--- /dev/null
+++ b/src/react/README.md
@@ -0,0 +1,141 @@
+# React Components for Angular2-HN Migration
+
+This directory contains React components migrated from the Angular 9 Hacker News application as part of Phase 1 and Phase 2 of the migration plan.
+
+## Prerequisites
+
+To use these components, you'll need to install the following dependencies:
+
+```bash
+npm install react react-dom react-router-dom
+npm install -D @types/react @types/react-dom
+```
+
+## Directory Structure
+
+```
+src/react/
+├── contexts/
+│ └── SettingsContext.tsx # React Context for settings state management
+├── components/
+│ ├── shared/
+│ │ ├── Loader/ # Loading indicator component
+│ │ └── ErrorMessage/ # Error message display component
+│ ├── feeds/
+│ │ └── Item/ # Story card component
+│ └── core/
+│ ├── Footer/ # Footer component
+│ ├── Header/ # Navigation header component
+│ └── Settings/ # Settings panel component
+├── types/
+│ └── index.ts # TypeScript interfaces
+├── utils/
+│ └── formatComment.ts # Comment formatting utility
+├── index.ts # Main exports
+├── tsconfig.json # TypeScript configuration for React
+└── README.md # This file
+```
+
+## Components
+
+### Phase 1: Shared/Utility Components
+
+#### Loader
+A pure presentational component that displays a loading animation.
+
+```tsx
+import { Loader } from './react';
+
+
+```
+
+#### ErrorMessage
+A presentational component that displays error messages with a skull icon.
+
+```tsx
+import { ErrorMessage } from './react';
+
+
+```
+
+#### Item
+A story card component that displays individual Hacker News items.
+
+```tsx
+import { Item } from './react';
+import { Story } from './react/types';
+
+const story: Story = { /* ... */ };
+
+```
+
+### Phase 2: Core Layout Components
+
+#### Footer
+A static footer component with a GitHub link.
+
+```tsx
+import { Footer } from './react';
+
+
+```
+
+#### Header
+A navigation header component with links and settings toggle.
+
+```tsx
+import { Header } from './react';
+
+
+```
+
+#### Settings
+A settings panel component for configuring app preferences.
+
+```tsx
+import { Settings } from './react';
+
+
+```
+
+## Context Provider
+
+The `SettingsProvider` must wrap your application to provide settings state to all components:
+
+```tsx
+import { SettingsProvider } from './react';
+import { BrowserRouter } from 'react-router-dom';
+
+function App() {
+ return (
+
+
+ {/* Your app components */}
+
+
+ );
+}
+```
+
+## Settings Context
+
+The settings context provides the following state and methods:
+
+- `settings`: Current settings object
+ - `showSettings`: boolean - Whether settings panel is visible
+ - `openLinkInNewTab`: boolean - Whether to open links in new tab
+ - `theme`: 'default' | 'night' | 'amoledblack' - Current theme
+ - `titleFontSize`: string - Font size for titles
+ - `listSpacing`: string - Spacing between list items
+
+- `toggleSettings()`: Toggle settings panel visibility
+- `toggleOpenLinksInNewTab()`: Toggle link behavior
+- `setTheme(theme)`: Set the current theme
+- `setFont(fontSize)`: Set the title font size
+- `setSpacing(listSpacing)`: Set the list spacing
+
+## Migration Notes
+
+These components are designed to run alongside the existing Angular components during the incremental migration process. The CSS styles have been converted from SCSS to plain CSS to work independently.
+
+The `SettingsService` from Angular has been converted to a React Context (`SettingsContext`) that provides the same functionality using React hooks.
diff --git a/src/react/components/core/Footer/Footer.css b/src/react/components/core/Footer/Footer.css
new file mode 100644
index 00000000..2edb5b8b
--- /dev/null
+++ b/src/react/components/core/Footer/Footer.css
@@ -0,0 +1,22 @@
+#footer {
+ position: relative;
+ padding: 10px;
+ height: 60px;
+ letter-spacing: 0.7px;
+ text-align: center;
+}
+
+#footer a {
+ font-weight: bold;
+ text-decoration: none;
+}
+
+#footer a:hover {
+ text-decoration: underline;
+}
+
+@media only screen and (max-width: 768px) {
+ #footer {
+ display: none;
+ }
+}
diff --git a/src/react/components/core/Footer/Footer.tsx b/src/react/components/core/Footer/Footer.tsx
new file mode 100644
index 00000000..88a5e808
--- /dev/null
+++ b/src/react/components/core/Footer/Footer.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import './Footer.css';
+
+export const Footer: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/react/components/core/Footer/index.ts b/src/react/components/core/Footer/index.ts
new file mode 100644
index 00000000..c211147c
--- /dev/null
+++ b/src/react/components/core/Footer/index.ts
@@ -0,0 +1 @@
+export { Footer, default } from './Footer';
diff --git a/src/react/components/core/Header/Header.css b/src/react/components/core/Header/Header.css
new file mode 100644
index 00000000..01f6fd53
--- /dev/null
+++ b/src/react/components/core/Header/Header.css
@@ -0,0 +1,164 @@
+#header {
+ color: #fff;
+ padding: 6px 0;
+ line-height: 18px;
+ vertical-align: middle;
+ position: relative;
+ z-index: 1;
+ width: 100%;
+}
+
+@media only screen and (max-width: 768px) {
+ #header {
+ height: 50px;
+ position: fixed;
+ top: 0;
+ }
+}
+
+#header a {
+ display: inline;
+}
+
+.home-link {
+ width: 50px;
+ height: 66px;
+}
+
+.logo-inner {
+ width: 32px;
+ position: absolute;
+ left: 17px;
+ top: 18px;
+ z-index: -1;
+ height: 32px;
+ border-radius: 50%;
+}
+
+@media only screen and (max-width: 768px) {
+ .logo-inner {
+ left: 16px;
+ top: 12px;
+ }
+}
+
+.logo {
+ width: 50px;
+ padding: 3px 8px 0;
+}
+
+@media only screen and (max-width: 768px) {
+ .logo {
+ width: 45px;
+ padding: 0 0 0 10px;
+ }
+}
+
+h1 {
+ font-weight: normal;
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0;
+ font-size: 16px;
+}
+
+h1 a {
+ color: #fff;
+ text-decoration: none;
+}
+
+.name {
+ margin-right: 30px;
+ margin-bottom: 2px;
+}
+
+@media only screen and (max-width: 768px) {
+ .name {
+ display: none;
+ }
+}
+
+.header-text {
+ position: absolute;
+ width: inherit;
+ height: 20px;
+ left: 10px;
+ top: 27px;
+ z-index: -1;
+}
+
+@media only screen and (max-width: 768px) {
+ .header-text {
+ top: 22px;
+ }
+}
+
+.left {
+ position: absolute;
+ left: 60px;
+ font-size: 16px;
+}
+
+@media only screen and (max-width: 768px) {
+ .left {
+ width: 100%;
+ left: 0;
+ }
+}
+
+.header-nav {
+ display: inline-block;
+ margin-left: 20px;
+}
+
+@media only screen and (max-width: 768px) {
+ .header-nav {
+ margin-left: 60px;
+ }
+}
+
+.header-nav a {
+ color: hsla(0, 0%, 100%, 0.9);
+ text-decoration: none;
+ margin: 0 5px;
+ letter-spacing: 1.8px;
+}
+
+.header-nav a:hover {
+ color: #fff;
+}
+
+.header-nav a.active {
+ color: #fff;
+}
+
+.info {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ height: 100%;
+}
+
+@media only screen and (max-width: 768px) {
+ .info {
+ right: 10px;
+ }
+}
+
+.info img {
+ opacity: 0.8;
+ width: 25px;
+ margin-top: 21.5px;
+ display: block;
+}
+
+.info img:hover {
+ opacity: 1;
+ cursor: pointer;
+}
+
+@media only screen and (max-width: 768px) {
+ .info img {
+ margin-top: 15px;
+ }
+}
diff --git a/src/react/components/core/Header/Header.tsx b/src/react/components/core/Header/Header.tsx
new file mode 100644
index 00000000..9bb6226f
--- /dev/null
+++ b/src/react/components/core/Header/Header.tsx
@@ -0,0 +1,64 @@
+import React, { useCallback } from 'react';
+import { NavLink } from 'react-router-dom';
+import { useSettings } from '../../../contexts/SettingsContext';
+import { Settings } from '../Settings';
+import './Header.css';
+
+export const Header: React.FC = () => {
+ const { settings, toggleSettings } = useSettings();
+
+ const scrollTop = useCallback(() => {
+ window.scrollTo(0, 0);
+ }, []);
+
+ const handleNavClick = useCallback(() => {
+ scrollTop();
+ }, [scrollTop]);
+
+ const handleSettingsClick = useCallback(() => {
+ toggleSettings();
+ }, [toggleSettings]);
+
+ return (
+
+
+ {settings.showSettings && }
+
+ );
+};
+
+export default Header;
diff --git a/src/react/components/core/Header/index.ts b/src/react/components/core/Header/index.ts
new file mode 100644
index 00000000..2a261d50
--- /dev/null
+++ b/src/react/components/core/Header/index.ts
@@ -0,0 +1 @@
+export { Header, default } from './Header';
diff --git a/src/react/components/core/Settings/Settings.css b/src/react/components/core/Settings/Settings.css
new file mode 100644
index 00000000..031c2520
--- /dev/null
+++ b/src/react/components/core/Settings/Settings.css
@@ -0,0 +1,79 @@
+.overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ opacity: 1;
+ z-index: 1;
+}
+
+.popup {
+ margin: 70px auto;
+ padding: 30px;
+ border-radius: 5px;
+ width: 30%;
+ position: relative;
+}
+
+.popup h1 {
+ margin-top: 0;
+ margin-bottom: 0px;
+ color: #fff;
+ text-align: center;
+ letter-spacing: 1px;
+}
+
+.popup h2 {
+ padding-top: 10px;
+}
+
+.popup hr {
+ width: 40%;
+ margin-bottom: 20px;
+}
+
+.popup .close {
+ position: absolute;
+ top: 12px;
+ right: 20px;
+ font-size: 30px;
+ font-weight: bold;
+ text-decoration: none;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.popup .close:hover {
+ color: #fff;
+ cursor: pointer;
+}
+
+.popup .content {
+ max-height: 30%;
+ color: #fff;
+ letter-spacing: 1px;
+ overflow: auto;
+}
+
+.popup input[type='number'] {
+ display: block;
+ width: 80%;
+ height: 20px;
+ margin-bottom: 15px;
+ border-radius: 5px;
+ padding: 2px;
+}
+
+.control-section {
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid white;
+}
+
+@media screen and (max-width: 700px) {
+ .box,
+ .popup {
+ width: 70%;
+ }
+}
diff --git a/src/react/components/core/Settings/Settings.tsx b/src/react/components/core/Settings/Settings.tsx
new file mode 100644
index 00000000..70af0e1c
--- /dev/null
+++ b/src/react/components/core/Settings/Settings.tsx
@@ -0,0 +1,133 @@
+import React, { useCallback, ChangeEvent } from 'react';
+import { useSettings } from '../../../contexts/SettingsContext';
+import { Settings as SettingsType } from '../../../types';
+import './Settings.css';
+
+export const Settings: React.FC = () => {
+ const { settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing } =
+ useSettings();
+
+ const handleClose = useCallback(() => {
+ toggleSettings();
+ }, [toggleSettings]);
+
+ const handleOpenLinksChange = useCallback(() => {
+ toggleOpenLinksInNewTab();
+ }, [toggleOpenLinksInNewTab]);
+
+ const handleThemeChange = useCallback(
+ (theme: SettingsType['theme']) => {
+ setTheme(theme);
+ },
+ [setTheme]
+ );
+
+ const handleFontChange = useCallback(
+ (e: ChangeEvent) => {
+ setFont(e.target.value);
+ },
+ [setFont]
+ );
+
+ const handleSpacingChange = useCallback(
+ (e: ChangeEvent) => {
+ setSpacing(e.target.value);
+ },
+ [setSpacing]
+ );
+
+ return (
+
+ );
+};
+
+export default Settings;
diff --git a/src/react/components/core/Settings/index.ts b/src/react/components/core/Settings/index.ts
new file mode 100644
index 00000000..fd5a3086
--- /dev/null
+++ b/src/react/components/core/Settings/index.ts
@@ -0,0 +1 @@
+export { Settings, default } from './Settings';
diff --git a/src/react/components/feeds/Item/Item.css b/src/react/components/feeds/Item/Item.css
new file mode 100644
index 00000000..7e311330
--- /dev/null
+++ b/src/react/components/feeds/Item/Item.css
@@ -0,0 +1,69 @@
+.item-container p {
+ margin: 2px 0;
+}
+
+@media only screen and (max-width: 768px) {
+ .item-container p {
+ margin-bottom: 5px;
+ margin-top: 0;
+ }
+}
+
+.item-container a {
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.title {
+ font-size: 16px;
+ font-family: Verdana, Geneva, sans-serif;
+}
+
+.subtext-laptop {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+}
+
+.subtext-laptop a:hover {
+ text-decoration: underline;
+}
+
+@media only screen and (max-width: 768px) {
+ .subtext-laptop {
+ display: none;
+ }
+}
+
+.subtext-palm {
+ font-size: 13px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+}
+
+.subtext-palm a:hover {
+ text-decoration: underline;
+}
+
+.subtext-palm .details {
+ margin-top: 5px;
+}
+
+.subtext-palm .details .right {
+ float: right;
+}
+
+@media only screen and (min-width: 769px) {
+ .subtext-palm {
+ display: none;
+ }
+}
+
+.domain {
+ color: #696969;
+ letter-spacing: 0.5px;
+}
+
+.item-details {
+ padding: 10px;
+}
diff --git a/src/react/components/feeds/Item/Item.tsx b/src/react/components/feeds/Item/Item.tsx
new file mode 100644
index 00000000..c08163a3
--- /dev/null
+++ b/src/react/components/feeds/Item/Item.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Story } from '../../../types';
+import { useSettings } from '../../../contexts/SettingsContext';
+import { formatComment } from '../../../utils/formatComment';
+import './Item.css';
+
+interface ItemProps {
+ item: Story;
+}
+
+export const Item: React.FC = ({ item }) => {
+ const { settings } = useSettings();
+
+ const hasUrl = item.url && item.url.indexOf('http') === 0;
+
+ return (
+
+ {hasUrl ? (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain})}
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+
+ {item.type !== 'job' && (
+
+
+ {item.user}
+
+ {item.points} ★
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' '}
+ • {formatComment(item.comments_count)}
+
+ )}
+
+
+
+
+ {item.type !== 'job' && (
+
+ {item.points} points by {item.user}
+
+ )}
+
+ {item.time_ago}
+ {item.type !== 'job' && (
+
+ {' '}
+ | {formatComment(item.comments_count)}
+
+ )}
+
+
+
+ );
+};
+
+export default Item;
diff --git a/src/react/components/feeds/Item/index.ts b/src/react/components/feeds/Item/index.ts
new file mode 100644
index 00000000..f1a13774
--- /dev/null
+++ b/src/react/components/feeds/Item/index.ts
@@ -0,0 +1 @@
+export { Item, default } from './Item';
diff --git a/src/react/components/shared/ErrorMessage/ErrorMessage.css b/src/react/components/shared/ErrorMessage/ErrorMessage.css
new file mode 100644
index 00000000..b8a15b06
--- /dev/null
+++ b/src/react/components/shared/ErrorMessage/ErrorMessage.css
@@ -0,0 +1,125 @@
+.error-section {
+ height: 300px;
+ margin: 200px;
+}
+
+@media only screen and (max-width: 768px) {
+ .error-section {
+ height: 0;
+ display: block;
+ position: relative;
+ margin: 30vh 0;
+ }
+}
+
+.error-section p {
+ text-align: center;
+ padding: 0 25px;
+}
+
+.error-section p.strong {
+ margin-top: 25px;
+ font-weight: bold;
+}
+
+.skull {
+ width: 200px;
+ height: 200px;
+ position: relative;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+}
+
+.skull .head {
+ width: 100%;
+ height: 75%;
+ border-radius: 15% / 20%;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.skull .head::before,
+.skull .head::after {
+ content: '';
+ position: absolute;
+ border-radius: 50%;
+ width: 20%;
+ height: 30%;
+ bottom: 10%;
+}
+
+.skull .head::before {
+ left: 10%;
+}
+
+.skull .head::after {
+ right: 10%;
+}
+
+.skull .head .crack {
+ width: 10%;
+ height: 10%;
+ position: absolute;
+ top: 0;
+ right: 25%;
+ transform: skew(-15deg);
+}
+
+.skull .head .crack::before {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 13.33px;
+ border-right: 10px solid transparent;
+ border-left: 5px solid transparent;
+}
+
+.skull .mouth {
+ width: 40%;
+ height: 25%;
+ position: absolute;
+ top: 75%;
+ left: 30%;
+ border-radius: 0 0 20px 20px;
+}
+
+.skull .mouth::before {
+ content: '';
+ position: absolute;
+ width: 15%;
+ height: 50%;
+ border-radius: 50% / 30%;
+ left: 42.5%;
+ top: -25%;
+}
+
+.skull .teeth {
+ position: absolute;
+ bottom: 0;
+ left: 45%;
+ width: 10%;
+ height: 50%;
+ margin-bottom: -5%;
+ border-radius: 50% / 20%;
+}
+
+.skull .teeth::before,
+.skull .teeth::after {
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ border-radius: 50% / 20%;
+}
+
+.skull .teeth::before {
+ left: -250%;
+}
+
+.skull .teeth::after {
+ right: -250%;
+}
diff --git a/src/react/components/shared/ErrorMessage/ErrorMessage.tsx b/src/react/components/shared/ErrorMessage/ErrorMessage.tsx
new file mode 100644
index 00000000..f7f81696
--- /dev/null
+++ b/src/react/components/shared/ErrorMessage/ErrorMessage.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import './ErrorMessage.css';
+
+interface ErrorMessageProps {
+ message: string;
+}
+
+export const ErrorMessage: React.FC = ({ message }) => {
+ return (
+
+
+
{message}
+
+ If you are offline viewing, you'll need to visit this page with a network connection first
+ before it can work offline.
+
+
+ );
+};
+
+export default ErrorMessage;
diff --git a/src/react/components/shared/ErrorMessage/index.ts b/src/react/components/shared/ErrorMessage/index.ts
new file mode 100644
index 00000000..3f923d79
--- /dev/null
+++ b/src/react/components/shared/ErrorMessage/index.ts
@@ -0,0 +1 @@
+export { ErrorMessage, default } from './ErrorMessage';
diff --git a/src/react/components/shared/Loader/Loader.css b/src/react/components/shared/Loader/Loader.css
new file mode 100644
index 00000000..d8e99a58
--- /dev/null
+++ b/src/react/components/shared/Loader/Loader.css
@@ -0,0 +1,109 @@
+.loading-section {
+ height: 70px;
+ margin: 40px 0 40px 40px;
+}
+
+@media only screen and (max-width: 768px) {
+ .loading-section {
+ display: block;
+ position: relative;
+ margin: 45vh 0;
+ }
+}
+
+.loader {
+ -webkit-animation: load1 1s infinite ease-in-out;
+ animation: load1 1s infinite ease-in-out;
+ width: 1em;
+ height: 4em;
+ text-indent: -9999em;
+ margin: 20px 20px;
+ position: relative;
+ font-size: 11px;
+ -webkit-transform: translateZ(0);
+ -ms-transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-animation-delay: -0.16s;
+ animation-delay: -0.16s;
+}
+
+.loader::before,
+.loader::after {
+ -webkit-animation: load1 1s infinite ease-in-out;
+ animation: load1 1s infinite ease-in-out;
+ width: 1em;
+ height: 4em;
+ position: absolute;
+ top: 0;
+ content: '';
+}
+
+.loader::before {
+ left: -1.5em;
+ -webkit-animation-delay: -0.32s;
+ animation-delay: -0.32s;
+}
+
+.loader::after {
+ left: 1.5em;
+}
+
+@media only screen and (max-width: 768px) {
+ .loader {
+ margin: 20px auto;
+ }
+}
+
+@-webkit-keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 2em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 3em;
+ }
+}
+
+@keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 2em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 3em;
+ }
+}
+
+@media only screen and (max-width: 768px) {
+ @-webkit-keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 4em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 5em;
+ }
+ }
+
+ @keyframes load1 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 0;
+ height: 3em;
+ }
+ 40% {
+ box-shadow: 0 -2em;
+ height: 4em;
+ }
+ }
+}
diff --git a/src/react/components/shared/Loader/Loader.tsx b/src/react/components/shared/Loader/Loader.tsx
new file mode 100644
index 00000000..183d6739
--- /dev/null
+++ b/src/react/components/shared/Loader/Loader.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import './Loader.css';
+
+export const Loader: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Loader;
diff --git a/src/react/components/shared/Loader/index.ts b/src/react/components/shared/Loader/index.ts
new file mode 100644
index 00000000..9524e43c
--- /dev/null
+++ b/src/react/components/shared/Loader/index.ts
@@ -0,0 +1 @@
+export { Loader, default } from './Loader';
diff --git a/src/react/contexts/SettingsContext.tsx b/src/react/contexts/SettingsContext.tsx
new file mode 100644
index 00000000..51394422
--- /dev/null
+++ b/src/react/contexts/SettingsContext.tsx
@@ -0,0 +1,126 @@
+import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
+import { Settings } from '../types';
+
+interface SettingsContextType {
+ settings: Settings;
+ toggleSettings: () => void;
+ toggleOpenLinksInNewTab: () => void;
+ setTheme: (theme: Settings['theme']) => void;
+ setFont: (fontSize: string) => void;
+ setSpacing: (listSpacing: string) => void;
+}
+
+const defaultSettings: Settings = {
+ showSettings: false,
+ openLinkInNewTab: false,
+ theme: 'default',
+ titleFontSize: '16',
+ listSpacing: '0',
+};
+
+const SettingsContext = createContext(undefined);
+
+const getInitialSettings = (): Settings => {
+ if (typeof window === 'undefined') {
+ return defaultSettings;
+ }
+
+ 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) : false,
+ theme: theme || 'default',
+ titleFontSize: titleFontSize || '16',
+ listSpacing: listSpacing || '0',
+ };
+};
+
+interface SettingsProviderProps {
+ children: ReactNode;
+}
+
+export const SettingsProvider: React.FC = ({ children }) => {
+ const [settings, setSettings] = useState(getInitialSettings);
+
+ useEffect(() => {
+ const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const handleSystemPreferredColorSchemeChange = (event: MediaQueryListEvent) => {
+ const savedTheme = localStorage.getItem('theme');
+ if (!savedTheme) {
+ const theme = event.matches ? 'night' : 'default';
+ setSettings((prev) => ({ ...prev, theme }));
+ }
+ };
+
+ const savedTheme = localStorage.getItem('theme');
+ if (!savedTheme) {
+ const theme = darkColorSchemeMedia.matches ? 'night' : 'default';
+ setSettings((prev) => ({ ...prev, theme }));
+ }
+
+ darkColorSchemeMedia.addEventListener('change', handleSystemPreferredColorSchemeChange);
+
+ return () => {
+ darkColorSchemeMedia.removeEventListener('change', handleSystemPreferredColorSchemeChange);
+ };
+ }, []);
+
+ const toggleSettings = useCallback(() => {
+ setSettings((prev) => ({ ...prev, showSettings: !prev.showSettings }));
+ }, []);
+
+ const toggleOpenLinksInNewTab = useCallback(() => {
+ setSettings((prev) => {
+ const newValue = !prev.openLinkInNewTab;
+ localStorage.setItem('openLinkInNewTab', JSON.stringify(newValue));
+ return { ...prev, openLinkInNewTab: newValue };
+ });
+ }, []);
+
+ const setTheme = useCallback((theme: Settings['theme']) => {
+ setSettings((prev) => {
+ localStorage.setItem('theme', theme);
+ return { ...prev, theme };
+ });
+ }, []);
+
+ const setFont = useCallback((fontSize: string) => {
+ setSettings((prev) => {
+ localStorage.setItem('titleFontSize', fontSize);
+ return { ...prev, titleFontSize: fontSize };
+ });
+ }, []);
+
+ const setSpacing = useCallback((listSpacing: string) => {
+ setSettings((prev) => {
+ localStorage.setItem('listSpacing', listSpacing);
+ return { ...prev, listSpacing };
+ });
+ }, []);
+
+ const value: SettingsContextType = {
+ settings,
+ toggleSettings,
+ toggleOpenLinksInNewTab,
+ setTheme,
+ setFont,
+ setSpacing,
+ };
+
+ return {children};
+};
+
+export const useSettings = (): SettingsContextType => {
+ const context = useContext(SettingsContext);
+ if (context === undefined) {
+ throw new Error('useSettings must be used within a SettingsProvider');
+ }
+ return context;
+};
+
+export default SettingsContext;
diff --git a/src/react/index.ts b/src/react/index.ts
new file mode 100644
index 00000000..044af3b0
--- /dev/null
+++ b/src/react/index.ts
@@ -0,0 +1,16 @@
+// Context exports
+export { SettingsProvider, useSettings } from './contexts/SettingsContext';
+
+// Type exports
+export type { Settings, Story, Comment, FeedType, PollResult } from './types';
+
+// Component exports
+export { Loader } from './components/shared/Loader';
+export { ErrorMessage } from './components/shared/ErrorMessage';
+export { Item } from './components/feeds/Item';
+export { Footer } from './components/core/Footer';
+export { Header } from './components/core/Header';
+export { Settings } from './components/core/Settings';
+
+// Utility exports
+export { formatComment } from './utils/formatComment';
diff --git a/src/react/tsconfig.json b/src/react/tsconfig.json
new file mode 100644
index 00000000..dce901be
--- /dev/null
+++ b/src/react/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
+ "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
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/src/react/types/index.ts b/src/react/types/index.ts
new file mode 100644
index 00000000..cfc8ceb8
--- /dev/null
+++ b/src/react/types/index.ts
@@ -0,0 +1,44 @@
+export interface Comment {
+ id: number;
+ user: string;
+ time: number;
+ time_ago: string;
+ content: string;
+ comments: Comment[];
+ level: number;
+ deleted?: boolean;
+ dead?: boolean;
+}
+
+export type FeedType = 'news' | 'newest' | 'show' | 'ask' | 'jobs' | 'job' | 'link' | 'comment' | 'poll' | 'pollopt';
+
+export interface PollResult {
+ item: string;
+ points: number;
+}
+
+export interface Story {
+ id: number;
+ title: string;
+ points: number;
+ user: string;
+ time: number;
+ time_ago: string;
+ type: FeedType;
+ url: string;
+ domain: string;
+ comments: Comment[];
+ comments_count: number;
+ poll: PollResult[];
+ poll_votes_count: number;
+ deleted: boolean;
+ dead: boolean;
+}
+
+export interface Settings {
+ showSettings: boolean;
+ openLinkInNewTab: boolean;
+ theme: 'default' | 'night' | 'amoledblack';
+ titleFontSize: string;
+ listSpacing: string;
+}
diff --git a/src/react/utils/formatComment.ts b/src/react/utils/formatComment.ts
new file mode 100644
index 00000000..f59fd50c
--- /dev/null
+++ b/src/react/utils/formatComment.ts
@@ -0,0 +1,7 @@
+export const formatComment = (commentCount: number): string => {
+ if (commentCount > 0) {
+ const suffix = commentCount === 1 ? 'comment' : 'comments';
+ return `${commentCount} ${suffix}`;
+ }
+ return 'discuss';
+};