From b7ff420d4b8b9b686cd0f8ee9f31fdc51011ec85 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:19:54 +0000 Subject: [PATCH] feat: implement full React migration - all components, routing, styles, and PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete Angular → React migration: - TypeScript interfaces for all models (Story, Comment, User, PollResult, Settings) - API service using native fetch (replaces Angular HttpClient + RxJS) - Settings context with React Context + localStorage persistence - All SCSS styles ported with theming (default, night, AMOLED black) - Components: Header, Footer, SettingsPanel, Feed, FeedItem, Comment, ItemDetails, UserProfile, Loader, ErrorMessage - React Router v6 with all feed routes, item details, user profiles - PWA configuration via vite-plugin-pwa with workbox runtime caching - Utility functions (formatComment pipe replacement) Co-Authored-By: Devanshi Gupta --- react-hn/src/App.tsx | 44 ++++ react-hn/src/api/.gitkeep | 0 react-hn/src/api/hn-api.ts | 42 ++++ react-hn/src/components/feeds/.gitkeep | 0 react-hn/src/components/feeds/Feed.tsx | 84 +++++++ react-hn/src/components/feeds/FeedItem.tsx | 78 ++++++ react-hn/src/components/item-details/.gitkeep | 0 .../src/components/item-details/Comment.tsx | 48 ++++ .../components/item-details/ItemDetails.tsx | 145 +++++++++++ react-hn/src/components/layout/.gitkeep | 0 react-hn/src/components/layout/Footer.tsx | 14 ++ react-hn/src/components/layout/Header.tsx | 43 ++++ .../src/components/layout/SettingsPanel.tsx | 76 ++++++ react-hn/src/components/shared/.gitkeep | 0 .../src/components/shared/ErrorMessage.tsx | 22 ++ react-hn/src/components/shared/Loader.tsx | 9 + react-hn/src/components/user/.gitkeep | 0 react-hn/src/components/user/UserProfile.tsx | 67 +++++ react-hn/src/context/.gitkeep | 0 react-hn/src/context/SettingsContext.tsx | 84 +++++++ react-hn/src/context/settingsContextDef.ts | 13 + react-hn/src/context/useSettings.ts | 11 + react-hn/src/main.tsx | 3 +- react-hn/src/models/.gitkeep | 0 react-hn/src/models/types.ts | 56 +++++ react-hn/src/styles/.gitkeep | 0 react-hn/src/styles/App.scss | 24 ++ react-hn/src/styles/Comment.scss | 85 +++++++ react-hn/src/styles/ErrorMessage.scss | 115 +++++++++ react-hn/src/styles/Feed.scss | 107 ++++++++ react-hn/src/styles/FeedItem.scss | 70 ++++++ react-hn/src/styles/Footer.scss | 23 ++ react-hn/src/styles/Header.scss | 149 +++++++++++ react-hn/src/styles/ItemDetails.scss | 153 ++++++++++++ react-hn/src/styles/Loader.scss | 109 ++++++++ react-hn/src/styles/Settings.scss | 74 ++++++ react-hn/src/styles/UserProfile.scss | 91 +++++++ react-hn/src/styles/_media.scss | 3 + react-hn/src/styles/_theme_variables.scss | 29 +++ react-hn/src/styles/_themes.scss | 233 ++++++++++++++++++ react-hn/src/styles/global.scss | 35 +++ react-hn/src/utils/.gitkeep | 0 react-hn/src/utils/formatComment.ts | 6 + react-hn/vite.config.ts | 55 ++++- 44 files changed, 2197 insertions(+), 3 deletions(-) create mode 100644 react-hn/src/App.tsx delete mode 100644 react-hn/src/api/.gitkeep create mode 100644 react-hn/src/api/hn-api.ts delete mode 100644 react-hn/src/components/feeds/.gitkeep create mode 100644 react-hn/src/components/feeds/Feed.tsx create mode 100644 react-hn/src/components/feeds/FeedItem.tsx delete mode 100644 react-hn/src/components/item-details/.gitkeep create mode 100644 react-hn/src/components/item-details/Comment.tsx create mode 100644 react-hn/src/components/item-details/ItemDetails.tsx delete mode 100644 react-hn/src/components/layout/.gitkeep create mode 100644 react-hn/src/components/layout/Footer.tsx create mode 100644 react-hn/src/components/layout/Header.tsx create mode 100644 react-hn/src/components/layout/SettingsPanel.tsx delete mode 100644 react-hn/src/components/shared/.gitkeep create mode 100644 react-hn/src/components/shared/ErrorMessage.tsx create mode 100644 react-hn/src/components/shared/Loader.tsx delete mode 100644 react-hn/src/components/user/.gitkeep create mode 100644 react-hn/src/components/user/UserProfile.tsx delete mode 100644 react-hn/src/context/.gitkeep create mode 100644 react-hn/src/context/SettingsContext.tsx create mode 100644 react-hn/src/context/settingsContextDef.ts create mode 100644 react-hn/src/context/useSettings.ts delete mode 100644 react-hn/src/models/.gitkeep create mode 100644 react-hn/src/models/types.ts delete mode 100644 react-hn/src/styles/.gitkeep create mode 100644 react-hn/src/styles/App.scss create mode 100644 react-hn/src/styles/Comment.scss create mode 100644 react-hn/src/styles/ErrorMessage.scss create mode 100644 react-hn/src/styles/Feed.scss create mode 100644 react-hn/src/styles/FeedItem.scss create mode 100644 react-hn/src/styles/Footer.scss create mode 100644 react-hn/src/styles/Header.scss create mode 100644 react-hn/src/styles/ItemDetails.scss create mode 100644 react-hn/src/styles/Loader.scss create mode 100644 react-hn/src/styles/Settings.scss create mode 100644 react-hn/src/styles/UserProfile.scss create mode 100644 react-hn/src/styles/_media.scss create mode 100644 react-hn/src/styles/_theme_variables.scss create mode 100644 react-hn/src/styles/_themes.scss create mode 100644 react-hn/src/styles/global.scss delete mode 100644 react-hn/src/utils/.gitkeep create mode 100644 react-hn/src/utils/formatComment.ts diff --git a/react-hn/src/App.tsx b/react-hn/src/App.tsx new file mode 100644 index 000000000..933ce015c --- /dev/null +++ b/react-hn/src/App.tsx @@ -0,0 +1,44 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { SettingsProvider } from './context/SettingsContext'; +import { useSettings } from './context/useSettings'; +import { Header } from './components/layout/Header'; +import { Footer } from './components/layout/Footer'; +import { Feed } from './components/feeds/Feed'; +import { ItemDetails } from './components/item-details/ItemDetails'; +import { UserProfile } from './components/user/UserProfile'; +import './styles/global.scss'; +import './styles/App.scss'; + +function AppShell() { + const { settings } = useSettings(); + + return ( +
+
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} + +export function App() { + return ( + + + + + + ); +} diff --git a/react-hn/src/api/.gitkeep b/react-hn/src/api/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/api/hn-api.ts b/react-hn/src/api/hn-api.ts new file mode 100644 index 000000000..6fa89dae1 --- /dev/null +++ b/react-hn/src/api/hn-api.ts @@ -0,0 +1,42 @@ +import type { Story, User, PollResult } from '../models/types'; + +const BASE_URL = 'https://node-hnapi.herokuapp.com'; + +export async function fetchFeed(feedType: string, page: number): Promise { + const res = await fetch(`${BASE_URL}/${feedType}?page=${page}`); + if (!res.ok) throw new Error(`Failed to fetch ${feedType} feed`); + return res.json() as Promise; +} + +export async function fetchItemContent(id: number): Promise { + const res = await fetch(`${BASE_URL}/item/${id}`); + if (!res.ok) throw new Error(`Failed to fetch item ${id}`); + const story = (await res.json()) as Story; + + if (story.type === 'poll' && story.poll) { + const numberOfPollOptions = story.poll.length; + story.poll_votes_count = 0; + const pollPromises = Array.from({ length: numberOfPollOptions }, (_, i) => + fetchPollContent(story.id + i + 1) + ); + const pollResults = await Promise.all(pollPromises); + pollResults.forEach((result, i) => { + story.poll![i] = result; + story.poll_votes_count! += result.points; + }); + } + + return story; +} + +export async function fetchPollContent(id: number): Promise { + const res = await fetch(`${BASE_URL}/item/${id}`); + if (!res.ok) throw new Error(`Failed to fetch poll item ${id}`); + return res.json() as Promise; +} + +export async function fetchUser(id: string): Promise { + const res = await fetch(`${BASE_URL}/user/${id}`); + if (!res.ok) throw new Error(`Failed to fetch user ${id}`); + return res.json() as Promise; +} diff --git a/react-hn/src/components/feeds/.gitkeep b/react-hn/src/components/feeds/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/components/feeds/Feed.tsx b/react-hn/src/components/feeds/Feed.tsx new file mode 100644 index 000000000..65ec46684 --- /dev/null +++ b/react-hn/src/components/feeds/Feed.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState, useRef } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { fetchFeed } from '../../api/hn-api'; +import { Loader } from '../shared/Loader'; +import { ErrorMessage } from '../shared/ErrorMessage'; +import { FeedItem } from './FeedItem'; +import type { Story } from '../../models/types'; +import '../../styles/Feed.scss'; + +interface FeedProps { + feedType: string; +} + +export function Feed({ feedType }: FeedProps) { + const { page } = useParams<{ page: string }>(); + const pageNum = page ? parseInt(page, 10) : 1; + const [items, setItems] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const requestRef = useRef(0); + + useEffect(() => { + const requestId = ++requestRef.current; + let cancelled = false; + + fetchFeed(feedType, pageNum) + .then(data => { + if (!cancelled && requestId === requestRef.current) { + setItems(data); + setErrorMessage(''); + window.scrollTo(0, 0); + } + }) + .catch(() => { + if (!cancelled && requestId === requestRef.current) { + setItems(null); + setErrorMessage(`Could not load ${feedType} stories.`); + } + }); + + return () => { cancelled = true; }; + }, [feedType, pageNum]); + + const listStart = ((pageNum - 1) * 30) + 1; + + return ( +
+ {!items && !errorMessage && } + {!items && errorMessage !== '' && } + + {items && ( +
+ {feedType === 'jobs' && ( +

+ These are jobs at startups that were funded by Y Combinator. + You can also get a job at a YC startup through{' '} + Triplebyte. +

+ )} + {feedType !== 'new' && ( +
    + {items.map(item => ( +
  1. + +
  2. + ))} +
+ )} +
+ {listStart !== 1 && ( + + ‹ Prev + + )} + {items.length === 30 && ( + + More › + + )} +
+
+ )} +
+ ); +} diff --git a/react-hn/src/components/feeds/FeedItem.tsx b/react-hn/src/components/feeds/FeedItem.tsx new file mode 100644 index 000000000..2c9addb5f --- /dev/null +++ b/react-hn/src/components/feeds/FeedItem.tsx @@ -0,0 +1,78 @@ +import { Link } from 'react-router-dom'; +import { useSettings } from '../../context/useSettings'; +import { formatComment } from '../../utils/formatComment'; +import type { Story } from '../../models/types'; +import '../../styles/FeedItem.scss'; + +interface FeedItemProps { + item: Story; +} + +export function FeedItem({ item }: FeedItemProps) { + const { settings } = useSettings(); + const hasUrl = item.url ? item.url.indexOf('http') === 0 : false; + + 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)} + + )} + +
+
+ ); +} diff --git a/react-hn/src/components/item-details/.gitkeep b/react-hn/src/components/item-details/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/components/item-details/Comment.tsx b/react-hn/src/components/item-details/Comment.tsx new file mode 100644 index 000000000..8d596eeb0 --- /dev/null +++ b/react-hn/src/components/item-details/Comment.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { Comment as CommentType } from '../../models/types'; +import '../../styles/Comment.scss'; + +interface CommentProps { + comment: CommentType; +} + +export function Comment({ comment }: CommentProps) { + const [collapsed, setCollapsed] = useState(false); + + if (comment.deleted) { + return ( +
+
+ [deleted] | Comment Deleted +
+
+ ); + } + + return ( +
+
+ setCollapsed(!collapsed)}> + [{collapsed ? '+' : '-'}] + {' '} + {comment.user} + {comment.time_ago} +
+
+ {!collapsed && ( +
+

+

    + {comment.comments.map(subComment => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/react-hn/src/components/item-details/ItemDetails.tsx b/react-hn/src/components/item-details/ItemDetails.tsx new file mode 100644 index 000000000..1e0cd33e8 --- /dev/null +++ b/react-hn/src/components/item-details/ItemDetails.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState, useRef } from 'react'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { fetchItemContent } from '../../api/hn-api'; +import { useSettings } from '../../context/useSettings'; +import { formatComment } from '../../utils/formatComment'; +import { Loader } from '../shared/Loader'; +import { ErrorMessage } from '../shared/ErrorMessage'; +import { Comment } from './Comment'; +import type { Story } from '../../models/types'; +import '../../styles/ItemDetails.scss'; + +export function ItemDetails() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { settings } = useSettings(); + const [item, setItem] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const requestRef = useRef(0); + + useEffect(() => { + if (!id) return; + const requestId = ++requestRef.current; + let cancelled = false; + + window.scrollTo(0, 0); + fetchItemContent(parseInt(id, 10)) + .then(data => { + if (!cancelled && requestId === requestRef.current) { + setItem(data); + setErrorMessage(''); + } + }) + .catch(() => { + if (!cancelled && requestId === requestRef.current) { + setItem(null); + setErrorMessage('Could not load item comments.'); + } + }); + + return () => { cancelled = true; }; + }, [id]); + + const goBack = () => navigate(-1); + const hasUrl = item?.url ? item.url.indexOf('http') === 0 : false; + + return ( +
+
+ {!item && !errorMessage && } + {!item && errorMessage !== '' && } + + {item && ( +
+
+

+ + {hasUrl ? ( + + {item.title} + + ) : ( + + {item.title} + + )} +

+
+
0 || item.type === 'job' ? ' item-header' : ''}${item.content ? ' head-margin' : ''}`} + > + {hasUrl ? ( +

+ + {item.title} + + {item.domain && ({item.domain})} +

+ ) : ( +

+ + {item.title} + +

+ )} +
+ {item.type !== 'job' && ( + + {item.points} points by{' '} + {item.user} + + )} + + {item.time_ago} + {item.type !== 'job' && ( + + {' '}|{' '} + {formatComment(item.comments_count)} + + )} + +
+
+ + {item.type === 'poll' && item.poll && ( +
+ {item.poll.map((pollResult, i) => ( +
+
+
{pollResult.points} points
+
+
+ ))} +
+ )} + + {item.content && ( +

+ )} + +

    + {item.comments.map(comment => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/react-hn/src/components/layout/.gitkeep b/react-hn/src/components/layout/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/components/layout/Footer.tsx b/react-hn/src/components/layout/Footer.tsx new file mode 100644 index 000000000..918863234 --- /dev/null +++ b/react-hn/src/components/layout/Footer.tsx @@ -0,0 +1,14 @@ +import '../../styles/Footer.scss'; + +export function Footer() { + return ( + + ); +} diff --git a/react-hn/src/components/layout/Header.tsx b/react-hn/src/components/layout/Header.tsx new file mode 100644 index 000000000..5bcfc7a46 --- /dev/null +++ b/react-hn/src/components/layout/Header.tsx @@ -0,0 +1,43 @@ +import { NavLink, useNavigate } from 'react-router-dom'; +import { useSettings } from '../../context/useSettings'; +import { SettingsPanel } from './SettingsPanel'; +import '../../styles/Header.scss'; + +export function Header() { + const { settings, toggleSettings } = useSettings(); + const navigate = useNavigate(); + + const scrollTopAndNavigate = (path: string) => (e: React.MouseEvent) => { + e.preventDefault(); + window.scrollTo(0, 0); + navigate(path); + }; + + return ( +
+ + {settings.showSettings && } +
+ ); +} diff --git a/react-hn/src/components/layout/SettingsPanel.tsx b/react-hn/src/components/layout/SettingsPanel.tsx new file mode 100644 index 000000000..b068b2a4f --- /dev/null +++ b/react-hn/src/components/layout/SettingsPanel.tsx @@ -0,0 +1,76 @@ +import { useSettings } from '../../context/useSettings'; +import type { Theme } from '../../models/types'; +import '../../styles/Settings.scss'; + +export function SettingsPanel() { + const { settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing } = useSettings(); + + return ( +
+
+

Settings

+
+ × +
+
+

Links

+ +
+
+
+

Select a theme

+ {(['default', 'night', 'amoledblack'] as Theme[]).map(theme => ( +
+ +
+ ))} +
+
+

Change Font

+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/react-hn/src/components/shared/.gitkeep b/react-hn/src/components/shared/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/components/shared/ErrorMessage.tsx b/react-hn/src/components/shared/ErrorMessage.tsx new file mode 100644 index 000000000..23fde7665 --- /dev/null +++ b/react-hn/src/components/shared/ErrorMessage.tsx @@ -0,0 +1,22 @@ +import '../../styles/ErrorMessage.scss'; + +interface ErrorMessageProps { + message: string; +} + +export function ErrorMessage({ message }: ErrorMessageProps) { + return ( +
+
+
+
+
+
+
+
+
+

{message}

+

If you are offline viewing, you'll need to visit this page with a network connection first before it can work offline.

+
+ ); +} diff --git a/react-hn/src/components/shared/Loader.tsx b/react-hn/src/components/shared/Loader.tsx new file mode 100644 index 000000000..23e23fac7 --- /dev/null +++ b/react-hn/src/components/shared/Loader.tsx @@ -0,0 +1,9 @@ +import '../../styles/Loader.scss'; + +export function Loader() { + return ( +
+
Loading...
+
+ ); +} diff --git a/react-hn/src/components/user/.gitkeep b/react-hn/src/components/user/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/components/user/UserProfile.tsx b/react-hn/src/components/user/UserProfile.tsx new file mode 100644 index 000000000..2c61a5b3b --- /dev/null +++ b/react-hn/src/components/user/UserProfile.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { fetchUser } from '../../api/hn-api'; +import { Loader } from '../shared/Loader'; +import { ErrorMessage } from '../shared/ErrorMessage'; +import type { User } from '../../models/types'; +import '../../styles/UserProfile.scss'; + +export function UserProfile() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const requestRef = useRef(0); + + useEffect(() => { + if (!id) return; + const requestId = ++requestRef.current; + let cancelled = false; + + fetchUser(id) + .then(data => { + if (!cancelled && requestId === requestRef.current) { + setUser(data); + setErrorMessage(''); + } + }) + .catch(() => { + if (!cancelled && requestId === requestRef.current) { + setUser(null); + setErrorMessage(`Could not load user ${id}.`); + } + }); + + return () => { cancelled = true; }; + }, [id]); + + const goBack = () => navigate(-1); + + return ( +
+ {!user && !errorMessage && } + {!user && errorMessage !== '' && } + + {user && ( +
+
+

+ + Profile: {user.id} +

+
+
+ {user.id} + {user.karma} ★ +

Created {user.created}

+
+ {user.about && ( +
+

+

+ )} +
+ )} +
+ ); +} diff --git a/react-hn/src/context/.gitkeep b/react-hn/src/context/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/context/SettingsContext.tsx b/react-hn/src/context/SettingsContext.tsx new file mode 100644 index 000000000..62ccfc636 --- /dev/null +++ b/react-hn/src/context/SettingsContext.tsx @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback, type ReactNode } from 'react'; +import type { Settings, Theme } from '../models/types'; +import { SettingsContext } from './settingsContextDef'; + +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) as boolean : false, + theme: 'default', + titleFontSize: titleFontSize ?? '16', + listSpacing: listSpacing ?? '0', + }; +} + +function getInitialTheme(): Theme { + const saved = localStorage.getItem('theme'); + if (saved === 'default' || saved === 'night' || saved === 'amoledblack') { + return saved; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'default'; +} + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(() => { + const initial = getInitialSettings(); + initial.theme = getInitialTheme(); + return initial; + }); + + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => { + if (!localStorage.getItem('theme')) { + setSettings(prev => ({ ...prev, theme: e.matches ? 'night' : 'default' })); + } + }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + const toggleSettings = useCallback(() => { + setSettings(prev => ({ ...prev, showSettings: !prev.showSettings })); + }, []); + + const toggleOpenLinksInNewTab = useCallback(() => { + setSettings(prev => { + const next = !prev.openLinkInNewTab; + localStorage.setItem('openLinkInNewTab', JSON.stringify(next)); + return { ...prev, openLinkInNewTab: next }; + }); + }, []); + + const setThemeValue = useCallback((theme: Theme) => { + localStorage.setItem('theme', theme); + setSettings(prev => ({ ...prev, theme })); + }, []); + + const setFont = useCallback((fontSize: string) => { + localStorage.setItem('titleFontSize', fontSize); + setSettings(prev => ({ ...prev, titleFontSize: fontSize })); + }, []); + + const setSpacing = useCallback((listSpacing: string) => { + localStorage.setItem('listSpacing', listSpacing); + setSettings(prev => ({ ...prev, listSpacing })); + }, []); + + return ( + + {children} + + ); +} diff --git a/react-hn/src/context/settingsContextDef.ts b/react-hn/src/context/settingsContextDef.ts new file mode 100644 index 000000000..95928a267 --- /dev/null +++ b/react-hn/src/context/settingsContextDef.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type { Settings, Theme } from '../models/types'; + +export interface SettingsContextValue { + settings: Settings; + toggleSettings: () => void; + toggleOpenLinksInNewTab: () => void; + setTheme: (theme: Theme) => void; + setFont: (fontSize: string) => void; + setSpacing: (listSpacing: string) => void; +} + +export const SettingsContext = createContext(null); diff --git a/react-hn/src/context/useSettings.ts b/react-hn/src/context/useSettings.ts new file mode 100644 index 000000000..996d9019c --- /dev/null +++ b/react-hn/src/context/useSettings.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SettingsContext } from './settingsContextDef'; +import type { SettingsContextValue } from './settingsContextDef'; + +export type { SettingsContextValue }; + +export function useSettings(): SettingsContextValue { + const ctx = useContext(SettingsContext); + if (!ctx) throw new Error('useSettings must be used within a SettingsProvider'); + return ctx; +} diff --git a/react-hn/src/main.tsx b/react-hn/src/main.tsx index b19555880..607cf4b4e 100644 --- a/react-hn/src/main.tsx +++ b/react-hn/src/main.tsx @@ -1,8 +1,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { App } from './App' createRoot(document.getElementById('root')!).render( -
Loading...
+
, ) diff --git a/react-hn/src/models/.gitkeep b/react-hn/src/models/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/models/types.ts b/react-hn/src/models/types.ts new file mode 100644 index 000000000..3d451c135 --- /dev/null +++ b/react-hn/src/models/types.ts @@ -0,0 +1,56 @@ +export type FeedType = 'poll' | 'story' | 'job'; + +export type Theme = 'default' | 'night' | 'amoledblack'; + +export interface PollResult { + content?: string; + points: number; +} + +export interface Comment { + id: number; + level: number; + user: string; + time: number; + time_ago: string; + content?: string; + deleted?: boolean; + dead?: boolean; + comments: Comment[]; +} + +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; + content?: string; +} + +export interface User { + id: string; + created_time: number; + created: string; + karma: number; + avg: number; + about?: string; +} + +export interface Settings { + showSettings: boolean; + openLinkInNewTab: boolean; + theme: Theme; + titleFontSize: string; + listSpacing: string; +} diff --git a/react-hn/src/styles/.gitkeep b/react-hn/src/styles/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/styles/App.scss b/react-hn/src/styles/App.scss new file mode 100644 index 000000000..f0523fa9a --- /dev/null +++ b/react-hn/src/styles/App.scss @@ -0,0 +1,24 @@ +@import "./media"; +@import "./theme_variables"; + +.body-cover { + width: 100%; + z-index: 0; + position: fixed; + height: 100%; +} + +.wrapper { + position: relative; + width: 85%; + min-height: 80px; + margin: 0 auto; + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + font-size: 15px; + height: 100%; + line-height: 1.3; + + @media #{$mobile-only} { + width: 100%; + } +} diff --git a/react-hn/src/styles/Comment.scss b/react-hn/src/styles/Comment.scss new file mode 100644 index 000000000..322671961 --- /dev/null +++ b/react-hn/src/styles/Comment.scss @@ -0,0 +1,85 @@ +@import "./media"; +@import "./theme_variables"; + +.comment-component { + a { + font-weight: bold; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } +} + +.meta { + font-size: 13px; + color: #696969; + font-weight: bold; + letter-spacing: 0.5px; + margin-bottom: 8px; + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + .time { + padding-left: 5px; + } +} + +@media #{$mobile-only} { + .meta { + font-size: 14px; + margin-bottom: 10px; + .time { + padding: 0; + float: right; + } + } +} + +.meta-collapse { + margin-bottom: 20px; +} + +.deleted-meta { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; + margin: 30px 0; + a { + text-decoration: none; + } +} + +.collapse { + font-size: 13px; + letter-spacing: 2px; + cursor: pointer; +} + +.comment-tree { + margin-left: 24px; +} + +@media #{$tablet-only} { + .comment-tree { + margin-left: 8px; + } +} + +.comment-text { + font-size: 15px; + margin-top: 0; + margin-bottom: 20px; + word-wrap: break-word; + line-height: 1.5em; +} + +.subtree { + margin-left: 0; + padding: 0; + list-style-type: none; +} diff --git a/react-hn/src/styles/ErrorMessage.scss b/react-hn/src/styles/ErrorMessage.scss new file mode 100644 index 000000000..8bb6c5925 --- /dev/null +++ b/react-hn/src/styles/ErrorMessage.scss @@ -0,0 +1,115 @@ +@import "./media"; +@import "./theme_variables"; + +.error-section { + height: 300px; + margin: 200px; + + @media #{$mobile-only} { + height: 0; + display: block; + position: relative; + margin: 30vh 0; + } + + p { + text-align: center; + padding: 0 25px; + + &.strong { + margin-top: 25px; + font-weight: bold; + } + } + + .skull { + width: $skull-size; + height: $skull-size; + position: relative; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + + .head { + width: 100%; + height: 75%; + border-radius: 15% / 20%; + position: absolute; + top: 0; + left: 0; + &:before, &:after { + content: ""; + position: absolute; + border-radius: 50%; + width: 20%; + height: 30%; + bottom: 10%; + } + &:before { + left: 10%; + } + &:after { + right: 10%; + } + .crack { + width: 10%; + height: 10%; + position: absolute; + top: 0; + right: 25%; + transform: skew(-15deg); + + &:before { + content: ""; + position: absolute; + top: 100%; + left: calc($skull-size / 15); + border-right: calc($skull-size / 20) solid transparent; + border-left: calc($skull-size / 40) solid transparent; + } + } + } + .mouth { + width: 40%; + height: 25%; + position: absolute; + top: 75%; + left: 30%; + border-radius: 0 0 calc($skull-size / 10) calc($skull-size / 10); + &:before { + content: ""; + position: absolute; + width: 15%; + height: 50%; + border-radius: 50% / 30%; + left: 42.5%; + top: -25%; + } + .teeth { + position: absolute; + bottom: 0; + left: 45%; + width: 10%; + height: 50%; + margin-bottom: -5%; + border-radius: 50% / 20%; + + &:before, &:after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + border-radius: 50% / 20%; + } + &:before { + left: -250%; + } + &:after { + right: -250%; + } + } + } + } +} diff --git a/react-hn/src/styles/Feed.scss b/react-hn/src/styles/Feed.scss new file mode 100644 index 000000000..51967782e --- /dev/null +++ b/react-hn/src/styles/Feed.scss @@ -0,0 +1,107 @@ +@import "./media"; +@import "./theme_variables"; + +.feed a { + text-decoration: none; + font-weight: bold; + + &:hover { + text-decoration: underline; + } +} + +.feed ol { + padding: 0 40px; + margin: 0; + + @media #{$mobile-only} { + box-sizing: border-box; + list-style: none; + padding: 0 10px; + } + + li { + position: relative; + -webkit-transition: background-color .2s ease; + transition: background-color .2s ease; + } +} + +.list-margin { + @media #{$mobile-only} { + margin-top: 55px; + } +} + +.main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; +} + +.post { + padding: 10px 0 10px 5px; + transition: background-color 0.2s ease; + border-bottom: 1px solid #CECECB; + + .itemNum { + color: #696969; + position: absolute; + width: 30px; + text-align: right; + left: 0; + top: 4px; + } +} + +.item-block { + display: block; +} + +.nav { + padding: 10px 40px; + margin-top: 10px; + font-size: 17px; + + a { + @media #{$mobile-only} { + text-decoration: none; + } + } + + @media #{$mobile-only} { + margin: 20px 0; + text-align: center; + padding: 10px 80px; + height: 20px; + } + + .prev { + padding-right: 20px; + + @media #{$mobile-only} { + float: left; + padding-right: 0; + } + } + + .more { + @media #{$mobile-only} { + float: right; + } + } +} + +.job-header { + font-size: 15px; + padding: 0 40px 10px; + + @media #{$mobile-only} { + padding: 60px 15px 25px 15px; + } +} diff --git a/react-hn/src/styles/FeedItem.scss b/react-hn/src/styles/FeedItem.scss new file mode 100644 index 000000000..fae8559f4 --- /dev/null +++ b/react-hn/src/styles/FeedItem.scss @@ -0,0 +1,70 @@ +@import "./media"; +@import "./theme_variables"; + +.feed-item { + p { + margin: 2px 0; + + @media #{$mobile-only} { + margin-bottom: 5px; + margin-top: 0; + } + } + + 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; + + a { + &:hover { + text-decoration: underline; + } + } + @media #{$mobile-only} { + display: none; + } + } + + .subtext-palm { + font-size: 13px; + font-weight: bold; + letter-spacing: 0.5px; + + a { + &:hover { + text-decoration: underline; + } + } + + .details { + margin-top: 5px; + + .right { + float: right; + } + } + @media #{$laptop-only} { + display: none; + } + } + + .domain { + color: #696969; + letter-spacing: 0.5px; + } + + .item-details { + padding: 10px; + } +} diff --git a/react-hn/src/styles/Footer.scss b/react-hn/src/styles/Footer.scss new file mode 100644 index 000000000..50a35fa8c --- /dev/null +++ b/react-hn/src/styles/Footer.scss @@ -0,0 +1,23 @@ +@import "./media"; +@import "./theme_variables"; + +#footer { + position: relative; + padding: 10px; + height: 60px; + letter-spacing: 0.7px; + text-align: center; + + a { + font-weight: bold; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media #{$mobile-only} { + display: none; + } +} diff --git a/react-hn/src/styles/Header.scss b/react-hn/src/styles/Header.scss new file mode 100644 index 000000000..8a903a241 --- /dev/null +++ b/react-hn/src/styles/Header.scss @@ -0,0 +1,149 @@ +@import "./media"; +@import "./theme_variables"; + +#header { + color: #fff; + padding: 6px 0; + line-height: 18px; + vertical-align: middle; + position: relative; + z-index: 1; + width: 100%; + + @media #{$mobile-only} { + height: 50px; + position: fixed; + top: 0; + } + + 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 #{$mobile-only} { + left: 16px; + top: 12px; + } +} + +.logo { + width: 50px; + padding: 3px 8px 0; + + @media #{$mobile-only} { + width: 45px; + padding: 0 0 0 10px; + } +} + +h1 { + font-weight: normal; + display: inline-block; + vertical-align:middle; + margin: 0; + font-size: 16px; + + a { + color: #fff; + text-decoration: none; + } +} + +.name { + margin-right: 30px; + margin-bottom: 2px; + + @media #{$mobile-only} { + display: none; + } +} + +.header-text { + position: absolute; + width: inherit; + height: 20px; + left: 10px; + top: 27px; + z-index: -1; + + @media #{$mobile-only} { + top: 22px; + } +} + +.left { + position: absolute; + left: 60px; + font-size: 16px; + + @media #{$mobile-only} { + width: 100%; + left: 0; + } +} + +.header-nav { + display: inline-block; + margin-left: 20px; + + @media #{$mobile-only} { + margin-left: 60px; + } + + a { + color: hsla(0,0%,100%,.9); + text-decoration: none; + margin: 0 5px; + letter-spacing: 1.8px; + + &:hover { + color: #fff; + } + } + + .active { + color: #fff; + } +} + +.info { + position: absolute; + top: 0; + right: 20px; + height: 100%; + + @media #{$mobile-only} { + right: 10px; + } + + img { + opacity: 0.8; + width: 25px; + margin-top: 21.5px; + display: block; + + &:hover { + opacity: 1; + cursor: pointer; + } + + @media #{$mobile-only} { + margin-top: 15px; + } + } +} diff --git a/react-hn/src/styles/ItemDetails.scss b/react-hn/src/styles/ItemDetails.scss new file mode 100644 index 000000000..063502c2e --- /dev/null +++ b/react-hn/src/styles/ItemDetails.scss @@ -0,0 +1,153 @@ +@import "./media"; +@import "./theme_variables"; + +.item-details-page { + .main-content { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; + } + + .item { + box-sizing: border-box; + padding: 10px 40px 0 40px; + z-index: 0; + } + + @media #{$tablet-only} { + .item { + padding: 10px 20px 0 40px; + } + } + + @media #{$mobile-only} { + .item { + box-sizing: border-box; + padding: 110px 15px 0 15px; + } + } + + .head-margin { + margin-bottom: 15px; + } + + p { + margin: 2px 0; + } + + .subject { + word-wrap: break-word; + margin-top: 20px; + } + + a { + cursor: pointer; + text-decoration: none; + } + + @media #{$mobile-only} { + .laptop { + display: none; + } + } + + @media #{$laptop-only} { + .mobile { + display: none; + } + } + + .title { + font-size: 16px; + font-family: Verdana, Geneva, sans-serif; + } + + .title-block { + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; + } + + @media #{$mobile-only} { + .title { + font-size: 15px; + } + .back-button { + position: absolute; + top: 52%; + width: 0.6rem; + height: 0.6rem; + background: transparent; + box-shadow: 0 0 0 lightgray; + transition: all 200ms ease; + left: 4%; + transform: translate3d(0, -50%, 0) rotate(-135deg); + } + } + + .subtext { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; + } + + .domain { + letter-spacing: 0.5px; + } + + .subtext a { + &:hover { + text-decoration: underline; + } + } + + .item-details { + padding: 10px; + } + + .item-header { + padding-bottom: 10px; + } + + @media #{$mobile-only} { + .item-header { + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + } + } + + .pollResults { + margin-bottom: 1em; + } + + .pollContent { + * { + padding-bottom: 0; + margin-bottom: -1em; + margin-top: 1em; + } + .pollBar { + height: 10px; + margin-bottom: 1em; + } + } + + ul { + list-style-type: none; + padding: 10px 0; + } + + li { + display: list-item; + } +} diff --git a/react-hn/src/styles/Loader.scss b/react-hn/src/styles/Loader.scss new file mode 100644 index 000000000..d5824469a --- /dev/null +++ b/react-hn/src/styles/Loader.scss @@ -0,0 +1,109 @@ +@import "./media"; +@import "./theme_variables"; + +.loader { + -webkit-animation: load1 1s infinite ease-in-out; + animation: load1 1s infinite ease-in-out; + width: 1em; + height: 4em; + &:before, &:after { + -webkit-animation: load1 1s infinite ease-in-out; + animation: load1 1s infinite ease-in-out; + width: 1em; + height: 4em; + } + &:before, &:after { + position: absolute; + top: 0; + content: ''; + } + &:before { + left: -1.5em; + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; + } +} + +.loading-section { + height: 70px; + margin: 40px 0 40px 40px; + + @media #{$mobile-only} { + display: block; + position: relative; + margin: 45vh 0; + } +} + +.loader { + 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; + &:after { + left: 1.5em; + } + + @media #{$mobile-only} { + 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 #{$mobile-only} { + @-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/react-hn/src/styles/Settings.scss b/react-hn/src/styles/Settings.scss new file mode 100644 index 000000000..d0b07d50f --- /dev/null +++ b/react-hn/src/styles/Settings.scss @@ -0,0 +1,74 @@ +@import "./media"; +@import "./theme_variables"; + +.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; + h1 { + margin-top: 0; + margin-bottom: 0px; + color: #fff; + text-align: center; + letter-spacing: 1px; + } + h2 { + padding-top: 10px; + } + hr { + width: 40%; + margin-bottom: 20px; + } + .close { + position: absolute; + top: 12px; + right: 20px; + font-size: 30px; + font-weight: bold; + text-decoration: none; + color: rgba(255,255,255,0.8); + &:hover { + color: #fff; + cursor: pointer; + } + } + .content { + max-height: 30%; + color: #fff; + letter-spacing: 1px; + overflow: auto; + } + 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/react-hn/src/styles/UserProfile.scss b/react-hn/src/styles/UserProfile.scss new file mode 100644 index 000000000..eeee8da1f --- /dev/null +++ b/react-hn/src/styles/UserProfile.scss @@ -0,0 +1,91 @@ +@import "./media"; +@import "./theme_variables"; + +.user-profile { + pre { + white-space: pre-wrap; + } + + .profile { + padding: 30px; + } + + @media #{$mobile-only} { + .profile { + padding: 110px 15px 0 15px; + } + .title-block { + font-size: 15px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; + } + .back-button { + position: absolute; + top: 52%; + width: 0.6rem; + height: 0.6rem; + background: transparent; + box-shadow: 0 0 0 lightgray; + transition: all 200ms ease; + left: 4%; + transform: translate3d(0, -50%, 0) rotate(-135deg); + } + .item-header { + padding-bottom: 10px; + background-color: #fff; + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + height: 20px; + } + } + + @media #{$laptop-only} { + .mobile { + display: none; + } + } + + .main-details { + .name { + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } + .age { + font-weight: bold; + color: #696969; + padding-bottom: 0; + } + .right { + float: right; + font-weight: bold; + font-size: 32px; + letter-spacing: 2px; + } + } + + @media #{$mobile-only} { + .main-details { + margin-top: 20px; + .name { + font-size: 18px; + } + } + } + + @media #{$mobile-only} { + .main-details .right { + font-size: 18px; + } + } + + .other-details { + word-wrap: break-word; + } +} diff --git a/react-hn/src/styles/_media.scss b/react-hn/src/styles/_media.scss new file mode 100644 index 000000000..b04b71a92 --- /dev/null +++ b/react-hn/src/styles/_media.scss @@ -0,0 +1,3 @@ +$mobile-only: "only screen and (max-width : 768px)"; +$laptop-only: "only screen and (min-width : 769px)"; +$tablet-only: "only screen and (max-width : 1024px)"; diff --git a/react-hn/src/styles/_theme_variables.scss b/react-hn/src/styles/_theme_variables.scss new file mode 100644 index 000000000..32759093e --- /dev/null +++ b/react-hn/src/styles/_theme_variables.scss @@ -0,0 +1,29 @@ +$skull-size: 200px; + +// Day theme colors +$theme-day-body-background-color: #fff; +$theme-day-wrapper-background-color: #f5f5f5; +$theme-day-wrapper-mobile-background-color: #fff; +$theme-day-text-color: #000; +$theme-day-subtext-color: #696969; +$theme-day-secondary-color: #b92b27; +$theme-day-header-background-color: $theme-day-secondary-color; +$theme-day-logo-inner: #fff; +$theme-day-border: 2px solid #b92b27; + +// Night theme colors +$theme-night-body-background-color: #37474F; +$theme-night-wrapper-background-color: #263238; +$theme-night-wrapper-mobile-background-color: $theme-night-wrapper-background-color; +$theme-night-text-color: rgba(255, 255, 255, 0.7); +$theme-night-subtext-color: #999; +$theme-night-secondary-color: #00c0ff; +$theme-night-header-background-color: #263238; +$theme-night-logo-inner: $theme-night-header-background-color; +$theme-night-border: 2px solid #00c0ff; + +// Black theme colors +$theme-amoledblack-body-background-color: #000; +$theme-amoledblack-text-color: rgba(255, 255, 255, 0.75); +$theme-amoledblack-subtext-color: rgba(255, 255, 255, 0.5); +$theme-amoledblack-secondary-color: rgba(255, 255, 255, 0.6); diff --git a/react-hn/src/styles/_themes.scss b/react-hn/src/styles/_themes.scss new file mode 100644 index 000000000..fc68aaeb1 --- /dev/null +++ b/react-hn/src/styles/_themes.scss @@ -0,0 +1,233 @@ +@import "./media"; +@import "./theme_variables"; + +@mixin theme( + $name, + $body-background-color, + $wrapper-background-color, + $wrapper-mobile-background-color, + $wrapper-color, + $item-a-color, + $item-a-visited-color, + $header-background-color, + $subtext-color, + $secondary-link-color, + $logo-inner-color, + $border +) { + .#{$name} { + .body-cover { + background: $body-background-color; + + @media #{$mobile-only} { + background: $wrapper-mobile-background-color; + } + } + + .wrapper { + background: $wrapper-background-color; + color: $wrapper-color; + + @media #{$mobile-only} { + background: $wrapper-mobile-background-color; + } + + a { + color: $item-a-color; + + &:visited { + color: $item-a-visited-color; + } + } + + #header { + background: $header-background-color; + border-bottom: $border; + } + + .logo-inner { + background: $logo-inner-color; + } + + .nav { + a { + color: $secondary-link-color; + + @media #{$mobile-only} { + color: $secondary-link-color; + } + } + } + + #footer { + border-top: $border; + + a { + color: $secondary-link-color; + } + } + + .subtext, .subtext-palm, .subtext-laptop, .domain, .meta, .deleted-meta{ + color: $subtext-color; + + a { + color: $secondary-link-color; + } + } + + .popup { + background: $header-background-color; + } + + .item-header { + border-bottom: $border; + + @media #{$mobile-only} { + background: $wrapper-mobile-background-color; + } + } + + .pollContent { + .pollBar { + background: $secondary-link-color; + } + } + + .loader { + color: $secondary-link-color; + background: $secondary-link-color; + + &:before, &:after { + background: $secondary-link-color; + } + } + + .job-header { + @media #{$mobile-only} { + border-bottom: $border; + } + } + + .back-button { + @media #{$mobile-only} { + border-top: .3rem solid $secondary-link-color; + border-right: .3rem solid $secondary-link-color; + } + } + + .error-section { + .skull { + .head { + background-color: $secondary-link-color; + + &:before, &:after { + background-color: $wrapper-background-color; + + @media #{$mobile-only} { + background-color: $wrapper-mobile-background-color; + } + } + + .crack { + background-color: $wrapper-background-color; + + @media #{$mobile-only} { + background-color: $wrapper-mobile-background-color; + } + + &:before { + border-top: $skull-size / 8 solid $wrapper-background-color; + + @media #{$mobile-only} { + border-top: $skull-size / 8 solid $wrapper-mobile-background-color; + } + } + } + } + + .mouth { + background-color: $secondary-link-color; + + &:before { + background-color: $wrapper-background-color; + + @media #{$mobile-only} { + background-color: $wrapper-mobile-background-color; + } + } + } + + .teeth { + background-color: $wrapper-background-color; + + @media #{$mobile-only} { + background-color: $wrapper-mobile-background-color; + } + + &:before, &:after { + background-color: $wrapper-background-color; + + @media #{$mobile-only} { + background-color: $wrapper-mobile-background-color; + } + } + } + } + } + + .main-details { + .name { + color: $secondary-link-color; + } + .right { + color: $secondary-link-color; + } + } + } + } +} + +@include theme( + default, + $theme-day-body-background-color, + $theme-day-wrapper-background-color, + $theme-day-wrapper-mobile-background-color, + $theme-day-text-color, + $theme-day-text-color, + $theme-day-subtext-color, + $theme-day-header-background-color, + $theme-day-subtext-color, + $theme-day-secondary-color, + $theme-day-logo-inner, + $theme-day-border +); + +@include theme( + night, + $theme-night-body-background-color, + $theme-night-wrapper-background-color, + $theme-night-wrapper-mobile-background-color, + $theme-night-text-color, + $theme-night-text-color, + $theme-night-subtext-color, + $theme-night-header-background-color, + $theme-night-subtext-color, + $theme-night-secondary-color, + $theme-night-logo-inner, + $theme-night-border +); + +@include theme( + amoledblack, + $theme-amoledblack-body-background-color, + $theme-amoledblack-body-background-color, + $theme-amoledblack-body-background-color, + $theme-amoledblack-text-color, + $theme-amoledblack-text-color, + darken($theme-amoledblack-text-color, 33%), + $theme-amoledblack-body-background-color, + $theme-amoledblack-subtext-color, + $theme-amoledblack-secondary-color, + $theme-amoledblack-body-background-color, + $theme-amoledblack-secondary-color +); diff --git a/react-hn/src/styles/global.scss b/react-hn/src/styles/global.scss new file mode 100644 index 000000000..ea1affb1c --- /dev/null +++ b/react-hn/src/styles/global.scss @@ -0,0 +1,35 @@ +@import "./themes"; + +html { + height: 100%; + width: 100%; +} + +body { + margin: 0; + height: 100%; + width: 100%; +} + +pre { + white-space: pre-wrap; +} + +.app-loader { + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + position: fixed; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: -1; +} + +@media screen and (max-width: 768px) { + .app-loader { + background-color: #fff; + } +} diff --git a/react-hn/src/utils/.gitkeep b/react-hn/src/utils/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/react-hn/src/utils/formatComment.ts b/react-hn/src/utils/formatComment.ts new file mode 100644 index 000000000..7daf2aece --- /dev/null +++ b/react-hn/src/utils/formatComment.ts @@ -0,0 +1,6 @@ +export function formatComment(count: number): string { + if (count > 0) { + return count === 1 ? '1 comment' : `${count} comments`; + } + return 'discuss'; +} diff --git a/react-hn/vite.config.ts b/react-hn/vite.config.ts index 8b0f57b91..ac2819328 100644 --- a/react-hn/vite.config.ts +++ b/react-hn/vite.config.ts @@ -1,7 +1,58 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { VitePWA } from 'vite-plugin-pwa' -// https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['assets/images/logo.svg', 'assets/icons/favicon-32x32.png'], + manifest: { + name: 'React HN', + short_name: 'React HN', + description: 'A Hacker News client built with React, TypeScript and Vite', + theme_color: '#b92b27', + background_color: '#f5f5f5', + display: 'standalone', + start_url: '/', + icons: [ + { + src: '/assets/icons/android-chrome-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/assets/icons/android-chrome-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/assets/icons/android-chrome-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, + ], + }, + workbox: { + runtimeCaching: [ + { + urlPattern: /^https:\/\/node-hnapi\.herokuapp\.com\/.*/i, + handler: 'NetworkFirst', + options: { + cacheName: 'hn-api-cache', + expiration: { + maxEntries: 100, + maxAgeSeconds: 60 * 5, + }, + cacheableResponse: { + statuses: [0, 200], + }, + }, + }, + ], + }, + }), + ], })