├── .gitattributes ├── src ├── @types │ └── global.ts ├── __mocks__ │ ├── style-mock.js │ └── file-mock.js ├── entry.client.tsx ├── server │ ├── models │ │ ├── feed.ts │ │ ├── index.ts │ │ ├── item-model.ts │ │ ├── comment-model.ts │ │ ├── user-model.ts │ │ └── story-model.ts │ ├── responses │ │ ├── index.ts │ │ ├── item.ts │ │ ├── user.ts │ │ ├── comment.ts │ │ └── story.ts │ ├── services │ │ ├── item-service.server.ts │ │ ├── story-service.server.ts │ │ ├── user-service.server.ts │ │ ├── comment-service.server.ts │ │ └── feed-service.server.ts │ ├── config.server.ts │ ├── database │ │ ├── feed-updater.ts │ │ ├── cache.ts │ │ └── database.ts │ └── bootstrap.server.ts ├── config.ts ├── utils │ ├── context.ts │ ├── validation │ │ ├── validation-error.ts │ │ └── user.ts │ ├── news-page-number.ts │ ├── hooks.ts │ ├── hash-password.server.ts │ ├── user-login-error-code.ts │ ├── is-valid-url.ts │ ├── convert-number-to-time-ago.ts │ ├── http-handlers.ts │ └── convert-number-to-time-ago.spec.ts ├── __tests__ │ ├── dmca.spec.tsx │ ├── front.spec.tsx │ ├── index.spec.tsx │ ├── jobs.spec.tsx │ ├── lists.spec.tsx │ ├── reply.spec.tsx │ ├── show.spec.tsx │ ├── user.spec.tsx │ ├── active.spec.tsx │ ├── ask.spec.tsx │ ├── best.spec.tsx │ ├── item.spec.tsx │ ├── login.spec.tsx │ ├── newsfaq.spec.tsx │ ├── noobstories.spec.tsx │ ├── submit.spec.tsx │ ├── hidden.spec.tsx │ ├── leaders.spec.tsx │ ├── newest.spec.tsx │ ├── newpoll.spec.tsx │ ├── showhn.spec.tsx │ ├── shownew.spec.tsx │ ├── threads.spec.tsx │ ├── formatdoc.spec.tsx │ ├── security.spec.tsx │ ├── bookmarklet.spec.tsx │ ├── changepw.spec.tsx │ ├── newcomments.spec.tsx │ ├── newswelcome.spec.tsx │ ├── submitted.spec.tsx │ ├── bestcomments.spec.tsx │ ├── newsguidelines.spec.tsx │ └── noobcomments.spec.tsx ├── routes │ ├── __main │ │ ├── changepw.tsx │ │ ├── newpoll.tsx │ │ ├── front.tsx │ │ ├── hidden.tsx │ │ ├── leaders.tsx │ │ ├── active.tsx │ │ ├── favorites.tsx │ │ ├── upvoted.tsx │ │ ├── bestcomments.tsx │ │ ├── noobcomments.tsx │ │ ├── noobstories.tsx │ │ ├── threads.tsx │ │ ├── submitted.tsx │ │ ├── from.tsx │ │ ├── x.tsx │ │ ├── newcomments.tsx │ │ ├── formatdoc.tsx │ │ ├── newest.tsx │ │ ├── ask.tsx │ │ ├── best.tsx │ │ ├── index.tsx │ │ ├── item.tsx │ │ ├── lists.tsx │ │ ├── show.tsx │ │ ├── shownew.tsx │ │ ├── jobs.tsx │ │ ├── reply.tsx │ │ └── submit.tsx │ ├── __notice.tsx │ ├── logout.ts │ ├── __blank │ │ ├── forgot.tsx │ │ ├── login.tsx │ │ └── dmca.tsx │ ├── comment.ts │ ├── xuser.ts │ ├── __main.tsx │ ├── hide.ts │ ├── vote.ts │ ├── register.ts │ └── __notice │ │ ├── bookmarklet.tsx │ │ ├── showhn.tsx │ │ ├── newswelcome.tsx │ │ ├── newsguidelines.tsx │ │ ├── security.tsx │ │ └── newsfaq.tsx ├── layouts │ ├── blank-layout.tsx │ ├── notice-layout.tsx │ └── main-layout.tsx ├── cookies.ts ├── components │ ├── comment-box.spec.tsx │ ├── news-feed.spec.tsx │ ├── item-title.spec.tsx │ ├── comment-box.tsx │ ├── comment.spec.tsx │ ├── loading-spinner.tsx │ ├── item-detail.spec.tsx │ ├── __snapshots__ │ │ ├── comment-box.spec.tsx.snap │ │ ├── item-detail.spec.tsx.snap │ │ ├── item-title.spec.tsx.snap │ │ └── comment.spec.tsx.snap │ ├── footer.tsx │ ├── item-title.tsx │ ├── item-with-comments.tsx │ ├── header-links.tsx │ ├── header.tsx │ ├── item-detail.tsx │ ├── comments.tsx │ ├── comment.tsx │ └── news-feed.tsx ├── assets │ ├── yc.css │ └── dmca.css ├── entry.server.tsx └── root.tsx ├── .prettierignore ├── .eslintrc.js ├── prettier.config.js ├── public ├── robots.txt └── static │ ├── s.gif │ ├── y18.gif │ ├── yc500.gif │ ├── favicon.ico │ ├── grayarrow.gif │ └── grayarrow2x.gif ├── docs ├── architecture.png └── hn-screenshot-seal.webp ├── remix.env.d.ts ├── .gitignore ├── .dockerignore ├── jest-setup.js ├── remix.config.js ├── jest.config.js ├── tsconfig.json ├── healthcheck.js ├── fly.toml ├── LICENSE ├── Dockerfile ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /src/@types/global.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/__mocks__/style-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | build 3 | docs 4 | public 5 | -------------------------------------------------------------------------------- /src/__mocks__/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-mock'; 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@remix-run/eslint-config', 3 | }; 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | printWidth: 100, 4 | }; 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: / # Dont allow any pages to be indexed by robots for demo 3 | -------------------------------------------------------------------------------- /public/static/s.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/s.gif -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/docs/architecture.png -------------------------------------------------------------------------------- /public/static/y18.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/y18.gif -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/static/yc500.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/yc500.gif -------------------------------------------------------------------------------- /public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/favicon.ico -------------------------------------------------------------------------------- /public/static/grayarrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/grayarrow.gif -------------------------------------------------------------------------------- /docs/hn-screenshot-seal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/docs/hn-screenshot-seal.webp -------------------------------------------------------------------------------- /public/static/grayarrow2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clintonwoo/hackernews-remix-react/HEAD/public/static/grayarrow2x.gif -------------------------------------------------------------------------------- /src/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom'; 2 | import { RemixBrowser } from 'remix'; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .env 3 | .next 4 | .vscode 5 | *.DS_Store 6 | *.log 7 | build 8 | coverage 9 | dist 10 | logs 11 | node_modules 12 | npm-debug.log 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitattributes 3 | .gitignore 4 | .next 5 | .prettierignore 6 | build 7 | dist 8 | docs 9 | node_modules 10 | npm-debug.log 11 | public/build 12 | -------------------------------------------------------------------------------- /src/server/models/feed.ts: -------------------------------------------------------------------------------- 1 | export enum FeedType { 2 | TOP = 'top', 3 | NEW = 'new', 4 | BEST = 'best', 5 | SHOW = 'show', 6 | ASK = 'ask', 7 | JOB = 'job', 8 | } 9 | -------------------------------------------------------------------------------- /src/server/responses/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Models are pure data representations with validation 3 | */ 4 | 5 | export * from './comment'; 6 | export * from './story'; 7 | export * from './user'; 8 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* UI CONFIG */ 2 | export const POSTS_PER_PAGE = 30; 3 | 4 | export const PASSWORD_MAX_LENGTH = 100; 5 | export const PASSWORD_MIN_LENGTH = 8; 6 | export const USERID_MAX_LENGTH = 30; 7 | export const USERID_MIN_LENGTH = 3; 8 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface ICurrentLoggedInUser { 4 | id: string; 5 | karma: number; 6 | } 7 | export const MeContext = React.createContext(undefined); 8 | -------------------------------------------------------------------------------- /src/__tests__/dmca.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__blank/dmca'; 4 | 5 | describe('DMCA Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/front.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/front'; 4 | 5 | describe('Front Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/index'; 4 | 5 | describe('Home Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/jobs.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/jobs'; 4 | 5 | describe('Jobs Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/lists.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/lists'; 4 | 5 | describe('Lists Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/reply.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/reply'; 4 | 5 | describe('Reply Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/show.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/show'; 4 | 5 | describe('Show Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/user.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/user'; 4 | 5 | describe('User Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/__main/changepw.tsx: -------------------------------------------------------------------------------- 1 | import { MainLayout } from '../../layouts/main-layout'; 2 | 3 | export function ChangePasswordPage(): JSX.Element { 4 | return Not implemented.; 5 | } 6 | 7 | export default ChangePasswordPage; 8 | -------------------------------------------------------------------------------- /src/__tests__/active.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/active'; 4 | 5 | describe('Active Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/ask.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/ask'; 4 | 5 | describe('Newest Posts Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/best.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/best'; 4 | 5 | describe('Best Posts Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/item.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/item'; 4 | 5 | describe('News Item Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/login.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__blank/login'; 4 | 5 | describe('Login Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newsfaq.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/newsfaq'; 4 | 5 | describe('FAQ Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/noobstories.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import Page from '../routes/__main/noobstories'; 3 | 4 | describe('Noob Stories Page', () => { 5 | it('has default export', () => { 6 | expect(Page).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/submit.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/submit'; 4 | 5 | describe('Submit Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/hidden.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/hidden'; 4 | 5 | describe('Hidden Posts Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/leaders.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/leaders'; 4 | 5 | describe('Leaders Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newest.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/newest'; 4 | 5 | describe('Newest Posts Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newpoll.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/newpoll'; 4 | 5 | describe('New Poll Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/showhn.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/showhn'; 4 | 5 | describe('Show HN Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/shownew.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/shownew'; 4 | 5 | describe('Show New Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/threads.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/threads'; 4 | 5 | describe('Threads Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/formatdoc.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/formatdoc'; 4 | 5 | describe('Format Doc Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/security.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/security'; 4 | 5 | describe('Security Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/bookmarklet.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/bookmarklet'; 4 | 5 | describe('Bookmarklet Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/changepw.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/changepw'; 4 | 5 | describe('Change Password Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newcomments.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/newcomments'; 4 | 5 | describe('New Comments Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newswelcome.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/newswelcome'; 4 | 5 | describe('Welcome Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/submitted.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/submitted'; 4 | 5 | describe('Submitted Posts Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/__main/newpoll.tsx: -------------------------------------------------------------------------------- 1 | import { MainLayout } from '../../layouts/main-layout'; 2 | 3 | export function NewPollPage(): JSX.Element { 4 | return Hacker News API does not publicly provide this data!; 5 | } 6 | 7 | export default NewPollPage; 8 | -------------------------------------------------------------------------------- /src/routes/__notice.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'remix'; 2 | 3 | import ycCss from '../assets/yc.css'; 4 | 5 | export const links = () => [{ rel: 'stylesheet', href: ycCss, type: 'text/css' }]; 6 | 7 | export default function () { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/__tests__/bestcomments.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/bestcomments'; 4 | 5 | describe('Best Comments Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/newsguidelines.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__notice/newsguidelines'; 4 | 5 | describe('Guidelines Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/noobcomments.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | 3 | import Page from '../routes/__main/noobcomments'; 4 | 5 | describe('Noob Comments Page', () => { 6 | it('has default export', () => { 7 | expect(Page).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/server/models/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Models are pure data representations with validation 3 | */ 4 | 5 | export * from './item-model'; 6 | export * from './feed'; 7 | export * from './story-model'; 8 | export * from './user-model'; 9 | export * from './comment-model'; 10 | -------------------------------------------------------------------------------- /src/layouts/blank-layout.tsx: -------------------------------------------------------------------------------- 1 | export interface IBlankLayoutProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export function BlankLayout(props: IBlankLayoutProps): JSX.Element { 6 | const { children } = props; 7 | 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | const { installGlobals } = require('@remix-run/node'); 2 | 3 | // This installs globals such as "fetch", "Response", "Request" and "Headers". 4 | installGlobals(); 5 | 6 | // Fail tests on any warning 7 | // console.error = (message) => { 8 | // throw new Error(message); 9 | // }; 10 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: 'src', 6 | assetsBuildDirectory: 'public/build', 7 | publicPath: '/build/', 8 | serverBuildTarget: 'node-cjs', 9 | devServerPort: 3001, 10 | ignoredRouteFiles: ['.*'], 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/validation/validation-error.ts: -------------------------------------------------------------------------------- 1 | export enum ValidationCode { 2 | ID = 'id', 3 | PASSWORD = 'pw', 4 | } 5 | 6 | export class ValidationError extends Error { 7 | public code: ValidationCode; 8 | 9 | constructor(err) { 10 | super(err.message); 11 | 12 | this.code = err.code; 13 | 14 | Error.captureStackTrace(this, ValidationError); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunction, redirect } from 'remix'; 2 | 3 | import { getSession, destroySession } from '../cookies'; 4 | 5 | export const loader: ActionFunction = async ({ request }) => { 6 | const session = await getSession(request.headers.get('Cookie')); 7 | 8 | return redirect('/login', { 9 | headers: { 'Set-Cookie': await destroySession(session) }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/__main/front.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Front | Hacker News Clone' }; 7 | }; 8 | 9 | export function FrontPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default FrontPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/hidden.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Hidden | Hacker News Clone' }; 7 | }; 8 | 9 | export function HiddenPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default HiddenPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/leaders.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Leaders | Hacker News Clone' }; 7 | }; 8 | 9 | export function LeadersPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default LeadersPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/active.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Active Threads | Hacker News Clone' }; 7 | }; 8 | 9 | export function ActivePage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default ActivePage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/favorites.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Favorites | Hacker News Clone' }; 7 | }; 8 | 9 | export function FavoritesPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default FavoritesPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/upvoted.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Upvoted submissions | Hacker News Clone' }; 7 | }; 8 | 9 | export function UpvotedPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default UpvotedPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/bestcomments.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Best Comments | Hacker News Clone' }; 7 | }; 8 | 9 | export function BestCommentsPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default BestCommentsPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/noobcomments.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Noob Comments | Hacker News Clone' }; 7 | }; 8 | 9 | export function NoobCommentsPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default NoobCommentsPage; 14 | -------------------------------------------------------------------------------- /src/routes/__main/noobstories.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | return { title: 'Noob Submissions | Hacker News Clone' }; 7 | }; 8 | 9 | export function NoobStoriesPage(): JSX.Element { 10 | return Hacker News API does not publicly provide this data!; 11 | } 12 | 13 | export default NoobStoriesPage; 14 | -------------------------------------------------------------------------------- /src/server/responses/item.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The base model for Jobs, Stories, Comments, Polls and Poll Options in the HN API 3 | */ 4 | export interface IItem { 5 | readonly id: number; 6 | 7 | readonly by?: string; 8 | 9 | readonly dead?: boolean; 10 | 11 | readonly deleted?: boolean; 12 | 13 | readonly didUserUpvote: boolean; 14 | 15 | readonly kids?: number[]; 16 | 17 | readonly time?: number; 18 | 19 | readonly type: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/news-page-number.ts: -------------------------------------------------------------------------------- 1 | import { URLSearchParamFields } from './http-handlers'; 2 | 3 | /** 4 | * Page number starts at 1. 5 | * Non-valid page numbers also are resolved to 1. 6 | */ 7 | export function getPageNumberFromSearchParams(searchParams: URLSearchParams): number { 8 | const p = searchParams.get(URLSearchParamFields.PAGE); 9 | const pageNumber: number = +(p || 1); 10 | 11 | return Number.isNaN(pageNumber) ? 1 : pageNumber; 12 | } 13 | -------------------------------------------------------------------------------- /src/cookies.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from 'remix'; 2 | 3 | export enum SessionCookieProperties { 4 | USER_ID = 'userId', 5 | } 6 | 7 | export const { getSession, commitSession, destroySession } = createCookieSessionStorage({ 8 | // a Cookie from `createCookie` or the same CookieOptions to create one 9 | cookie: { 10 | name: '__session', 11 | secrets: ['insecure_example'], 12 | sameSite: 'strict', 13 | maxAge: 604_800, // one week 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/routes/__main/threads.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = ({ location }) => { 6 | const params = new URLSearchParams(location.search); 7 | 8 | return { title: `${params.get('id')}'s comments | Hacker News Clone` }; 9 | }; 10 | 11 | export function ThreadsPage(): JSX.Element { 12 | return Hacker News API does not publicly provide this data!; 13 | } 14 | 15 | export default ThreadsPage; 16 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useLocation, useSearchParams } from 'remix'; 2 | 3 | import { getPageNumberFromSearchParams } from './news-page-number'; 4 | 5 | export function useCurrentPathname(): string { 6 | const loc = useLocation(); 7 | 8 | return loc.pathname; 9 | } 10 | 11 | /** 12 | * Returns the current page number for news feed, default is 0 13 | */ 14 | export function usePageNumber(): number { 15 | const [searchParams] = useSearchParams(); 16 | 17 | return getPageNumberFromSearchParams(searchParams); 18 | } 19 | -------------------------------------------------------------------------------- /src/routes/__main/submitted.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = ({ location }) => { 6 | const params = new URLSearchParams(location.search); 7 | 8 | return { title: `${params.get('id')}'s submissions | Hacker News Clone` }; 9 | }; 10 | 11 | export function SubmittedPage(): JSX.Element { 12 | return Hacker News API does not publicly provide this data!; 13 | } 14 | 15 | export default SubmittedPage; 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | moduleNameMapper: { 4 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 5 | '/src/__mocks__/file-mock.js', 6 | '\\.(css|less)$': '/src/__mocks__/style-mock.js', 7 | }, 8 | preset: 'ts-jest', 9 | setupFilesAfterEnv: ['/jest-setup.js'], 10 | testEnvironment: 'node', 11 | globals: { 12 | 'ts-jest': { 13 | diagnostics: false, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/__main/from.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from 'remix'; 2 | 3 | import { MainLayout } from '../../layouts/main-layout'; 4 | 5 | export const meta: MetaFunction = () => { 6 | const params = new URLSearchParams(location.search); 7 | const site = params.get('site') || 'site'; 8 | 9 | return { title: `Submissions from ${site} | Hacker News Clone` }; 10 | }; 11 | 12 | export function FrontPage(): JSX.Element { 13 | return Hacker News API does not publicly provide this data!; 14 | } 15 | 16 | export default FrontPage; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./src/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/server/responses/user.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | readonly id: string; 3 | 4 | readonly about: string; 5 | 6 | readonly creationTime: number; 7 | 8 | readonly dateOfBirth: number | null; 9 | 10 | readonly email: string | null; 11 | 12 | readonly firstName: string | null; 13 | 14 | readonly hides; 15 | 16 | readonly karma: number; 17 | 18 | readonly lastName: string | null; 19 | 20 | readonly likes; 21 | 22 | readonly posts; 23 | 24 | readonly hashedPassword: string | undefined; 25 | 26 | readonly passwordSalt: string | undefined; 27 | } 28 | -------------------------------------------------------------------------------- /healthcheck.js: -------------------------------------------------------------------------------- 1 | // This file is used to run health checks with Node in the Docker container 2 | 3 | var http = require('http'); 4 | 5 | var options = { 6 | host: 'localhost', 7 | port: '3000', 8 | timeout: 2000, 9 | }; 10 | 11 | var request = http.request(options, (res) => { 12 | console.log(`STATUS: ${res.statusCode}`); 13 | 14 | if (res.statusCode == 200) { 15 | process.exit(0); 16 | } else { 17 | process.exit(1); 18 | } 19 | }); 20 | 21 | request.on('error', function (err) { 22 | console.log('ERROR'); 23 | process.exit(1); 24 | }); 25 | 26 | request.end(); 27 | -------------------------------------------------------------------------------- /src/utils/hash-password.server.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export const createHash = (password: string, salt: string, iterations: number): Promise => { 4 | return new Promise((resolve, reject) => { 5 | const saltBuffer = typeof salt === 'string' ? Buffer.from(salt, 'base64') : salt; 6 | 7 | const callback = (err: Error | null, derivedKey: Buffer): void => 8 | err ? reject(err) : resolve(derivedKey.toString('base64')); 9 | 10 | crypto.pbkdf2(password, saltBuffer, iterations, 512 / 8, 'sha512', callback); 11 | }); 12 | }; 13 | 14 | export const createSalt = (): string => crypto.randomBytes(128).toString('base64'); 15 | -------------------------------------------------------------------------------- /src/server/services/item-service.server.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug'; 2 | 3 | import { ItemModel } from '../models'; 4 | import { HnCache } from '../database/cache'; 5 | import { HnDatabase } from '../database/database'; 6 | 7 | const logger = debug('app:NewsItem'); 8 | logger.log = console.log.bind(console); 9 | 10 | export class ItemService { 11 | db: HnDatabase; 12 | cache: HnCache; 13 | 14 | constructor(db: HnDatabase, cache: HnCache) { 15 | this.db = db; 16 | this.cache = cache; 17 | } 18 | 19 | async upvoteItem(id: number, userId: string): Promise { 20 | return this.db.upvoteItem(id, userId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/comment-box.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import { render } from '@testing-library/react'; 3 | import * as React from 'react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | import { sampleData } from '../server/sample-data'; 7 | import { CommentBox } from './comment-box'; 8 | 9 | describe('CommentBox component', () => { 10 | it('renders at different indentation levels', () => { 11 | const wrapper = render( 12 | 13 | 14 | 15 | ); 16 | 17 | expect(wrapper.baseElement).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/assets/yc.css: -------------------------------------------------------------------------------- 1 | body { font-family:Verdana; font-size:8.5pt; } 2 | td { font-family:Verdana; font-size:8.5pt; line-height:138%; } 3 | blockquote {line-height:122%; } 4 | p { margin-bottom:15px; } 5 | 6 | a:link { color:#222222; } 7 | a:visited { color:#444444; } 8 | b { color:#333333; } 9 | 10 | .foot { font-size:7.5pt; } 11 | .foot a:link, .foot a:visited { text-decoration:none; } 12 | .foot a:hover { text-decoration:underline; } 13 | 14 | .apply a:link { color:#0000ff; font-size:9pt; } 15 | .apply a:visited { color:#0000ff; font-size:9pt; } 16 | 17 | .title { font-size:10pt; } 18 | .title b { color:#000000; } 19 | 20 | .big { margin-top:3ex; } 21 | .small { margin-top:-1.6ex; } 22 | -------------------------------------------------------------------------------- /src/routes/__blank/forgot.tsx: -------------------------------------------------------------------------------- 1 | import { BlankLayout } from '../../layouts/blank-layout'; 2 | 3 | function ForgotPage(): JSX.Element { 4 | return ( 5 | 6 | Reset your password 7 |
8 |
9 |
10 | 11 | 12 | username: 13 |
14 |
15 | 16 |
17 |
18 | ); 19 | } 20 | 21 | export default ForgotPage; 22 | -------------------------------------------------------------------------------- /src/components/news-feed.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import { render } from '@testing-library/react'; 3 | import MockDate from 'mockdate'; 4 | import * as React from 'react'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | 7 | import { sampleData } from '../server/sample-data'; 8 | import { NewsFeed } from './news-feed'; 9 | 10 | MockDate.set(1506022129802); 11 | 12 | describe('NewsFeed component', () => { 13 | it('renders news items passed in as props', () => { 14 | const wrapper = render( 15 | 16 | 17 | 18 | ); 19 | 20 | expect(wrapper.baseElement).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/item-title.spec.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment jsdom */ 2 | import { render } from '@testing-library/react'; 3 | import MockDate from 'mockdate'; 4 | import * as React from 'react'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | 7 | import { sampleData } from '../server/sample-data'; 8 | import { ItemTitle } from './item-title'; 9 | 10 | MockDate.set(1506022129802); 11 | 12 | describe('ItemTitle component', () => { 13 | it('renders news item properties passed in as props', () => { 14 | const wrapper = render( 15 | 16 | 17 | 18 | ); 19 | 20 | expect(wrapper.baseElement).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from 'react-dom/server'; 2 | import { HandleDocumentRequestFunction, RemixServer } from 'remix'; 3 | import type { EntryContext } from 'remix'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config(); 7 | 8 | export const handleRequest: HandleDocumentRequestFunction = ( 9 | request: Request, 10 | responseStatusCode: number, 11 | responseHeaders: Headers, 12 | remixContext: EntryContext 13 | ) => { 14 | const markup = renderToString(); 15 | 16 | responseHeaders.set('Content-Type', 'text/html'); 17 | 18 | return new Response('' + markup, { 19 | status: responseStatusCode, 20 | headers: responseHeaders, 21 | }); 22 | }; 23 | 24 | export default handleRequest; 25 | -------------------------------------------------------------------------------- /src/components/comment-box.tsx: -------------------------------------------------------------------------------- 1 | export interface ICommentBoxProps { 2 | parentId: number; 3 | } 4 | 5 | export function CommentBox(props: ICommentBoxProps): JSX.Element { 6 | const { parentId } = props; 7 | 8 | return ( 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |