diff --git a/src/main.tsx b/src/main.tsx
index 6e48f57c..db16d364 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,7 +1,7 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
-import App from './react/App';
import { SettingsProvider } from './react/context/SettingsContext';
+import AppRoutes from './react/routes';
import './react/styles.scss';
const rootElement = document.getElementById('root');
@@ -13,7 +13,7 @@ if (!rootElement) {
createRoot(rootElement).render(
-
+
);
diff --git a/src/react/App.scss b/src/react/App.scss
new file mode 100644
index 00000000..2b421f0d
--- /dev/null
+++ b/src/react/App.scss
@@ -0,0 +1,24 @@
+@import "./scss/media";
+@import "./scss/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/src/react/App.tsx b/src/react/App.tsx
index 7537f7c2..7303e10a 100644
--- a/src/react/App.tsx
+++ b/src/react/App.tsx
@@ -1,11 +1,23 @@
+import { Outlet } from 'react-router-dom';
import { useSettings } from './context/SettingsContext';
+import { usePageViews } from './hooks/usePageViews';
+import Footer from './components/Footer';
+import Header from './components/Header';
+import './App.scss';
export default function App() {
const { settings } = useSettings();
+ usePageViews();
+
return (
-
- Hacker News
-
+
);
}
diff --git a/src/react/components/Feed.tsx b/src/react/components/Feed.tsx
new file mode 100644
index 00000000..a17d97f4
--- /dev/null
+++ b/src/react/components/Feed.tsx
@@ -0,0 +1,17 @@
+import { useParams } from 'react-router-dom';
+import { FeedName } from '../models/feed-name.type';
+
+interface FeedProps {
+ feedName: FeedName;
+}
+
+export default function Feed({ feedName }: FeedProps) {
+ const { page } = useParams<'page'>();
+
+ return (
+
+ {feedName}
+ Page {page}
+
+ );
+}
diff --git a/src/react/components/Footer.scss b/src/react/components/Footer.scss
new file mode 100644
index 00000000..ec664bb1
--- /dev/null
+++ b/src/react/components/Footer.scss
@@ -0,0 +1,23 @@
+@import "../scss/media";
+@import "../scss/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/src/react/components/Footer.tsx b/src/react/components/Footer.tsx
new file mode 100644
index 00000000..dcd1c0f6
--- /dev/null
+++ b/src/react/components/Footer.tsx
@@ -0,0 +1,14 @@
+import './Footer.scss';
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/src/react/components/Header.scss b/src/react/components/Header.scss
new file mode 100644
index 00000000..b97bc434
--- /dev/null
+++ b/src/react/components/Header.scss
@@ -0,0 +1,149 @@
+@import "../scss/media";
+@import "../scss/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/src/react/components/Header.tsx b/src/react/components/Header.tsx
new file mode 100644
index 00000000..86663e92
--- /dev/null
+++ b/src/react/components/Header.tsx
@@ -0,0 +1,72 @@
+import { Fragment } from 'react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { useSettings } from '../context/SettingsContext';
+import { FeedName } from '../models/feed-name.type';
+import Settings from './Settings';
+import './Header.scss';
+
+const feedLinks: Array<{ feedName: FeedName; path: string; label: string }> = [
+ { feedName: 'newest', path: '/newest/1', label: 'new' },
+ { feedName: 'show', path: '/show/1', label: 'show' },
+ { feedName: 'ask', path: '/ask/1', label: 'ask' },
+ { feedName: 'jobs', path: '/jobs/1', label: 'jobs' },
+];
+
+function isFeedActive(pathname: string, feedName: FeedName): boolean {
+ return pathname.split('/')[1] === feedName;
+}
+
+function scrollTop(): void {
+ window.scrollTo(0, 0);
+}
+
+export default function Header() {
+ const { pathname } = useLocation();
+ const { settings, toggleSettings } = useSettings();
+
+ return (
+
+
+ {settings.showSettings && }
+
+ );
+}
diff --git a/src/react/components/ItemDetails.tsx b/src/react/components/ItemDetails.tsx
new file mode 100644
index 00000000..e6e5382d
--- /dev/null
+++ b/src/react/components/ItemDetails.tsx
@@ -0,0 +1,12 @@
+import { useParams } from 'react-router-dom';
+
+export default function ItemDetails() {
+ const { id } = useParams<'id'>();
+
+ return (
+
+ Item details
+ Item {id}
+
+ );
+}
diff --git a/src/react/components/Settings.scss b/src/react/components/Settings.scss
new file mode 100644
index 00000000..354c248b
--- /dev/null
+++ b/src/react/components/Settings.scss
@@ -0,0 +1,74 @@
+@import "../scss/media";
+@import "../scss/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/src/react/components/Settings.tsx b/src/react/components/Settings.tsx
new file mode 100644
index 00000000..f1cbe0b8
--- /dev/null
+++ b/src/react/components/Settings.tsx
@@ -0,0 +1,104 @@
+import { useSettings } from '../context/SettingsContext';
+import './Settings.scss';
+
+export default function Settings() {
+ const {
+ settings,
+ toggleSettings,
+ toggleOpenLinksInNewTab,
+ setTheme,
+ setFont,
+ setSpacing,
+ } = useSettings();
+
+ return (
+
+ );
+}
diff --git a/src/react/components/User.tsx b/src/react/components/User.tsx
new file mode 100644
index 00000000..6f21b762
--- /dev/null
+++ b/src/react/components/User.tsx
@@ -0,0 +1,12 @@
+import { useParams } from 'react-router-dom';
+
+export default function User() {
+ const { id } = useParams<'id'>();
+
+ return (
+
+ User
+ {id}
+
+ );
+}
diff --git a/src/react/hooks/usePageViews.ts b/src/react/hooks/usePageViews.ts
new file mode 100644
index 00000000..a58c3615
--- /dev/null
+++ b/src/react/hooks/usePageViews.ts
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+export function usePageViews(): void {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof (window as any).ga !== 'function') {
+ return;
+ }
+
+ (window as any).ga('set', 'page', pathname);
+ (window as any).ga('send', 'pageview');
+ }, [pathname]);
+}
diff --git a/src/react/routes.tsx b/src/react/routes.tsx
new file mode 100644
index 00000000..115c537a
--- /dev/null
+++ b/src/react/routes.tsx
@@ -0,0 +1,41 @@
+import { lazy, Suspense } from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { FeedName } from './models/feed-name.type';
+import App from './App';
+import Feed from './components/Feed';
+
+const ItemDetails = lazy(() => import('./components/ItemDetails'));
+const User = lazy(() => import('./components/User'));
+
+const lazyFallback = Loading...
;
+
+export default function AppRoutes() {
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ );
+}