├── .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 |
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 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/server/responses/comment.ts:
--------------------------------------------------------------------------------
1 | import { IItem } from './item';
2 |
3 | import { CommentModel } from '../models';
4 |
5 | export interface IComment extends IItem {
6 | readonly comments: IComment[];
7 |
8 | readonly creationTime: number;
9 |
10 | readonly didUserUpvote: boolean;
11 |
12 | readonly parent: number;
13 |
14 | readonly submitterId: string;
15 |
16 | readonly text: string;
17 | }
18 |
19 | export function createResponseComment(
20 | { id, comments, creationTime, upvotes, parent, submitterId, text, type }: CommentModel,
21 | userId: string | undefined
22 | ): IComment {
23 | return {
24 | id,
25 | comments: comments as any,
26 | creationTime,
27 | didUserUpvote: userId ? upvotes.has(userId) : false,
28 | parent,
29 | submitterId,
30 | text,
31 | type,
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/comment.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 { Comment } from './comment';
9 |
10 | const comment = sampleData.comments[0];
11 | // Snapshot will be out of date if we don't use consistent time ago for comment
12 | MockDate.set(1506022129802);
13 |
14 | describe('Comment component', () => {
15 | it('renders at different indentation levels', () => {
16 | const wrapper = render(
17 |
18 |
19 |
20 | );
21 | expect(wrapper.baseElement).toMatchSnapshot();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | export function LoadingSpinner(): JSX.Element {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "hackernews-remix-react"
2 |
3 | kill_signal = "SIGINT"
4 | kill_timeout = 5
5 | processes = []
6 |
7 | [env]
8 | PORT = "8080"
9 |
10 | [experimental]
11 | allowed_public_ports = []
12 | auto_rollback = true
13 |
14 | [[statics]]
15 | guest_path = "/app/public/build"
16 | url_prefix = "/build"
17 |
18 | [[services]]
19 | http_checks = []
20 | internal_port = 8080
21 | processes = ["app"]
22 | protocol = "tcp"
23 | script_checks = []
24 |
25 | [services.concurrency]
26 | hard_limit = 25
27 | soft_limit = 20
28 | type = "connections"
29 |
30 | [[services.ports]]
31 | handlers = ["http"]
32 | port = 80
33 |
34 | [[services.ports]]
35 | handlers = ["tls", "http"]
36 | port = 443
37 |
38 | [[services.tcp_checks]]
39 | grace_period = "1s"
40 | interval = "15s"
41 | restart_limit = 0
42 | timeout = "2s"
43 |
--------------------------------------------------------------------------------
/src/components/item-detail.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 { ItemDetail } from './item-detail';
9 |
10 | const newsItem = sampleData.newsItems[0];
11 | // Snapshot will be out of date if we don't use consistent time ago
12 | // newsItem.creationTime = new Date().valueOf();
13 | MockDate.set(1506022129802);
14 |
15 | describe('ItemDetail component', () => {
16 | it('renders news items passed in as props', () => {
17 | const wrapper = render(
18 |
19 |
20 |
21 | );
22 |
23 | expect(wrapper.baseElement).toMatchSnapshot();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/layouts/notice-layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import yc500Gif from '../../public/static/yc500.gif';
4 |
5 | export interface INoticeLayoutProps {
6 | children: React.ReactNode;
7 | }
8 |
9 | export function NoticeLayout(props: INoticeLayoutProps): JSX.Element {
10 | const { children } = props;
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {children}
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/server/config.server.ts:
--------------------------------------------------------------------------------
1 | /** True if the app is running on the server, false if running on the client */
2 | export const IS_SERVER = typeof window === 'undefined';
3 |
4 | /* SERVER CONFIG */
5 | export const dev = process.env.NODE_ENV !== 'production';
6 | export const appPath = process.env.NODE_ENV === 'production' ? './dist' : './src';
7 |
8 | export const HN_DB_URI = process.env.DB_URI || 'https://hacker-news.firebaseio.com';
9 | export const HN_API_VERSION = process.env.HN_API_VERSION || '/v0';
10 | export const HN_API_URL = process.env.HN_API_URL || `${HN_DB_URI}${HN_API_VERSION}`;
11 |
12 | export const HOST_NAME = process.env.HOST_NAME || 'localhost';
13 | export const APP_PORT = process.env.APP_PORT || 3000;
14 | export const ORIGIN = !IS_SERVER ? window.location.origin : `http://${HOST_NAME}:${APP_PORT}`;
15 | /*
16 | Cryptography
17 | https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2_password_salt_iterations_keylen_digest_callback
18 | */
19 | export const passwordIterations = 10000;
20 |
--------------------------------------------------------------------------------
/src/routes/comment.ts:
--------------------------------------------------------------------------------
1 | import { ActionFunction, redirect } from 'remix';
2 |
3 | import { commentService } from '../server/bootstrap.server';
4 | import { getSession, SessionCookieProperties } from '../cookies';
5 | import { checkBadRequest, checkUnauthorized } from '../utils/http-handlers';
6 |
7 | export const action: ActionFunction = async ({ request }) => {
8 | const session = await getSession(request.headers.get('Cookie'));
9 |
10 | const userId = session.get(SessionCookieProperties.USER_ID) as string;
11 | checkUnauthorized(userId, 'Must be logged in to comment.');
12 |
13 | const formData = await request.formData();
14 | const parentId = +formData.get('parent')! as number;
15 | checkBadRequest(parentId, '"parent" is required.');
16 | const text = formData.get('text') as string;
17 | checkBadRequest(text, '"text" is required.');
18 | const goto = formData.get('goto') as string;
19 |
20 | await commentService.createComment(parentId, userId, text, userId);
21 |
22 | return redirect(goto || `/item?${parentId}`);
23 | };
24 |
--------------------------------------------------------------------------------
/src/server/models/item-model.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The base model for Jobs, Stories, Comments, Polls and Poll Options in the HN API
3 | */
4 | export class ItemModel {
5 | public readonly id: number;
6 |
7 | public readonly by?: string;
8 |
9 | public readonly dead?: boolean;
10 |
11 | public readonly deleted?: boolean;
12 |
13 | public readonly kids?: number[];
14 |
15 | public readonly time?: number;
16 |
17 | public readonly type: string;
18 |
19 | public readonly upvotes: Set;
20 |
21 | constructor(fields: any) {
22 | if (!fields.id) {
23 | throw new Error(`Error instantiating item, id invalid: ${fields.id}`);
24 | } else if (!fields.type) {
25 | throw new Error(`Error instantiating item, type invalid: ${fields.parent}`);
26 | }
27 |
28 | this.id = fields.id;
29 | this.by = fields.by;
30 | this.dead = fields.dead;
31 | this.deleted = fields.deleted;
32 | this.kids = fields.kids;
33 | this.time = fields.time;
34 | this.type = fields.type;
35 | this.upvotes = new Set();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/routes/__main/x.tsx:
--------------------------------------------------------------------------------
1 | import { MainLayout } from '../../layouts/main-layout';
2 |
3 | /** Password recovery email sent page after submitting forgot password */
4 | function PasswordRecoveryPage(): JSX.Element {
5 | return (
6 |
7 | <>
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Password recovery message sent. If you don't see it, you might want to
17 | check your spam folder.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | >
28 |
29 | );
30 | }
31 |
32 | export default PasswordRecoveryPage;
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017, Clinton D'Annolfo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/src/components/__snapshots__/comment-box.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`CommentBox component renders at different indentation levels 1`] = `
4 |
5 |
6 |
7 |
10 |
11 |
15 |
20 |
25 |
30 |
35 |
36 |
37 |
41 |
42 |
43 |
44 |
45 |
46 | `;
47 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/item-detail.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ItemDetail component renders news items passed in as props 1`] = `
4 |
5 |
52 |
53 | `;
54 |
--------------------------------------------------------------------------------
/src/routes/xuser.ts:
--------------------------------------------------------------------------------
1 | import { ActionFunction, redirect } from 'remix';
2 |
3 | import { userService } from '../server/bootstrap.server';
4 | import { checkUnauthorized, checkForbidden, checkBadRequest } from '../utils/http-handlers';
5 | import { getSession, SessionCookieProperties } from '../cookies';
6 |
7 | /**
8 | * xuser endpoint is to update the user information
9 | */
10 | export const action: ActionFunction = async ({ request }) => {
11 | const session = await getSession(request.headers.get('Cookie'));
12 | const formData = await request.formData();
13 |
14 | const currentUserId = session.get(SessionCookieProperties.USER_ID);
15 | checkUnauthorized(currentUserId, 'Must be logged in.');
16 |
17 | const id = formData.get('id') as string;
18 | checkBadRequest(id, '"id" must be provided.');
19 | checkForbidden(
20 | currentUserId && currentUserId === id,
21 | 'User can only update their own profile data.'
22 | );
23 |
24 | const about = formData.get('about') as string;
25 | const email = formData.get('uemail') as string;
26 |
27 | await userService.updateUser({ id, about, email });
28 |
29 | return redirect(`/user?id=${id}`);
30 | };
31 |
--------------------------------------------------------------------------------
/src/routes/__main.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, Outlet, useLoaderData } from 'remix';
2 |
3 | import { getSession, SessionCookieProperties } from '../cookies';
4 | import { userService } from '../server/bootstrap.server';
5 | import { MeContext } from '../utils/context';
6 |
7 | import newsCss from '../assets/news.css';
8 |
9 | export const links = () => [{ rel: 'stylesheet', href: newsCss, type: 'text/css' }];
10 |
11 | export interface IMainLoader {
12 | me: { id: string; karma: number } | undefined;
13 | }
14 | export const loader: LoaderFunction = async (req) => {
15 | const session = await getSession(req.request.headers.get('Cookie'));
16 |
17 | const loggedInUserId = session.get(SessionCookieProperties.USER_ID);
18 |
19 | const loggedInUser = loggedInUserId ? await userService.getUser(loggedInUserId) : undefined;
20 |
21 | const me = loggedInUser && { id: loggedInUser.id, karma: loggedInUser.karma };
22 |
23 | return { me };
24 | };
25 |
26 | export default function Main() {
27 | const { me } = useLoaderData();
28 |
29 | return (
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/layouts/main-layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { Footer } from '../components/footer';
4 | import { Header } from '../components/header';
5 |
6 | interface IMainLayoutProps {
7 | children: React.ReactChild;
8 | isNavVisible?: boolean;
9 | isUserVisible?: boolean;
10 | isFooterVisible?: boolean;
11 | title?: string;
12 | }
13 |
14 | export function MainLayout(props: IMainLayoutProps): JSX.Element {
15 | const { children, isNavVisible = true, isFooterVisible = true, title = 'Hacker News' } = props;
16 |
17 | return (
18 |
19 |
32 |
33 |
34 |
35 | {children}
36 | {isFooterVisible && }
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/routes/hide.ts:
--------------------------------------------------------------------------------
1 | import { ActionFunction, redirect } from 'remix';
2 |
3 | import { getSession, SessionCookieProperties } from '../cookies';
4 | import {
5 | checkBadRequest,
6 | getSearchParamsFromRequest,
7 | URLSearchParamFields,
8 | URLSearchParamHowValue,
9 | } from '../utils/http-handlers';
10 | import { UserLoginErrorCode } from '../utils/user-login-error-code';
11 |
12 | export const loader: ActionFunction = async ({ request }) => {
13 | const session = await getSession(request.headers.get('Cookie'));
14 | const userId = session.get(SessionCookieProperties.USER_ID);
15 |
16 | const searchParams = getSearchParamsFromRequest(request);
17 | const id = searchParams.get(URLSearchParamFields.ID);
18 | const how = searchParams.get(URLSearchParamFields.HOW);
19 | const goto = searchParams.get(URLSearchParamFields.GOTO);
20 |
21 | if (!userId) {
22 | return redirect(`/login?how=${UserLoginErrorCode.LOGIN_UPVOTE}&goto=${goto}`);
23 | }
24 |
25 | checkBadRequest(id, 'item "id" is required to hide.');
26 |
27 | if (how === URLSearchParamHowValue.UNVOTE) {
28 | // Unhide the item
29 | } else {
30 | // Hide the item
31 | }
32 |
33 | return redirect(goto || `/item?id=${id}`);
34 | };
35 |
--------------------------------------------------------------------------------
/src/routes/__main/newcomments.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { MainLayout } from '../../layouts/main-layout';
4 | import { commentService } from '../../server/bootstrap.server';
5 | import { Comments } from '../../components/comments';
6 | import { IComment } from '../../server/responses';
7 | import { getSession, SessionCookieProperties } from '../../cookies';
8 |
9 | export interface INewCommentsPageLoader {
10 | comments: IComment[];
11 | }
12 | export const loader: LoaderFunction = async ({ request }) => {
13 | const session = await getSession(request.headers.get('Cookie'));
14 | const userId = session.get(SessionCookieProperties.USER_ID);
15 |
16 | const comments = await commentService.getNewComments(userId);
17 |
18 | return { comments };
19 | };
20 |
21 | export const meta: MetaFunction = () => {
22 | return { title: 'New Comments | Hacker News Clone' };
23 | };
24 |
25 | export function NewCommentsPage(): JSX.Element {
26 | const { comments } = useLoaderData();
27 |
28 | return (
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default NewCommentsPage;
36 |
--------------------------------------------------------------------------------
/src/assets/dmca.css:
--------------------------------------------------------------------------------
1 | /* THIS CSS FILE DOES NOT EXIST ON THE REAL YCOMBINATOR BUT ONLY AS A SCRIPT TAG */
2 | /* Font Definitions */
3 | @font-face
4 | {font-family:"Courier New";
5 | panose-1:2 7 3 9 2 2 5 2 4 4;}
6 | @font-face
7 | {font-family:Wingdings;
8 | panose-1:5 0 0 0 0 0 0 0 0 0;}
9 | @font-face
10 | {font-family:Wingdings;
11 | panose-1:5 0 0 0 0 0 0 0 0 0;}
12 | @font-face
13 | {font-family:Calibri;
14 | panose-1:2 15 5 2 2 2 4 3 2 4;}
15 | /* Style Definitions */
16 | p.MsoNormal, li.MsoNormal, div.MsoNormal
17 | {margin-top:0in;
18 | margin-right:0in;
19 | margin-bottom:10.0pt;
20 | margin-left:0in;
21 | line-height:115%;
22 | font-size:11.0pt;
23 | font-family:Calibri;}
24 | a:link, span.MsoHyperlink
25 | {color:#444444;
26 | text-decoration:underline;}
27 | a:visited, span.MsoHyperlinkFollowed
28 | {color:purple;
29 | text-decoration:underline;}
30 | .MsoChpDefault
31 | {font-size:11.0pt;
32 | font-family:Calibri;}
33 | .MsoPapDefault
34 | {margin-bottom:10.0pt;
35 | line-height:115%;}
36 | @page WordSection1
37 | {size:8.5in 11.0in;
38 | margin:1.0in 1.0in 1.0in 1.0in;}
39 | div.WordSection1
40 | {page:WordSection1; width:700 px;}
41 | /* List Definitions */
42 | ol
43 | {margin-bottom:0in;}
44 | ul
45 | {margin-bottom:0in;}
46 |
--------------------------------------------------------------------------------
/src/utils/user-login-error-code.ts:
--------------------------------------------------------------------------------
1 | import { USERID_MAX_LENGTH, USERID_MIN_LENGTH } from '../config';
2 |
3 | export enum UserLoginErrorCode {
4 | INCORRECT_PASSWORD = 'pw',
5 | INVALID_ID = 'invalid_id',
6 | LOGGED_IN = 'loggedin',
7 | LOGGED_IN_REGISTER = 'user',
8 | LOGIN_UNSUCCESSFUL = 'unsuccessful',
9 | LOGIN_UPVOTE = 'up',
10 | USERNAME_TAKEN = 'id',
11 | SUBMIT = 'submit',
12 | }
13 |
14 | const userLoginErrorCodeMessages: Record = {
15 | [UserLoginErrorCode.INCORRECT_PASSWORD]: 'Incorrect password.',
16 | [UserLoginErrorCode.INVALID_ID]: `User ID must be between ${USERID_MIN_LENGTH} and ${USERID_MAX_LENGTH} characters.`,
17 | [UserLoginErrorCode.LOGGED_IN]: 'Logged in user must logout before logging in again.',
18 | [UserLoginErrorCode.LOGGED_IN_REGISTER]:
19 | 'Logged in user must logout before registering a new user.',
20 | [UserLoginErrorCode.LOGIN_UNSUCCESSFUL]: 'Login unsuccessful.',
21 | [UserLoginErrorCode.LOGIN_UPVOTE]: 'You have to be logged in to vote.',
22 | [UserLoginErrorCode.USERNAME_TAKEN]: 'Username is taken.',
23 | [UserLoginErrorCode.SUBMIT]: 'You have to be logged in to submit.',
24 | };
25 |
26 | export function getErrorMessageForLoginErrorCode(code: UserLoginErrorCode): string {
27 | return userLoginErrorCodeMessages[code];
28 | }
29 |
--------------------------------------------------------------------------------
/src/utils/validation/user.ts:
--------------------------------------------------------------------------------
1 | import {
2 | PASSWORD_MAX_LENGTH,
3 | PASSWORD_MIN_LENGTH,
4 | USERID_MAX_LENGTH,
5 | USERID_MIN_LENGTH,
6 | } from '../../config';
7 | import { ValidationError, ValidationCode } from './validation-error';
8 |
9 | export function validateUserId(id: string): boolean {
10 | if (id.length < USERID_MIN_LENGTH || id.length > USERID_MAX_LENGTH) {
11 | throw new ValidationError({
12 | code: ValidationCode.ID,
13 | message: `User ID must be between ${USERID_MIN_LENGTH} and ${USERID_MAX_LENGTH} characters.`,
14 | });
15 | }
16 |
17 | return true;
18 | }
19 |
20 | export function validateNewUser({ id, password }: { id: string; password: string }): boolean {
21 | if (id.length < USERID_MIN_LENGTH || id.length > USERID_MAX_LENGTH) {
22 | throw new ValidationError({
23 | code: ValidationCode.ID,
24 | message: `User ID must be between ${USERID_MIN_LENGTH} and ${USERID_MAX_LENGTH} characters.`,
25 | });
26 | }
27 |
28 | if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
29 | throw new ValidationError({
30 | code: ValidationCode.PASSWORD,
31 | message: `User password must be between ${PASSWORD_MIN_LENGTH} and ${PASSWORD_MAX_LENGTH} characters.`,
32 | });
33 | }
34 |
35 | return true;
36 | }
37 |
--------------------------------------------------------------------------------
/src/server/database/feed-updater.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { FeedType } from '../models';
4 | import type { HnCache } from './cache';
5 | import type { HnDatabase } from './database';
6 |
7 | const logger = debug('app:cache-warmer');
8 | logger.log = console.log.bind(console);
9 |
10 | const TWO_MINUTES = 1000 * 60 * 2;
11 |
12 | /**
13 | * Updates the news feed story orders in memory
14 | */
15 | async function updateFeed(db: HnDatabase, cache: HnCache, feedType: FeedType): Promise {
16 | setTimeout(() => updateFeed(db, cache, feedType), TWO_MINUTES);
17 |
18 | try {
19 | const feed = await db.getFeed(feedType);
20 |
21 | if (feed) {
22 | cache[feedType] = feed;
23 | logger('Updated Feed ids for type: ', feedType);
24 | }
25 | } catch (err) {
26 | logger('Error building feed: ', err);
27 | }
28 | }
29 |
30 | export function watchFeeds(db: HnDatabase, cache: HnCache, delay: number): void {
31 | logger('Waiting ms before seeding the app with data:', delay);
32 |
33 | // Delay so we don't spam in dev
34 | setTimeout(() => {
35 | logger('Seeding cache');
36 |
37 | [FeedType.TOP, FeedType.NEW, FeedType.BEST, FeedType.SHOW, FeedType.ASK, FeedType.JOB].forEach(
38 | (feedType) => updateFeed(db, cache, feedType)
39 | );
40 | }, delay);
41 | }
42 |
--------------------------------------------------------------------------------
/src/server/models/comment-model.ts:
--------------------------------------------------------------------------------
1 | export class CommentModel {
2 | public readonly id: number;
3 |
4 | public readonly creationTime: number;
5 |
6 | public readonly comments: number[];
7 |
8 | public readonly parent: number;
9 |
10 | public readonly submitterId: string;
11 |
12 | public readonly upvotes: Set;
13 |
14 | public readonly text: string;
15 |
16 | public readonly type: string;
17 |
18 | constructor(fields: any) {
19 | if (!fields.id) {
20 | throw new Error(`Error instantiating Comment, id invalid: ${fields.id}`);
21 | } else if (!fields.parent) {
22 | throw new Error(`Error instantiating Comment, parent invalid: ${fields.parent}`);
23 | } else if (!fields.submitterId) {
24 | throw new Error(`Error instantiating Comment, submitterId invalid: ${fields.submitterId}`);
25 | } else if (!fields.text) {
26 | throw new Error(`Error instantiating Comment, text invalid: ${fields.text}`);
27 | }
28 |
29 | this.id = fields.id;
30 | this.creationTime = fields.creationTime || +new Date();
31 | this.comments = fields.comments || [];
32 | this.parent = fields.parent;
33 | this.submitterId = fields.submitterId;
34 | this.text = fields.text;
35 | this.type = fields.type;
36 | this.upvotes = fields.upvotes || new Set();
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # base node image
2 | FROM node:20.15-alpine3.19 as base
3 |
4 | # Install all node_modules, including dev dependencies
5 | FROM base as deps
6 | RUN mkdir /app
7 | WORKDIR /app
8 |
9 | ADD package.json package-lock.json ./
10 | RUN npm install --production=false
11 |
12 | # Setup production node_modules
13 | FROM base as production-deps
14 | RUN mkdir /app
15 | WORKDIR /app
16 |
17 | COPY --from=deps /app/node_modules /app/node_modules
18 | ADD package.json package-lock.json ./
19 | RUN npm prune --production
20 |
21 | # Build the app
22 | FROM base as build
23 | ENV NODE_ENV=production
24 | RUN mkdir /app
25 | WORKDIR /app
26 |
27 | COPY --from=deps /app/node_modules /app/node_modules
28 | ADD public ./public
29 | ADD src ./src
30 | ADD package.json package-lock.json remix.config.js remix.env.d.ts tsconfig.json ./
31 | RUN npm run build
32 |
33 | # Finally, build the production image with minimal footprint
34 | FROM base
35 | ENV NODE_ENV=production
36 | ENV PORT=8080
37 | RUN mkdir /app
38 | WORKDIR /app
39 |
40 | COPY --from=production-deps /app/node_modules /app/node_modules
41 | COPY --from=build /app/build /app/build
42 | COPY --from=build /app/public /app/public
43 | COPY package.json package-lock.json healthcheck.js ./
44 |
45 | # Expose the container port to the OS
46 | EXPOSE 8080
47 |
48 | CMD ["npm", "run", "start"]
49 |
--------------------------------------------------------------------------------
/src/routes/vote.ts:
--------------------------------------------------------------------------------
1 | import { ActionFunction, redirect } from 'remix';
2 |
3 | import { itemService } from '../server/bootstrap.server';
4 | import {
5 | checkBadRequest,
6 | getSearchParamsFromRequest,
7 | URLSearchParamFields,
8 | URLSearchParamHowValue,
9 | } from '../utils/http-handlers';
10 | import { UserLoginErrorCode } from '../utils/user-login-error-code';
11 | import { getSession, SessionCookieProperties } from '../cookies';
12 |
13 | /**
14 | * vote endpoint is to vote up a news item or comment
15 | */
16 | export const loader: ActionFunction = async ({ request }) => {
17 | const session = await getSession(request.headers.get('Cookie'));
18 |
19 | const searchParams = getSearchParamsFromRequest(request);
20 | const id = searchParams.get(URLSearchParamFields.ID);
21 | const how = searchParams.get(URLSearchParamFields.HOW);
22 | const goto = searchParams.get(URLSearchParamFields.GOTO);
23 |
24 | const currentUserId = session.get(SessionCookieProperties.USER_ID);
25 | if (!currentUserId) {
26 | return redirect(`/login?how=${UserLoginErrorCode.LOGIN_UPVOTE}goto=${goto}`);
27 | }
28 |
29 | checkBadRequest(id, '"id" must be provided.');
30 |
31 | if (how === URLSearchParamHowValue.UNVOTE) {
32 | // Unvote
33 | return;
34 | } else {
35 | await itemService.upvoteItem(+id, currentUserId);
36 | return redirect(goto || '/');
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/src/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ErrorBoundaryComponent,
3 | Links,
4 | LiveReload,
5 | Meta,
6 | Outlet,
7 | Scripts,
8 | ScrollRestoration,
9 | } from 'remix';
10 | import type { MetaFunction } from 'remix';
11 |
12 | export const meta: MetaFunction = () => {
13 | return { title: 'Hacker News Clone' };
14 | };
15 |
16 | export const ErrorBoundary: ErrorBoundaryComponent = (props) => {
17 | const { error } = props;
18 |
19 | return (
20 |
21 |
22 | Oh no!
23 |
24 |
25 |
26 |
27 | Something went wrong.
28 | {process.env.NODE_ENV === 'development' && {error}
}
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default function App() {
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {process.env.NODE_ENV === 'development' && }
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/routes/register.ts:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant';
2 | import { ActionFunction, redirect } from 'remix';
3 |
4 | import { userService } from '../server/bootstrap.server';
5 | import { getSession, commitSession, destroySession, SessionCookieProperties } from '../cookies';
6 |
7 | export const action: ActionFunction = async ({ request }) => {
8 | const session = await getSession(request.headers.get('Cookie'));
9 |
10 | const currentUserId = session.get(SessionCookieProperties.USER_ID);
11 | if (!currentUserId) {
12 | try {
13 | const formData = await request.formData();
14 | const id = formData.get('id') as string;
15 | const password = formData.get('password') as string;
16 | invariant(id, 'id field is required.');
17 | invariant(password, 'password field is required.');
18 |
19 | await userService.registerUser({ id, password });
20 |
21 | session.set(SessionCookieProperties.USER_ID, id);
22 |
23 | return redirect(`/user?id=${id}`, {
24 | headers: { 'Set-Cookie': await commitSession(session) },
25 | });
26 | } catch (err) {
27 | return redirect(`/login?how=${(err as any).code}`, {
28 | headers: { 'Set-Cookie': await destroySession(session) },
29 | });
30 | }
31 | } else {
32 | return redirect('/login?how=user', {
33 | headers: { 'Set-Cookie': await destroySession(session) },
34 | });
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/src/server/bootstrap.server.ts:
--------------------------------------------------------------------------------
1 | import { initializeApp } from '@firebase/app';
2 | import { getDatabase, ref } from '@firebase/database';
3 |
4 | import { dev, HN_API_VERSION, HN_DB_URI } from './config.server';
5 | import { HnCache } from './database/cache';
6 | import { watchFeeds } from './database/feed-updater';
7 | import { HnDatabase } from './database/database';
8 | import { CommentService } from './services/comment-service.server';
9 | import { FeedService } from './services/feed-service.server';
10 | import { ItemService } from './services/item-service.server';
11 | import { StoryService } from './services/story-service.server';
12 | import { UserService } from './services/user-service.server';
13 |
14 | const FIVE_SECONDS = 1000 * 5;
15 |
16 | // Seed the in-memory data using the HN api
17 | const delay = dev ? FIVE_SECONDS : 0;
18 |
19 | const firebaseApp = initializeApp({ databaseURL: HN_DB_URI });
20 | const firebaseDb = getDatabase(firebaseApp);
21 | const firebaseRef = ref(firebaseDb, HN_API_VERSION);
22 |
23 | const cache = new HnCache();
24 | const db = new HnDatabase(firebaseRef, cache);
25 | watchFeeds(db, cache, delay);
26 |
27 | export const commentService = new CommentService(db, cache);
28 | export const feedService = new FeedService(db, cache);
29 | export const itemService = new ItemService(db, cache);
30 | export const newsItemService = new StoryService(db, cache);
31 | export const userService = new UserService(db, cache);
32 |
--------------------------------------------------------------------------------
/src/server/services/story-service.server.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { StoryModel } 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 | let newPostIdCounter = 100;
11 |
12 | export class StoryService {
13 | db: HnDatabase;
14 | cache: HnCache;
15 |
16 | constructor(db: HnDatabase, cache: HnCache) {
17 | this.db = db;
18 | this.cache = cache;
19 | }
20 |
21 | async getStory(id: number): Promise {
22 | return this.cache.getStory(id) || this.db.getNewsItem(id) || this.db.fetchStory(id);
23 | }
24 |
25 | async getStories(ids: number[]): Promise | void> {
26 | return Promise.all(ids.map((id) => this.getStory(id)))
27 | .then((newsItems) => newsItems.filter((newsItem) => newsItem !== undefined))
28 | .catch((reason) => logger('Rejected News Items:', reason));
29 | }
30 |
31 | async hideStory(id: number, userId: string): Promise {
32 | return this.db.hideNewsItem(id, userId);
33 | }
34 |
35 | async submitStory({ submitterId, title, text, url }): Promise {
36 | const newsItem = new StoryModel({
37 | id: (newPostIdCounter += 1),
38 | submitterId,
39 | text,
40 | title,
41 | url,
42 | });
43 |
44 | return this.db.submitNewsItem(newsItem.id, newsItem);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/server/responses/story.ts:
--------------------------------------------------------------------------------
1 | import { IComment } from '.';
2 | import { StoryModel } from '../models';
3 | import { IItem } from './item';
4 |
5 | export interface IStory extends IItem {
6 | /** Count of comments on the post */
7 | readonly commentCount: number;
8 |
9 | /** List of comments */
10 | readonly comments: IComment[];
11 |
12 | /** Post creation time, number of ms since 1970 */
13 | readonly creationTime: number;
14 |
15 | readonly hiddenCount: number;
16 |
17 | /** ID of user who submitted */
18 | readonly submitterId: string;
19 |
20 | /** Body text */
21 | readonly text: string | null;
22 |
23 | /** Post title */
24 | readonly title: string;
25 |
26 | /** Number of upvotes */
27 | upvoteCount: number;
28 |
29 | readonly url?: string;
30 |
31 | readonly hidden?: boolean; // TODO: exists?
32 |
33 | readonly rank?: number;
34 | }
35 |
36 | export function ClientStory(
37 | {
38 | commentCount,
39 | comments,
40 | creationTime,
41 | hidden,
42 | hiddenCount,
43 | id,
44 | submitterId,
45 | text,
46 | title,
47 | type,
48 | upvoteCount,
49 | upvotes,
50 | url,
51 | }: StoryModel,
52 | userId: string | undefined
53 | ): IStory {
54 | return {
55 | commentCount,
56 | comments,
57 | creationTime,
58 | didUserUpvote: userId ? upvotes.has(userId) : false,
59 | hidden,
60 | hiddenCount,
61 | id,
62 | submitterId,
63 | text,
64 | title,
65 | type,
66 | upvoteCount,
67 | url,
68 | };
69 | }
70 |
--------------------------------------------------------------------------------
/src/server/models/user-model.ts:
--------------------------------------------------------------------------------
1 | import { validateUserId } from '../../utils/validation/user';
2 |
3 | export class UserModel {
4 | public readonly id: string;
5 |
6 | public readonly about: string;
7 |
8 | public readonly creationTime: number;
9 |
10 | public readonly dateOfBirth: number | null;
11 |
12 | public readonly email: string | null;
13 |
14 | public readonly firstName: string | null;
15 |
16 | public readonly hides;
17 |
18 | public readonly karma: number;
19 |
20 | public readonly lastName: string | null;
21 |
22 | public readonly likes;
23 |
24 | public readonly posts;
25 |
26 | public readonly hashedPassword: string | undefined;
27 |
28 | public readonly passwordSalt: string | undefined;
29 |
30 | constructor(props) {
31 | if (!props.id) {
32 | throw new Error(`Error instantiating User, id invalid: ${props.id}`);
33 | }
34 |
35 | validateUserId(props.id);
36 |
37 | this.id = props.id;
38 | this.about = props.about || '';
39 | this.creationTime = props.creationTime || +new Date();
40 | this.dateOfBirth = props.dateOfBirth || null;
41 | this.email = props.email || null;
42 | this.firstName = props.firstName || null;
43 | this.hides = props.hides || [];
44 | this.karma = props.karma || 1;
45 | this.lastName = props.lastName || null;
46 | this.likes = props.likes || [];
47 | this.posts = props.posts || [];
48 | this.hashedPassword = props.hashedPassword || undefined;
49 | this.passwordSalt = props.passwordSalt || undefined;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/utils/is-valid-url.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns whether a string is a valid URL
3 | */
4 | export function isValidStoryUrl(str: string): boolean {
5 | const pattern = new RegExp(
6 | '^' +
7 | // protocol identifier
8 | '(?:(?:https?|ftp)://)' +
9 | // user:pass authentication
10 | '(?:\\S+(?::\\S*)?@)?' +
11 | '(?:' +
12 | // IP address exclusion
13 | // private & local networks
14 | '(?!(?:10|127)(?:\\.\\d{1,3}){3})' +
15 | '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' +
16 | '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
17 | // IP address dotted notation octets
18 | // excludes loopback network 0.0.0.0
19 | // excludes reserved space >= 224.0.0.0
20 | // excludes network & broacast addresses
21 | // (first & last IP address of each class)
22 | '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
23 | '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
24 | '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
25 | '|' +
26 | // host name
27 | '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' +
28 | // domain name
29 | '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' +
30 | // TLD identifier
31 | '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' +
32 | // TLD may end with dot
33 | '\\.?' +
34 | ')' +
35 | // port number
36 | '(?::\\d{2,5})?' +
37 | // resource path
38 | '(?:[/?#]\\S*)?' +
39 | '$',
40 | 'i'
41 | );
42 |
43 | return pattern.test(str);
44 | }
45 |
--------------------------------------------------------------------------------
/src/utils/convert-number-to-time-ago.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts a number to text to show how long ago it was
3 | * eg. 2 years ago. 3 months ago. 16 minutes ago.
4 | */
5 | export const convertNumberToTimeAgo = (number: number): string => {
6 | const now = +new Date();
7 | const timeAgo = now - number;
8 |
9 | const ONE_YEAR = 3.154e10;
10 | const ONE_MONTH = 2.628e9;
11 | // don't care about weeks
12 | const ONE_DAY = 8.64e7;
13 | const ONE_HOUR = 3.6e6;
14 | const ONE_MINUTE = 60000;
15 |
16 | if (timeAgo >= ONE_YEAR * 2) {
17 | return `${Math.floor(timeAgo / ONE_YEAR)} years ago`;
18 | } else if (timeAgo >= ONE_YEAR) {
19 | return 'a year ago';
20 | } else if (timeAgo >= ONE_MONTH * 2) {
21 | return `${Math.floor(timeAgo / ONE_MONTH)} months ago`;
22 | } else if (timeAgo >= ONE_MONTH) {
23 | return '1 month ago';
24 | } else if (timeAgo >= ONE_DAY * 2) {
25 | return `${Math.floor(timeAgo / ONE_DAY)} days ago`;
26 | } else if (timeAgo >= ONE_DAY) {
27 | return '1 day ago';
28 | } else if (timeAgo >= ONE_HOUR * 2) {
29 | return `${Math.floor(timeAgo / ONE_HOUR)} hours ago`;
30 | } else if (timeAgo >= ONE_HOUR) {
31 | return '1 hour ago';
32 | } else if (timeAgo >= ONE_MINUTE * 2) {
33 | return `${Math.floor(timeAgo / ONE_MINUTE)} minutes ago`;
34 | } else if (timeAgo >= 0) {
35 | return '1 minute ago';
36 | } else {
37 | // timeAgo < 0 is in the future
38 | console.error(
39 | `convertNumberToTimeAgo: number ${number} timeAgo ${timeAgo}, is date older than 1970 or in the future?`
40 | );
41 | return '';
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/src/utils/http-handlers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Enum with values for the URL search param fields, which are unique and global across the site
3 | */
4 | export enum URLSearchParamFields {
5 | HOW = 'how',
6 | GOTO = 'goto',
7 | PAGE = 'p',
8 | ID = 'id',
9 | }
10 |
11 | export enum URLSearchParamHowValue {
12 | UPVOTE = 'up',
13 | UNVOTE = 'un',
14 | }
15 |
16 | export function getSearchParamsFromRequest(request: Request) {
17 | return new URL(request.url).searchParams;
18 | }
19 |
20 | function isAsserted(value: any): boolean {
21 | return value === undefined || value === null || value === false;
22 | }
23 |
24 | /**
25 | * Remix Response throwers for CatchBoundary rendering
26 | */
27 |
28 | export function checkBadRequest(value: any, message: string): asserts value {
29 | if (isAsserted(value)) {
30 | throw new Response(message, { status: 400, statusText: 'Bad Request' });
31 | }
32 |
33 | return;
34 | }
35 |
36 | export function checkUnauthorized(value: any, message: string): asserts value {
37 | if (isAsserted(value)) {
38 | throw new Response(message, { status: 401, statusText: 'Not Authorized' });
39 | }
40 |
41 | return;
42 | }
43 |
44 | export function checkForbidden(value: any, message: string): asserts value {
45 | if (isAsserted(value)) {
46 | throw new Response(message, { status: 403, statusText: 'Forbidden' });
47 | }
48 |
49 | return;
50 | }
51 |
52 | export function checkNotFound(value: any, message: string): asserts value {
53 | if (isAsserted(value)) {
54 | throw new Response(message, { status: 404, statusText: 'Not Found' });
55 | }
56 |
57 | return;
58 | }
59 |
--------------------------------------------------------------------------------
/src/routes/__notice/bookmarklet.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function BookmarkletPage(): JSX.Element {
6 | return (
7 |
8 | Bookmarklet
9 |
10 |
11 |
12 |
13 | Thanks to Phil Kast for writing this bookmarklet for submitting links to{' '}
14 | Hacker News
15 | . When you click on the bookmarklet, it will submit the page you're on. To install,
16 | drag this link to your browser toolbar:
17 |
18 |
19 |
20 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export default BookmarkletPage;
49 |
--------------------------------------------------------------------------------
/src/routes/__main/formatdoc.tsx:
--------------------------------------------------------------------------------
1 | import { MetaFunction } from 'remix';
2 |
3 | import { MainLayout } from '../../layouts/main-layout';
4 |
5 | export const meta: MetaFunction = () => {
6 | return { title: 'Formatting Options | Hacker News Clone' };
7 | };
8 |
9 | export function FormatDocPage(): JSX.Element {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Blank lines separate paragraphs.
21 |
22 | Text after a blank line that is indented by two or more spaces is reproduced
23 | verbatim. (This is intended for code.)
24 |
25 |
26 | Text surrounded by asterisks is italicized, if the character after the first
27 | asterisk isn't whitespace.
28 |
29 |
30 | Urls become links, except in the text field of a submission.
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export default FormatDocPage;
49 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/item-title.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ItemTitle component renders news item properties passed in as props 1`] = `
4 |
5 |
66 |
67 | `;
68 |
--------------------------------------------------------------------------------
/src/routes/__main/newest.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, useLoaderData } from 'remix';
2 |
3 | import { feedService } from '../../server/bootstrap.server';
4 | import { FeedType } from '../../server/models';
5 | import { MainLayout } from '../../layouts/main-layout';
6 | import { NewsFeed } from '../../components/news-feed';
7 | import { POSTS_PER_PAGE } from '../../config';
8 | import { usePageNumber } from '../../utils/hooks';
9 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
10 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
11 | import { getSession, SessionCookieProperties } from '../../cookies';
12 | import type { IStory } from '../../server/responses';
13 |
14 | export interface INewestPageLoader {
15 | stories: (void | IStory)[];
16 | }
17 | export const loader: LoaderFunction = async ({ request }): Promise => {
18 | const session = await getSession(request.headers.get('Cookie'));
19 | const userId = session.get(SessionCookieProperties.USER_ID);
20 |
21 | const searchParams = getSearchParamsFromRequest(request);
22 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
23 |
24 | const first = POSTS_PER_PAGE;
25 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
26 |
27 | return { stories: await feedService.getForType(FeedType.NEW, first, skip, userId) };
28 | };
29 |
30 | export const meta = () => ({
31 | title: 'New Links | Hacker News Clone',
32 | });
33 |
34 | export function NewestPage(): JSX.Element {
35 | const { stories } = useLoaderData();
36 | const pageNumber: number = usePageNumber();
37 |
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default NewestPage;
46 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import sGif from '../../public/static/s.gif';
4 |
5 | export function Footer(): JSX.Element {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Guidelines
21 | | FAQ
22 | | Support
23 | | API
24 | | Security
25 | | Lists
26 | | Bookmarklet
27 | | DMCA
28 | | Apply to YC
29 | | Contact
30 |
31 |
32 |
33 |
34 | Search:
35 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/routes/__main/ask.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { NewsFeed } from '../../components/news-feed';
4 | import { POSTS_PER_PAGE } from '../../config';
5 | import { FeedType } from '../../server/models';
6 | import { MainLayout } from '../../layouts/main-layout';
7 | import { feedService } from '../../server/bootstrap.server';
8 | import { usePageNumber } from '../../utils/hooks';
9 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
10 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
11 | import { getSession, SessionCookieProperties } from '../../cookies';
12 | import type { IStory } from '../../server/responses';
13 |
14 | export interface IAskPageLoader {
15 | stories: (IStory | undefined)[];
16 | }
17 | export const loader: LoaderFunction = async ({ request }): Promise => {
18 | const session = await getSession(request.headers.get('Cookie'));
19 | const userId = session.get(SessionCookieProperties.USER_ID);
20 |
21 | const searchParams = getSearchParamsFromRequest(request);
22 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
23 |
24 | const first = POSTS_PER_PAGE;
25 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
26 |
27 | return { stories: await feedService.getForType(FeedType.ASK, first, skip, userId) };
28 | };
29 |
30 | export const meta: MetaFunction = () => {
31 | return { title: 'ask | Hacker News Clone' };
32 | };
33 |
34 | export function AskPage(): JSX.Element {
35 | const { stories } = useLoaderData();
36 | const pageNumber: number = usePageNumber();
37 |
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default AskPage;
46 |
--------------------------------------------------------------------------------
/src/routes/__main/best.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 | import { NewsFeed } from '../../components/news-feed';
3 | import { POSTS_PER_PAGE } from '../../config';
4 | import { FeedType } from '../../server/models';
5 | import { MainLayout } from '../../layouts/main-layout';
6 | import { feedService } from '../../server/bootstrap.server';
7 | import { usePageNumber } from '../../utils/hooks';
8 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
9 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
10 | import { getSession, SessionCookieProperties } from '../../cookies';
11 | import type { IStory } from '../../server/responses';
12 |
13 | export interface IBestPageLoader {
14 | stories: (IStory | void)[];
15 | }
16 | export const loader: LoaderFunction = async ({ request }): Promise => {
17 | const session = await getSession(request.headers.get('Cookie'));
18 | const userId = session.get(SessionCookieProperties.USER_ID);
19 |
20 | const searchParams = getSearchParamsFromRequest(request);
21 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
22 |
23 | const first = POSTS_PER_PAGE;
24 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
25 |
26 | return { stories: await feedService.getForType(FeedType.BEST, first, skip, userId) };
27 | };
28 |
29 | export const meta: MetaFunction = () => {
30 | return { title: 'Top Links | Hacker News Clone' };
31 | };
32 |
33 | export function BestPage(): JSX.Element {
34 | const { stories } = useLoaderData();
35 | const pageNumber: number = usePageNumber();
36 |
37 | return (
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | export default BestPage;
45 |
--------------------------------------------------------------------------------
/src/routes/__main/index.tsx:
--------------------------------------------------------------------------------
1 | import { useLoaderData, LoaderFunction, MetaFunction } from 'remix';
2 |
3 | import { feedService } from '../../server/bootstrap.server';
4 | import { FeedType } from '../../server/models';
5 | import { MainLayout } from '../../layouts/main-layout';
6 | import { NewsFeed } from '../../components/news-feed';
7 | import { POSTS_PER_PAGE } from '../../config';
8 | import { usePageNumber } from '../../utils/hooks';
9 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
10 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
11 | import { getSession, SessionCookieProperties } from '../../cookies';
12 | import type { IStory } from '../../server/responses';
13 |
14 | export interface IIndexPageLoader {
15 | stories: (IStory | void)[];
16 | }
17 | export const loader: LoaderFunction = async ({ request }): Promise => {
18 | const session = await getSession(request.headers.get('Cookie'));
19 | const userId = session.get(SessionCookieProperties.USER_ID);
20 |
21 | const searchParams = getSearchParamsFromRequest(request);
22 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
23 |
24 | const first = POSTS_PER_PAGE;
25 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
26 |
27 | return { stories: await feedService.getForType(FeedType.TOP, first, skip, userId) };
28 | };
29 |
30 | export const meta: MetaFunction = () => {
31 | return {
32 | description: 'The top stories from technology and startup business hackers around the world.',
33 | };
34 | };
35 |
36 | export default function IndexPage(): JSX.Element {
37 | const { stories } = useLoaderData();
38 | const pageNumber: number = usePageNumber();
39 |
40 | return (
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/routes/__main/item.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { commentService, newsItemService } from '../../server/bootstrap.server';
4 | import { ItemWithComments } from '../../components/item-with-comments';
5 | import { MainLayout } from '../../layouts/main-layout';
6 | import {
7 | checkNotFound,
8 | checkBadRequest,
9 | getSearchParamsFromRequest,
10 | URLSearchParamFields,
11 | } from '../../utils/http-handlers';
12 | import type { StoryModel } from '../../server/models';
13 | import { getSession, SessionCookieProperties } from '../../cookies';
14 |
15 | export interface IItemPageLoader {
16 | newsItem: StoryModel;
17 | }
18 | export const loader: LoaderFunction = async ({ request }): Promise => {
19 | const searchParams = getSearchParamsFromRequest(request);
20 | const newsItemId = searchParams.get(URLSearchParamFields.ID);
21 | checkBadRequest(newsItemId, '"id" is required.');
22 |
23 | const newsItem = await newsItemService.getStory(+newsItemId);
24 | checkNotFound(newsItem, 'NewsItem not found');
25 |
26 | const session = await getSession(request.headers.get('Cookie'));
27 | const userId = session.get(SessionCookieProperties.USER_ID);
28 |
29 | const comments = await commentService.getCommentTree(newsItem.comments, userId);
30 | newsItem.comments = comments;
31 |
32 | return { newsItem };
33 | };
34 |
35 | export const meta: MetaFunction = ({ data }) => {
36 | if (data) {
37 | return { title: `${(data as IItemPageLoader).newsItem.title} | Hacker News Clone` };
38 | }
39 |
40 | return { title: 'Story not found | Hacker News Clone' };
41 | };
42 |
43 | export function ItemPage(): JSX.Element {
44 | const { newsItem } = useLoaderData();
45 |
46 | return (
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | export default ItemPage;
54 |
--------------------------------------------------------------------------------
/src/components/item-title.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useLocation } from 'remix';
2 |
3 | export interface IItemTitleProps {
4 | id: number;
5 | isRankVisible?: boolean;
6 | isUpvoteVisible?: boolean;
7 | rank?: number;
8 | title: string;
9 | url: string | undefined;
10 | upvoted: boolean;
11 | }
12 |
13 | export function ItemTitle(props: IItemTitleProps): JSX.Element {
14 | const { id, isRankVisible = true, isUpvoteVisible = true, rank, title, upvoted, url } = props;
15 |
16 | const loc = useLocation();
17 |
18 | const hostname: string | undefined = url ? new URL(url).hostname : undefined;
19 |
20 | return (
21 |
22 |
23 | {isRankVisible && `${rank}.`}
24 |
25 |
26 |
27 | {isUpvoteVisible && (
28 |
33 |
34 |
35 | )}
36 |
37 |
38 |
39 | {url ? (
40 | <>
41 |
42 | {title}
43 |
44 |
45 | {' '}
46 | (
47 |
48 | {hostname}
49 |
50 | )
51 |
52 | >
53 | ) : (
54 |
55 | {title}
56 |
57 | )}
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/item-with-comments.tsx:
--------------------------------------------------------------------------------
1 | import { StoryModel } from '../server/models';
2 | import { CommentBox } from './comment-box';
3 | import { Comments } from './comments';
4 | import { ItemDetail } from './item-detail';
5 | import { ItemTitle } from './item-title';
6 |
7 | export interface INewsItemWithCommentsProps {
8 | loading?: boolean;
9 | newsItem: StoryModel;
10 | }
11 |
12 | /** Acts as the component for a page of a news item with all it's comments */
13 | export function ItemWithComments(props: INewsItemWithCommentsProps): JSX.Element {
14 | const {
15 | newsItem: {
16 | commentCount,
17 | comments,
18 | creationTime,
19 | id,
20 | rank,
21 | submitterId,
22 | title,
23 | upvoteCount,
24 | url,
25 | },
26 | } = props;
27 |
28 | return (
29 |
30 |
31 |
40 |
41 |
49 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/header-links.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | interface IHeaderNavProps {
4 | userId?: string;
5 | currentUrl: string;
6 | isNavVisible: boolean;
7 | title: string;
8 | }
9 |
10 | export function HeaderLinks(props: IHeaderNavProps): JSX.Element {
11 | const { userId, currentUrl, isNavVisible, title } = props;
12 |
13 | return isNavVisible ? (
14 |
15 |
16 | {title}
17 |
18 |
19 | {userId && (
20 | <>
21 | welcome
22 | {' | '}
23 | >
24 | )}
25 |
26 | new
27 |
28 | {userId && (
29 | <>
30 | {' | '}
31 |
36 | threads
37 |
38 | >
39 | )}
40 | {' | '}
41 |
46 | comments
47 |
48 | {' | '}
49 |
50 | show
51 |
52 | {' | '}
53 |
54 | ask
55 |
56 | {' | '}
57 |
58 | jobs
59 |
60 | {' | '}
61 |
62 | submit
63 |
64 | {currentUrl === '/best' && (
65 | <>
66 | {' | '}
67 |
68 | best
69 |
70 | >
71 | )}
72 |
73 | ) : (
74 |
75 | {title}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/routes/__main/lists.tsx:
--------------------------------------------------------------------------------
1 | import { Link, MetaFunction } from 'remix';
2 |
3 | import { MainLayout } from '../../layouts/main-layout';
4 |
5 | export const meta: MetaFunction = () => {
6 | return { title: 'Lists | Hacker News Clone' };
7 | };
8 |
9 | export function ListsPage(): JSX.Element {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | leaders
19 |
20 | Users with most karma.
21 |
22 |
23 |
24 | front
25 |
26 |
27 | Front page submissions for a given day (e.g.{' '}
28 | 2016-06-20 ), ordered by time spent there.
29 |
30 |
31 |
32 |
33 | best
34 |
35 | Highest-voted recent links.
36 |
37 |
38 |
39 | active
40 |
41 | Most active current discussions.
42 |
43 |
44 |
45 | bestcomments
46 |
47 | Highest-voted recent comments.
48 |
49 |
50 |
51 | noobstories
52 |
53 | Submissions from new accounts.
54 |
55 |
56 |
57 | noobcomments
58 |
59 | Comments from new accounts.
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
69 | export default ListsPage;
70 |
--------------------------------------------------------------------------------
/src/server/models/story-model.ts:
--------------------------------------------------------------------------------
1 | import { isValidStoryUrl } from '../../utils/is-valid-url';
2 |
3 | let newPostIdCounter = 100;
4 |
5 | export class StoryModel {
6 | /** News item ID */
7 | public readonly id: number;
8 |
9 | /** Count of comments on the post */
10 | public readonly commentCount: number;
11 |
12 | /** List of comments */
13 | public readonly comments;
14 |
15 | /** Post creation time, number of ms since 1970 */
16 | public readonly creationTime: number;
17 |
18 | /** IDs of users who hid the post */
19 | public readonly hides: string[];
20 |
21 | public readonly hiddenCount: number;
22 |
23 | /** ID of user who submitted */
24 | public readonly submitterId: string;
25 |
26 | /** Body text */
27 | public readonly text: string | null;
28 |
29 | /** Post title */
30 | public readonly title: string;
31 |
32 | /** Number of upvotes */
33 | public upvoteCount: number;
34 |
35 | public readonly upvotes: Set;
36 |
37 | public readonly url?: string;
38 |
39 | public readonly hidden?: boolean; // TODO: exists?
40 |
41 | public readonly rank?: number;
42 |
43 | public readonly type: string;
44 |
45 | constructor(fields) {
46 | if (!fields.id) {
47 | throw new Error(`Error instantiating News Item, id is required: ${fields.id}`);
48 | } else if (!fields.submitterId) {
49 | throw new Error(`Error instantiating News Item, submitterId is required: ${fields.id}`);
50 | } else if (!fields.title) {
51 | throw new Error(`Error instantiating News Item, title is required: ${fields.id}`);
52 | } else if (fields.url && !isValidStoryUrl(fields.url)) {
53 | throw new Error(`Error instantiating News Item ${fields.id}, invalid URL: ${fields.url}`);
54 | }
55 |
56 | this.id = fields.id || (newPostIdCounter += 1);
57 | this.commentCount = fields.commentCount || 0;
58 | this.comments = fields.comments || [];
59 | this.creationTime = fields.creationTime || +new Date();
60 | this.hides = fields.hides || [];
61 | this.hiddenCount = this.hides.length;
62 | this.submitterId = fields.submitterId;
63 | this.text = fields.text || null;
64 | this.title = fields.title;
65 | this.type = fields.type;
66 | this.upvoteCount = fields.upvoteCount || 1;
67 | this.upvotes = fields.upvotes || new Set([fields.submitterId]);
68 | this.url = fields.url;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hackernews",
3 | "version": "0.1.0",
4 | "description": "A Hacker News clone built to demonstrate Remix and React for production ready websites.",
5 | "engines": {
6 | "node": ">=14.16.0"
7 | },
8 | "scripts": {
9 | "build": "npm run clean && remix build",
10 | "build:docker": "docker build --tag 'clintonwoo/hackernews-remix-react:latest' --rm . && docker run --rm -p 8080:8080 --name hackernews-remix-react clintonwoo/hackernews-remix-react",
11 | "build:prod": "NODE_ENV=production npm run clean && remix build",
12 | "check": "tsc --noEmit",
13 | "clean": "rm -rf build && rm -rf public/build",
14 | "dev": "NODE_ENV=production remix dev",
15 | "debug:prod": "DEBUG=app:* NODE_ENV=production npm run dev",
16 | "lint": "eslint \"src/**/*.ts?(x)\" --ext .js,.jsx,.ts,.tsx",
17 | "postinstall": "remix setup node",
18 | "prettier:check": "prettier --check .",
19 | "prettier:format": "prettier --write .",
20 | "start": "remix-serve build",
21 | "start:prod": "NODE_ENV=production remix-serve build",
22 | "test": "jest"
23 | },
24 | "author": "Clinton D'Annolfo",
25 | "license": "MIT",
26 | "keywords": [
27 | "hacker-news",
28 | "clone",
29 | "react",
30 | "remix"
31 | ],
32 | "dependencies": {
33 | "@firebase/app": "0.7.16",
34 | "@firebase/database": "0.12.5",
35 | "@remix-run/react": "1.2.0",
36 | "@remix-run/serve": "1.2.0",
37 | "debug": "4.3.3",
38 | "dotenv": "16.0.0",
39 | "invariant": "2.2.4",
40 | "lru-cache": "6.0.0",
41 | "react": "17.0.2",
42 | "react-dom": "17.0.2",
43 | "remix": "1.2.0"
44 | },
45 | "devDependencies": {
46 | "@remix-run/dev": "1.2.0",
47 | "@remix-run/eslint-config": "1.2.0",
48 | "@testing-library/jest-dom": "5.16.2",
49 | "@testing-library/react": "12.1.2",
50 | "@types/debug": "4.1.7",
51 | "@types/invariant": "2.2.35",
52 | "@types/jest": "27.4.0",
53 | "@types/node": "17.0.16",
54 | "@types/react": "17.0.39",
55 | "@types/react-dom": "17.0.11",
56 | "eslint": "8.8.0",
57 | "jest": "27.5.1",
58 | "mockdate": "3.0.5",
59 | "prettier": "2.5.1",
60 | "react-router-dom": "6.2.1",
61 | "react-test-renderer": "17.0.2",
62 | "ts-jest": "27.1.3",
63 | "ts-node": "10.5.0",
64 | "tslint-config-prettier": "1.18.0",
65 | "typescript": "4.5.5"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/server/services/user-service.server.ts:
--------------------------------------------------------------------------------
1 | import { passwordIterations } from '../config.server';
2 | import { StoryModel, UserModel } from '../models';
3 | import { validateNewUser } from '../../utils/validation/user';
4 | import { createHash, createSalt } from '../../utils/hash-password.server';
5 | import type { HnCache } from '../database/cache';
6 | import type { HnDatabase } from '../database/database';
7 |
8 | export class UserService {
9 | db: HnDatabase;
10 | cache: HnCache;
11 |
12 | constructor(db: HnDatabase, cache: HnCache) {
13 | this.db = db;
14 | this.cache = cache;
15 | }
16 |
17 | async getUser(id: string): Promise {
18 | return this.cache.getUser(id) || this.db.fetchUser(id);
19 | }
20 |
21 | async getPostsForUser(id: string): Promise {
22 | return this.db.getNewsItems().filter((newsItem) => newsItem.submitterId === id);
23 | }
24 |
25 | async validatePassword(id: string, password: string): Promise {
26 | const user = this.cache.getUser(id);
27 | if (user) {
28 | return (
29 | (await createHash(password, user.passwordSalt!, passwordIterations)) === user.hashedPassword
30 | );
31 | }
32 |
33 | return false;
34 | }
35 |
36 | async registerUser(user: { id: string; password: string }): Promise {
37 | // Check if user is valid
38 | validateNewUser(user);
39 |
40 | // Check if user already exists
41 | if (this.cache.getUser(user.id)) {
42 | throw new Error('Username is taken.');
43 | }
44 |
45 | // Go ahead and create the new user
46 | const passwordSalt = createSalt();
47 | const hashedPassword = await createHash(user.password, passwordSalt, passwordIterations);
48 |
49 | const newUser = new UserModel({
50 | hashedPassword,
51 | id: user.id,
52 | passwordSalt,
53 | });
54 |
55 | // Store the new user
56 | this.cache.setUser(user.id, newUser);
57 |
58 | return newUser;
59 | }
60 |
61 | async updateUser(user: { id: string; about: string; email: string }): Promise {
62 | const foundUser = this.cache.getUser(user.id);
63 | if (!foundUser) {
64 | throw new Error('User not found.');
65 | }
66 |
67 | if (user.email) foundUser.email = user.email;
68 | if (user.about) foundUser.about = user.about;
69 |
70 | this.cache.setUser(user.id, foundUser);
71 |
72 | return foundUser;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 | import { useContext } from 'react';
3 |
4 | import { ICurrentLoggedInUser, MeContext } from '../utils/context';
5 | import { useCurrentPathname } from '../utils/hooks';
6 | import { HeaderLinks } from './header-links';
7 |
8 | import y18Gif from '../../public/static/y18.gif';
9 |
10 | export interface IHeaderProps {
11 | isNavVisible: boolean;
12 | title: string;
13 | }
14 |
15 | export function Header(props: IHeaderProps): JSX.Element {
16 | const { isNavVisible, title } = props;
17 |
18 | const currentUrl = useCurrentPathname();
19 | const me = useContext(MeContext);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {me ? (
46 |
47 | {me.id}
48 | {` (${me.karma}) | `}
49 |
52 | logout
53 |
54 |
55 | ) : (
56 |
57 | login
58 |
59 | )}
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/routes/__main/show.tsx:
--------------------------------------------------------------------------------
1 | import { Link, LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { NewsFeed } from '../../components/news-feed';
4 | import { POSTS_PER_PAGE } from '../../config';
5 | import { FeedType } from '../../server/models';
6 | import { MainLayout } from '../../layouts/main-layout';
7 | import { feedService } from '../../server/bootstrap.server';
8 | import { usePageNumber } from '../../utils/hooks';
9 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
10 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
11 | import { getSession, SessionCookieProperties } from '../../cookies';
12 | import type { IStory } from '../../server/responses';
13 |
14 | export interface IShowPageLoader {
15 | stories: (void | IStory)[];
16 | }
17 | export const loader: LoaderFunction = async ({ request }): Promise => {
18 | const session = await getSession(request.headers.get('Cookie'));
19 | const userId = session.get(SessionCookieProperties.USER_ID);
20 |
21 | const searchParams = getSearchParamsFromRequest(request);
22 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
23 |
24 | const first = POSTS_PER_PAGE;
25 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
26 |
27 | return { stories: await feedService.getForType(FeedType.SHOW, first, skip, userId) };
28 | };
29 |
30 | export const meta: MetaFunction = () => {
31 | return { title: 'Show | Hacker News Clone' };
32 | };
33 |
34 | export function ShowHNPage(): JSX.Element {
35 | const { stories } = useLoaderData();
36 | const pageNumber: number = usePageNumber();
37 |
38 | return (
39 |
40 |
46 |
47 |
48 |
49 |
50 | Please read the{' '}
51 |
52 | rules
53 |
54 | . You can also browse the{' '}
55 |
56 | newest
57 | {' '}
58 | Show HNs.
59 |
60 |
61 |
62 | >
63 | }
64 | />
65 |
66 | );
67 | }
68 |
69 | export default ShowHNPage;
70 |
--------------------------------------------------------------------------------
/src/routes/__main/shownew.tsx:
--------------------------------------------------------------------------------
1 | import { Link, LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { NewsFeed } from '../../components/news-feed';
4 | import { POSTS_PER_PAGE } from '../../config';
5 | import { FeedType } from '../../server/models';
6 | import { MainLayout } from '../../layouts/main-layout';
7 | import { feedService } from '../../server/bootstrap.server';
8 | import { usePageNumber } from '../../utils/hooks';
9 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
10 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
11 | import { getSession, SessionCookieProperties } from '../../cookies';
12 | import type { IStory } from '../../server/responses';
13 |
14 | export interface IShowNewPageLoader {
15 | stories: (void | IStory)[];
16 | }
17 | export const loader: LoaderFunction = async ({ request }): Promise => {
18 | const session = await getSession(request.headers.get('Cookie'));
19 | const userId = session.get(SessionCookieProperties.USER_ID);
20 |
21 | const searchParams = getSearchParamsFromRequest(request);
22 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
23 |
24 | const first = POSTS_PER_PAGE;
25 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
26 |
27 | return { stories: await feedService.getForType(FeedType.SHOW, first, skip, userId) };
28 | };
29 |
30 | export const meta: MetaFunction = () => {
31 | return { title: 'New Show | Hacker News Clone' };
32 | };
33 |
34 | export function ShowNewPage(): JSX.Element {
35 | const { stories } = useLoaderData();
36 | const pageNumber: number = usePageNumber();
37 |
38 | return (
39 |
40 |
46 |
47 |
48 |
49 |
50 | Please read the{' '}
51 |
52 | rules
53 |
54 | . You can also browse the{' '}
55 |
56 | newest
57 | {' '}
58 | Show HNs.
59 |
60 |
61 |
62 | >
63 | }
64 | />
65 |
66 | );
67 | }
68 |
69 | export default ShowNewPage;
70 |
--------------------------------------------------------------------------------
/src/routes/__notice/showhn.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function ShowHNRulesPage(): JSX.Element {
6 | return (
7 |
8 | Show HN
9 |
10 |
11 | Show HN is a way to share something that you've made on Hacker News.
12 |
13 | The current Show HNs can be found via show in the top bar, and the
14 | newest are here. To post one, simply{' '}
15 | submit a story whose title begins with "Show HN".
16 |
17 |
18 | What to Submit
19 |
20 |
21 | Show HN is for something you've made that other people can play with. HN users can try it
22 | out, give you feedback, and ask questions in the thread.
23 |
24 |
25 | A Show HN needn't be complicated or look slick. The community is comfortable with work
26 | that's at an early stage.
27 |
28 |
29 | If your work isn't ready for people to try out yet, please don't do a Show HN. Once it's
30 | ready, come back and do it then.
31 |
32 |
33 | Blog posts, sign-up pages, and fundraisers can't be tried out, so they can't be Show HNs.
34 |
35 |
36 | New features and upgrades ("Foo 1.3.1 is out") generally aren't substantive enough to be
37 | Show HNs. A major overhaul is probably ok.
38 |
39 |
40 | In Comments
41 |
42 | Be respectful. Anyone sharing work is making a contribution, however modest.
43 | Ask questions out of curiosity. Don't cross-examine.
44 |
45 | Instead of "you're doing it wrong", suggest alternatives. When someone is learning, help
46 | them learn more.
47 |
48 |
49 | When something isn't good, you needn't pretend that it is. But don't be gratuitously
50 | negative.
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export default ShowHNRulesPage;
74 |
--------------------------------------------------------------------------------
/src/components/item-detail.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { convertNumberToTimeAgo } from '../utils/convert-number-to-time-ago';
4 |
5 | export interface IItemDetailProps {
6 | commentCount: number;
7 | creationTime: number;
8 | hidden?: boolean;
9 | id: number;
10 | isFavoriteVisible?: boolean;
11 | isJobListing?: boolean;
12 | isPostScrutinyVisible?: boolean;
13 | submitterId: string;
14 | upvoteCount: number;
15 | }
16 |
17 | const HIDE_BUTTON_STYLE = { cursor: 'pointer' };
18 |
19 | export function ItemDetail(props: IItemDetailProps): JSX.Element {
20 | const {
21 | commentCount,
22 | creationTime,
23 | hidden,
24 | id,
25 | isFavoriteVisible = true,
26 | isJobListing = false,
27 | isPostScrutinyVisible = false,
28 | submitterId,
29 | upvoteCount,
30 | } = props;
31 |
32 | return isJobListing ? (
33 |
34 |
35 |
36 |
37 | {convertNumberToTimeAgo(creationTime)}
38 |
39 |
40 |
41 | ) : (
42 |
43 |
44 |
45 | {upvoteCount} points
46 | {' by '}
47 |
48 | {submitterId}
49 | {' '}
50 |
51 | {convertNumberToTimeAgo(creationTime)}
52 |
53 | {' | '}
54 | {hidden ? (
55 |
56 | unhide
57 |
58 | ) : (
59 |
60 | hide
61 |
62 | )}
63 | {isPostScrutinyVisible && (
64 |
65 | {' | '}
66 |
67 | past
68 |
69 | {' | '}
70 | web
71 |
72 | )}
73 | {' | '}
74 |
75 | {commentCount === 0
76 | ? 'discuss'
77 | : commentCount === 1
78 | ? '1 comment'
79 | : `${commentCount} comments`}
80 |
81 | {isFavoriteVisible && ' | favorite'}
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/routes/__main/jobs.tsx:
--------------------------------------------------------------------------------
1 | import { NewsFeed } from '../../components/news-feed';
2 | import { MainLayout } from '../../layouts/main-layout';
3 | import { FeedType } from '../../server/models';
4 | import { POSTS_PER_PAGE } from '../../config';
5 | import { feedService } from '../../server/bootstrap.server';
6 | import { LoaderFunction, MetaFunction, useLoaderData } from 'remix';
7 | import { usePageNumber } from '../../utils/hooks';
8 | import { getSearchParamsFromRequest } from '../../utils/http-handlers';
9 | import { getPageNumberFromSearchParams } from '../../utils/news-page-number';
10 | import { getSession, SessionCookieProperties } from '../../cookies';
11 | import type { IStory } from '../../server/responses';
12 |
13 | import sGif from '../../../public/static/s.gif';
14 |
15 | export interface IJobsPageLoader {
16 | stories: (IStory | void)[];
17 | }
18 | export const loader: LoaderFunction = async ({ request }): Promise => {
19 | const session = await getSession(request.headers.get('Cookie'));
20 | const userId = session.get(SessionCookieProperties.USER_ID);
21 |
22 | const searchParams = getSearchParamsFromRequest(request);
23 | const pageNumber: number = getPageNumberFromSearchParams(searchParams);
24 |
25 | const first = POSTS_PER_PAGE;
26 | const skip = POSTS_PER_PAGE * (pageNumber - 1);
27 |
28 | return {
29 | stories: await feedService.getForType(FeedType.JOB, first, skip, userId),
30 | };
31 | };
32 |
33 | export const meta: MetaFunction = () => {
34 | return { title: 'jobs | Hacker News Clone' };
35 | };
36 |
37 | export function JobsPage(): JSX.Element {
38 | const { stories } = useLoaderData();
39 | const pageNumber: number = usePageNumber();
40 |
41 | return (
42 |
43 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | These are jobs at startups that were funded by Y Combinator. You can also get a job
60 | at a YC startup through{' '}
61 |
62 | Triplebyte
63 |
64 | .
65 |
66 |
67 |
68 | >
69 | }
70 | />
71 |
72 | );
73 | }
74 |
75 | export default JobsPage;
76 |
--------------------------------------------------------------------------------
/src/components/comments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { IComment } from '../server/responses';
4 | import { Comment } from './comment';
5 |
6 | function countChildrenComments(comments: IComment[]): number {
7 | return (
8 | comments.length +
9 | comments.reduce((count, comment) => {
10 | if (comment.comments) count += countChildrenComments(comment.comments);
11 | return count;
12 | }, 0)
13 | );
14 | }
15 |
16 | /**
17 | * Recursively flattens tree into flat array and calls the callback on each item
18 | */
19 | function renderCommentTreeAsFlatArray(
20 | array: any[],
21 | comments: IComment[],
22 | level: number,
23 | shouldIndent: boolean,
24 | collapsedComments: ICollapsedComments,
25 | toggleCollapseComment: (id: number) => void
26 | ): React.ReactNode {
27 | for (const comment of comments) {
28 | if (typeof comment === 'number') continue;
29 |
30 | const isCollapsed = collapsedComments[comment.id];
31 | const children = comment.comments;
32 |
33 | array.push(
34 |
45 | );
46 |
47 | if (!isCollapsed && Array.isArray(children) && children.length > 0) {
48 | renderCommentTreeAsFlatArray(
49 | array,
50 | children,
51 | level + (shouldIndent ? 1 : 0),
52 | shouldIndent,
53 | collapsedComments,
54 | toggleCollapseComment
55 | );
56 | }
57 | }
58 |
59 | return array;
60 | }
61 |
62 | export interface ICommentsProps {
63 | comments: IComment[];
64 | shouldIndent: boolean;
65 | }
66 |
67 | export interface ICollapsedComments {
68 | [key: number]: boolean;
69 | }
70 |
71 | export function Comments(props: ICommentsProps): JSX.Element {
72 | const { comments, shouldIndent } = props;
73 |
74 | const [collapsedComments, setCollapsedComments] = React.useState({});
75 |
76 | const toggleCollapseComment = React.useCallback(
77 | (id: number) => {
78 | setCollapsedComments({ ...collapsedComments, [id]: !collapsedComments[id] });
79 | },
80 | [collapsedComments, setCollapsedComments]
81 | );
82 |
83 | return (
84 |
85 |
86 | {renderCommentTreeAsFlatArray(
87 | [],
88 | comments,
89 | 0,
90 | shouldIndent,
91 | collapsedComments,
92 | toggleCollapseComment
93 | )}
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/server/services/comment-service.server.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { createResponseComment, IComment } from '../responses';
4 | import type { HnCache } from '../database/cache';
5 | import type { HnDatabase } from '../database/database';
6 |
7 | const logger = debug('app:Comment');
8 | logger.log = console.log.bind(console);
9 |
10 | export class CommentService {
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 getComment(id: number, userId: string | undefined): Promise {
20 | const dbComment = await (this.cache.getComment(id) ||
21 | this.db.fetchComment(id).catch((reason) => logger('Rejected comment:', reason)));
22 |
23 | return dbComment ? createResponseComment(dbComment, userId) : undefined;
24 | }
25 |
26 | async getComments(ids: number[], userId: string | undefined): Promise | void> {
27 | return Promise.all(ids.map((commentId) => this.getComment(commentId, userId)))
28 | .then((comments): IComment[] =>
29 | comments.filter((comment): comment is IComment => comment !== undefined)
30 | )
31 | .catch((reason) => logger('Rejected comments:', reason));
32 | }
33 |
34 | async getCommentTree(ids: number[], userId: string | undefined): Promise | void> {
35 | return Promise.all(
36 | ids.map(async (commentId) => {
37 | if (Number.isNaN(Number(commentId))) {
38 | return commentId; // commentId is already resolved to comment previously
39 | }
40 |
41 | const comment = await this.getComment(commentId, userId);
42 |
43 | if (comment?.comments?.length) {
44 | comment.comments = await this.getCommentTree(comment.comments, userId);
45 | }
46 |
47 | return comment;
48 | })
49 | )
50 | .then((comments): IComment[] =>
51 | comments.filter((comment): comment is IComment => comment !== undefined)
52 | )
53 | .catch((reason) => logger('Rejected comments:', reason));
54 | }
55 |
56 | async getNewComments(userId: string | undefined): Promise {
57 | return this.cache.newComments.map((comment) =>
58 | createResponseComment({ ...comment, comments: [] }, userId)
59 | );
60 | }
61 |
62 | async createComment(
63 | parent: number,
64 | submitterId: string,
65 | text: string,
66 | userId: string
67 | ): Promise {
68 | const comment = await this.db.createComment(parent, submitterId, text);
69 |
70 | this.cache.setComment(comment.id, comment);
71 |
72 | const parentComment = this.cache.getComment(comment.parent);
73 | if (parentComment) {
74 | parentComment.comments.push(comment.id);
75 | this.cache.setComment(parentComment.id, parentComment);
76 | }
77 |
78 | return comment ? createResponseComment(comment, userId) : undefined;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/utils/convert-number-to-time-ago.spec.ts:
--------------------------------------------------------------------------------
1 | import { convertNumberToTimeAgo } from './convert-number-to-time-ago';
2 |
3 | const ONE_YEAR = 3.154e10;
4 | const ONE_MONTH = 2.628e9;
5 | // don't care about weeks
6 | const ONE_DAY = 8.64e7;
7 | const ONE_HOUR = 3.6e6;
8 | const ONE_MINUTE = 60000;
9 |
10 | describe('convert-number-to-time-ago helper function', () => {
11 | it('accepts negative numbers (date older than 1970)', () => {
12 | const now = new Date();
13 | const sixtyYearsAgo = new Date(now.valueOf() - ONE_YEAR * 60);
14 | expect(sixtyYearsAgo.valueOf()).toBeLessThan(0);
15 | expect(convertNumberToTimeAgo(sixtyYearsAgo.valueOf())).toMatch('60 years ago');
16 | });
17 | it('outputs multiple years', () => {
18 | const now = new Date();
19 | const threeYearsAgo = new Date(now.valueOf() - ONE_YEAR * 3);
20 | expect(convertNumberToTimeAgo(threeYearsAgo.valueOf())).toMatch('3 years ago');
21 | });
22 | it('outputs one year', () => {
23 | const now = new Date();
24 | const oneYearAgo = new Date(now.valueOf() - ONE_YEAR);
25 | expect(convertNumberToTimeAgo(oneYearAgo.valueOf())).toMatch('a year ago');
26 | });
27 | it('outputs multiple months', () => {
28 | const now = new Date();
29 | const threeMonthsAgo = new Date(now.valueOf() - ONE_MONTH * 3);
30 | expect(convertNumberToTimeAgo(threeMonthsAgo.valueOf())).toMatch('3 months');
31 | });
32 | it('outputs one month', () => {
33 | const now = new Date();
34 | const oneMonthAgo = new Date(now.valueOf() - ONE_MONTH);
35 | expect(convertNumberToTimeAgo(oneMonthAgo.valueOf())).toMatch('1 month ago');
36 | });
37 | it('outputs multiple days', () => {
38 | const now = new Date();
39 | const threeDaysAgo = new Date(now.valueOf() - ONE_DAY * 3);
40 | expect(convertNumberToTimeAgo(threeDaysAgo.valueOf())).toMatch('3 days ago');
41 | });
42 | it('outputs one day', () => {
43 | const now = new Date();
44 | const oneDayAgo = new Date(now.valueOf() - ONE_DAY);
45 | expect(convertNumberToTimeAgo(oneDayAgo.valueOf())).toMatch('1 day ago');
46 | });
47 | it('outputs multiple hours', () => {
48 | const now = new Date();
49 | const threeHoursAgo = new Date(now.valueOf() - ONE_HOUR * 3);
50 | expect(convertNumberToTimeAgo(threeHoursAgo.valueOf())).toMatch('3 hours ago');
51 | });
52 | it('outputs one hour', () => {
53 | const now = new Date();
54 | const oneHourAgo = new Date(now.valueOf() - ONE_HOUR);
55 | expect(convertNumberToTimeAgo(oneHourAgo.valueOf())).toMatch('1 hour ago');
56 | });
57 | it('outputs multiple minutes', () => {
58 | const now = new Date();
59 | const threeMinutesAgo = new Date(now.valueOf() - ONE_MINUTE * 3);
60 | expect(convertNumberToTimeAgo(threeMinutesAgo.valueOf())).toMatch('3 minutes ago');
61 | });
62 | it('outputs one minute', () => {
63 | const now = new Date();
64 | const oneMinuteAgo = new Date(now.valueOf() - ONE_MINUTE);
65 | expect(convertNumberToTimeAgo(oneMinuteAgo.valueOf())).toMatch('1 minute ago');
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/server/services/feed-service.server.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { FeedType } from '../models';
4 | import { sampleData } from '../sample-data';
5 | import { HnCache } from '../database/cache';
6 | import { HnDatabase } from '../database/database';
7 | import { ClientStory, IStory } from '../responses';
8 |
9 | const logger = debug('app:Feed');
10 | logger.log = console.log.bind(console);
11 |
12 | export class FeedService {
13 | db: HnDatabase;
14 | cache: HnCache;
15 |
16 | constructor(db: HnDatabase, cache: HnCache) {
17 | this.db = db;
18 | this.cache = cache;
19 | }
20 |
21 | public async getForType(
22 | type: FeedType,
23 | first: number,
24 | skip: number,
25 | userId: string | undefined
26 | ): Promise> {
27 | logger('Get first', first, type, 'stories skip', skip);
28 |
29 | switch (type) {
30 | case FeedType.TOP: {
31 | // In this app the HN data is reconstructed in-memory
32 | const topStories = await Promise.all(
33 | this.cache.top
34 | .slice(skip, first + skip)
35 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
36 | );
37 |
38 | return topStories.map((id) => (id ? ClientStory(id, userId) : undefined));
39 | }
40 |
41 | case FeedType.NEW: {
42 | const newStories = await Promise.all(
43 | this.cache.new
44 | .slice(skip, first + skip)
45 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
46 | );
47 |
48 | return newStories.map((id) => (id ? ClientStory(id, userId) : undefined));
49 | }
50 |
51 | case FeedType.BEST: {
52 | const bestStories = await Promise.all(
53 | this.cache.best
54 | .slice(skip, first + skip)
55 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
56 | );
57 |
58 | return bestStories.map((id) => (id ? ClientStory(id, userId) : undefined));
59 | }
60 |
61 | case FeedType.SHOW: {
62 | const showStories = await Promise.all(
63 | this.cache.show
64 | .slice(skip, first + skip)
65 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
66 | );
67 |
68 | return showStories.map((id) => (id ? ClientStory(id, userId) : undefined));
69 | }
70 |
71 | case FeedType.ASK: {
72 | const askStories = await Promise.all(
73 | this.cache.ask
74 | .slice(skip, first + skip)
75 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
76 | );
77 |
78 | return askStories.map((id) => (id ? ClientStory(id, userId) : undefined));
79 | }
80 |
81 | case FeedType.JOB: {
82 | const jobStories = await Promise.all(
83 | this.cache.job
84 | .slice(skip, first + skip)
85 | .map((id) => this.cache.getStory(id) || this.db.fetchStory(id))
86 | );
87 |
88 | return jobStories.map((id) => (id ? ClientStory(id, userId) : undefined));
89 | }
90 |
91 | default:
92 | return sampleData.newsItems
93 | .slice(skip, skip + first)
94 | .map((id) => (id ? ClientStory(id, userId) : undefined));
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/server/database/cache.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 | import LRU from 'lru-cache';
3 |
4 | import { StoryModel, UserModel, CommentModel, FeedType, ItemModel } from '../models';
5 | import { sampleData } from '../sample-data';
6 |
7 | const logger = debug('app:Cache');
8 | logger.log = console.log.bind(console);
9 |
10 | const STORY_MAX_AGE = 60 * 1000; // 60 seconds in milliseconds
11 |
12 | // The cache is a singleton
13 |
14 | export class HnCache {
15 | isReady = false;
16 |
17 | /* Feeds - Arrays of post ids in descending rank order */
18 | [FeedType.TOP]: number[] = sampleData.top;
19 |
20 | [FeedType.NEW]: number[] = sampleData.new;
21 |
22 | [FeedType.BEST]: number[] = [];
23 |
24 | [FeedType.SHOW]: number[] = [];
25 |
26 | [FeedType.ASK]: number[] = [];
27 |
28 | [FeedType.JOB]: number[] = [];
29 |
30 | newComments: CommentModel[] = sampleData.comments;
31 |
32 | /* BEGIN NEWS ITEMS */
33 |
34 | getStory(id: number): StoryModel | undefined {
35 | return this.itemCache.get(id.toString()) as StoryModel | undefined;
36 | }
37 |
38 | setStory(id: number, newsItem: StoryModel): boolean {
39 | return this.itemCache.set(id.toString(), newsItem as ItemModel, STORY_MAX_AGE);
40 | }
41 |
42 | /* END NEWS ITEMS */
43 |
44 | /* BEGIN USERS */
45 |
46 | getUser(id: string): UserModel | undefined {
47 | return this.userCache.get(id);
48 | }
49 |
50 | getUsers(): Array> {
51 | return this.userCache.dump();
52 | }
53 |
54 | setUser(id: string, user: UserModel): UserModel {
55 | logger('Cache set user:', user);
56 |
57 | this.userCache.set(id, user);
58 |
59 | return user;
60 | }
61 |
62 | /* END USERS */
63 |
64 | /* BEGIN COMMENTS */
65 |
66 | getComment(id: number): CommentModel | undefined {
67 | return this.itemCache.get(id.toString()) as CommentModel | undefined;
68 | }
69 |
70 | setComment(id: number, comment: CommentModel): CommentModel {
71 | this.itemCache.set(id.toString(), comment);
72 |
73 | logger('Cache set comment:', comment);
74 |
75 | return comment;
76 | }
77 |
78 | /* END COMMENTS */
79 |
80 | /* BEGIN BASE ITEM */
81 |
82 | getItem(id: number): ItemModel | undefined {
83 | return this.itemCache.get(id.toString()) as ItemModel | undefined;
84 | }
85 |
86 | setItem(id: number, item: ItemModel): ItemModel {
87 | this.itemCache.set(id.toString(), item);
88 |
89 | logger('Cache set item:', item);
90 |
91 | return item;
92 | }
93 |
94 | /* END BASE ITEM */
95 |
96 | /* BEGIN CACHES */
97 |
98 | userCache = new LRU({
99 | max: 500,
100 | ttl: 1000 * 60 * 60 * 2, // 2 hour cache: ms * s * m
101 | });
102 |
103 | /** Jobs, Stories, Comments, Polls and Poll Options are "items" in the HN API */
104 | itemCache = new LRU({
105 | max: 20_000,
106 | ttl: 1000 * 60 * 5, // 5 min cache: ms * s * m
107 | });
108 |
109 | /* END CACHES */
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/comment.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'remix';
3 |
4 | import { convertNumberToTimeAgo } from '../utils/convert-number-to-time-ago';
5 |
6 | import sGif from '../../public/static/s.gif';
7 |
8 | export interface ICommentProps {
9 | collapsedChildrenCommentsCount: number | undefined;
10 | creationTime: number;
11 | id: number;
12 | indentationLevel: number;
13 | isCollapsed: boolean;
14 | submitterId: string;
15 | text: string;
16 | toggleCollapseComment: (id: number) => void;
17 | }
18 |
19 | export function Comment(props: ICommentProps): JSX.Element {
20 | const {
21 | creationTime,
22 | collapsedChildrenCommentsCount,
23 | id,
24 | indentationLevel,
25 | isCollapsed,
26 | submitterId,
27 | text,
28 | toggleCollapseComment,
29 | } = props;
30 |
31 | const collapseComment = React.useCallback(() => {
32 | toggleCollapseComment(id);
33 | }, [toggleCollapseComment, id]);
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 |
49 |
50 |
58 |
59 |
60 |
61 |
62 |
63 | {submitterId}
64 |
65 |
66 | {' '}
67 | {convertNumberToTimeAgo(creationTime)}
68 | {' '}
69 |
70 | {' '}
71 |
72 | {isCollapsed
73 | ? `[${
74 | collapsedChildrenCommentsCount
75 | ? `${collapsedChildrenCommentsCount + 1} more`
76 | : '+'
77 | }] `
78 | : '[-]'}
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | {!isCollapsed && (
87 | <>
88 |
89 |
90 |
91 | reply
92 |
93 |
94 | >
95 | )}
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/comment.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Comment component renders at different indentation levels 1`] = `
4 |
5 |
6 |
10 |
11 |
14 |
15 |
16 |
19 |
25 |
26 |
30 |
43 |
44 |
47 |
50 |
53 |
57 | megous
58 |
59 |
62 |
63 |
66 |
67 |
68 |
71 |
74 |
75 |
79 | [-]
80 |
81 |
84 |
85 |
86 |
87 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | `;
121 |
--------------------------------------------------------------------------------
/src/components/news-feed.tsx:
--------------------------------------------------------------------------------
1 | import { useCurrentPathname } from '../utils/hooks';
2 | import type { IStory } from '../server/responses';
3 | import { LoadingSpinner } from './loading-spinner';
4 | import { ItemDetail } from './item-detail';
5 | import { ItemTitle } from './item-title';
6 |
7 | export interface INewsFeedProps {
8 | error?: any;
9 | isJobListing?: boolean;
10 | isLoading?: boolean;
11 | isPostScrutinyVisible?: boolean;
12 | isRankVisible?: boolean;
13 | isUpvoteVisible?: boolean;
14 | stories: Array | void;
15 | notice?: JSX.Element;
16 | pageNumber: number;
17 | postsPerPage: number;
18 | }
19 |
20 | export function NewsFeed(props: INewsFeedProps): JSX.Element {
21 | const {
22 | error,
23 | isJobListing = false,
24 | isLoading,
25 | isPostScrutinyVisible = false,
26 | isRankVisible = true,
27 | isUpvoteVisible = true,
28 | notice = null,
29 | pageNumber,
30 | postsPerPage,
31 | stories,
32 | } = props;
33 |
34 | const currentPathname = useCurrentPathname();
35 |
36 | if (error) {
37 | return (
38 |
39 | Error loading news items.
40 |
41 | );
42 | }
43 |
44 | if (isLoading) {
45 | return ;
46 | }
47 |
48 | if (!stories?.length) {
49 | return (
50 |
51 | No stories found.
52 |
53 | );
54 | }
55 |
56 | const nextPage = pageNumber + 1;
57 |
58 | return (
59 |
60 |
61 |
70 |
71 | {notice && notice}
72 | <>
73 | {stories
74 | .filter((newsItem): newsItem is IStory => !!newsItem && !newsItem.hidden)
75 | .flatMap((newsItem, index) => [
76 | ,
86 | ,
98 | ,
99 | ])}
100 |
101 |
102 |
103 |
104 |
110 | More
111 |
112 |
113 |
114 | >
115 |
116 |
117 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/src/routes/__notice/newswelcome.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function NewsWelcomePage(): JSX.Element {
6 | return (
7 |
8 | Welcome to Hacker News
9 |
10 |
11 |
12 | Hacker News is a bit different from other community sites, and we'd
13 | appreciate it if you'd take a minute to read the following as well as the{' '}
14 | official guidelines.
15 |
16 |
17 | HN is an experiment. As a rule, a community site that becomes popular will decline in
18 | quality. Our hypothesis is that this is not inevitable—that by making a conscious effort to
19 | resist decline, we can keep it from happening.
20 |
21 |
22 | Essentially there are two rules here: don't post or upvote crap links, and don't be
23 | rude or dumb in comment threads.
24 |
25 |
26 | A crap link is one that's only superficially interesting. Stories on HN don't have
27 | to be about hacking, because good hackers aren't only interested in hacking, but they do
28 | have to be deeply interesting.
29 |
30 |
31 | What does "deeply interesting" mean? It means stuff that teaches you about the
32 | world. A story about a robbery, for example, would probably not be deeply interesting. But
33 | if this robbery was a sign of some bigger, underlying trend, perhaps it could be.
34 |
35 |
36 | The worst thing to post or upvote is something that's intensely but shallowly
37 | interesting: gossip about famous people, funny or cute pictures or videos, partisan
38 | political articles, etc. If you let{' '}
39 |
40 | that sort of thing onto a news site, it will push aside the deeply interesting stuff,
41 | which tends to be quieter.
42 |
43 |
44 |
45 | The most important principle on HN, though, is to make thoughtful comments. Thoughtful in
46 | both senses: civil and substantial.
47 |
48 |
49 | The test for substance is a lot like it is for links. Does your comment teach us anything?
50 | There are two ways to do that: by pointing out some consideration that hadn't previously
51 | been mentioned, and by giving more information about the topic, perhaps from personal
52 | experience. Whereas comments like "LOL!" or worse still, "That's
53 | retarded!" teach us nothing.
54 |
55 |
56 | Empty comments can be ok if they're positive. There's nothing wrong with submitting
57 | a comment saying just "Thanks." What we especially discourage are comments that
58 | are empty and negative—comments that are mere name-calling.
59 |
60 |
61 | Which brings us to the most important principle on HN: civility. Since long before the web,
62 | the anonymity of online conversation has lured people into being much ruder than they'd
63 | be in person. So the principle here is: don't say anything you wouldn't say face to
64 | face. This doesn't mean you can't disagree. But disagree without calling names. If
65 | you're right, your argument will be more convincing without them.
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | export default NewsWelcomePage;
88 |
--------------------------------------------------------------------------------
/src/routes/__main/reply.tsx:
--------------------------------------------------------------------------------
1 | import { Link, LoaderFunction, MetaFunction, useLoaderData } from 'remix';
2 |
3 | import { getSession, SessionCookieProperties } from '../../cookies';
4 | import { commentService } from '../../server/bootstrap.server';
5 | import type { IComment } from '../../server/responses';
6 | import {
7 | checkBadRequest,
8 | getSearchParamsFromRequest,
9 | URLSearchParamFields,
10 | } from '../../utils/http-handlers';
11 | import { MainLayout } from '../../layouts/main-layout';
12 |
13 | export interface IReplyPageLoader {
14 | comment: IComment;
15 | }
16 | export const loader: LoaderFunction = async ({ request }) => {
17 | const searchParams = getSearchParamsFromRequest(request);
18 | const commentId = +searchParams.get(URLSearchParamFields.ID)!;
19 | checkBadRequest(commentId, '"id" is required.');
20 |
21 | const session = await getSession(request.headers.get('Cookie'));
22 | const userId = session.get(SessionCookieProperties.USER_ID);
23 |
24 | const comment = await commentService.getComment(commentId, userId);
25 |
26 | return { comment };
27 | };
28 |
29 | export const meta: MetaFunction = () => {
30 | return { title: 'Add Comment | Hacker News Clone' };
31 | };
32 |
33 | function ReplyPage(): JSX.Element {
34 | const { comment } = useLoaderData();
35 |
36 | return (
37 |
38 |
39 |
40 |
109 |
110 |
111 |
112 | );
113 | }
114 |
115 | export default ReplyPage;
116 |
--------------------------------------------------------------------------------
/src/routes/__main/submit.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundaryComponent, Link, LoaderFunction, MetaFunction, useActionData } from 'remix';
2 | import { ActionFunction, Form, redirect } from 'remix';
3 |
4 | import { newsItemService } from '../../server/bootstrap.server';
5 | import { MainLayout } from '../../layouts/main-layout';
6 | import { getSession, SessionCookieProperties } from '../../cookies';
7 | import { isValidStoryUrl } from '../../utils/is-valid-url';
8 |
9 | export const loader: LoaderFunction = async ({ request }) => {
10 | const session = await getSession(request.headers.get('Cookie'));
11 | const currentUserId = session.get(SessionCookieProperties.USER_ID);
12 | if (!currentUserId) {
13 | return redirect('/login?how=submit&goto=submit');
14 | }
15 |
16 | return null;
17 | };
18 |
19 | export const meta: MetaFunction = () => {
20 | return { title: 'Submit | Hacker News Clone' };
21 | };
22 |
23 | export interface ISubmitPageAction {
24 | title?: boolean;
25 | textOrUrl?: boolean;
26 | text?: boolean;
27 | url?: boolean;
28 | }
29 | export const action: ActionFunction = async ({ request }) => {
30 | const session = await getSession(request.headers.get('Cookie'));
31 | const submitterId = session.get(SessionCookieProperties.USER_ID);
32 | if (!submitterId) {
33 | return redirect('/login?how=submit&goto=submit');
34 | }
35 |
36 | const formData = await request.formData();
37 | const title = formData.get('title');
38 | const text = formData.get('text');
39 | const url = formData.get('url');
40 |
41 | const errors: ISubmitPageAction = {};
42 | if (!title) errors.title = true;
43 | if (!text && !url) errors.textOrUrl = true;
44 | if (url && !isValidStoryUrl(url as string)) errors.url = true;
45 |
46 | if (Object.keys(errors).length) {
47 | return errors;
48 | }
49 |
50 | const newsItem = await newsItemService.submitStory({ submitterId, title, text, url });
51 |
52 | return redirect(`/item?id=${newsItem.id}`);
53 | };
54 |
55 | function SubmitPage(): JSX.Element {
56 | const actionData = useActionData();
57 |
58 | return (
59 |
60 |
61 |
62 |
63 |
64 |
65 | {actionData?.textOrUrl && URL or Text is required.
}
66 | {actionData?.url && URL is not correctly formatted.
}
67 |
121 |
122 |
123 |
124 |
125 | );
126 | }
127 |
128 | export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
129 | return (
130 |
131 |
Something went wrong.
132 |
{error.message}
133 |
134 | );
135 | };
136 |
137 | export default SubmitPage;
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Hacker News Clone Remix/React
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | This is a clone of Hacker News written in TypeScript using Remix and React.
11 |
12 | It is intended to be an example or boilerplate to help you structure your projects using production-ready technologies.
13 |
14 | The project implements the publicly available parts of the Hacker News site API, with some remaining functionality implemented in-memory.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Live Demo
23 |
24 |
25 | ## Overview
26 |
27 | ### Featuring
28 |
29 | - Remix (Server side rendering framework)
30 | - React (Declarative UI)
31 | - ESBuild (via Remix, sub-second production builds)
32 | - TypeScript (Static typing)
33 | - ESLint (Code checking)
34 | - Authentication via Cookies (plain JS)
35 | - Jest (Test runner)
36 | - Prettier (Code formatter)
37 | - Docker (Container builder)
38 |
39 | ### Benefits
40 |
41 | **UI**
42 |
43 | - Website works with JavaScript disabled (`Remix`)
44 | - Nested routes allow building complex UI applications that are also SSR capable (`Remix`)
45 | - Data fetching for client and server uses the same loader code (`Remix`)
46 | - Most apps can be built leveraging web fundamentals (form/anchor tag) requiring no state management library (`Remix`)
47 | - Data management is simple resulting in smaller codebase and client JS bundle sizes, approx ~30-50% smaller for this HN clone project compared to the GraphQL HN clone (`Remix`)
48 | - Declarative UI (`React`)
49 | - Minimalistic client-side UI rendering (`Remix`)
50 | - Pre-fetch page assets (`Remix`)
51 | - JS Code splitting (`Remix`)
52 | - Loading state spinners not required by default (`Remix`)
53 |
54 | **Server**
55 |
56 | - Server Side rendering (`Remix`)
57 | - Universal TypeScript/JavaScript (`Web standards`)
58 | - Server can build a single JS file for optimized deployments (`Remix`)
59 | - Deployable on FaaS (Functions as a Service), edge workers or on your own NodeJS server (`Remix`)
60 | - Asset bundler (`ESBuild` via `Remix`)
61 |
62 | **Dev/Test**
63 |
64 | - Hot module reloading (`remix`)
65 | - Snapshot testing (`Jest`)
66 | - JS/TS best practices (`eslint`)
67 |
68 | ## Architecture overview
69 |
70 |
71 |
72 |
73 |
74 | Remix emphasises web primitives and fundamentals. So requests are made generally using Remix's ` `s and ``s which add some extra functionality on top of the regular `` and `` tags.
75 |
76 | Remix `routes` folder correlates to route-matching UI views with layouts and endpoints for GET (loader) or all other HTTP verb methods (action). You can have endpoints with no UI and UI with no endpoints, or mixed.
77 |
78 | Remix takes care of the build system (using ESBuild), which works incredibly quickly and is a pleasure to work with. Remix implements code-splitting, HTTP headers, asset bundling and various optimizations to make the site run fast in real-world scenarios.
79 |
80 | Remix can run in a number of runtime environments so the architecture for your app could be widely different depending on your requirements. You could deploy it on an edge network (like Cloudflare Workers) or run it with NodeJS inside a cloud VM or VPS, for example.
81 |
82 | ### How to build and start
83 |
84 | Start with `npm install`.
85 |
86 | You can build and start with file watching using `npm run dev`.
87 |
88 | Or you can do a regular build and start using `npm run build` and `npm run start`.
89 |
90 | ### One Command Setup & Run
91 |
92 | You can download and run the repo with one command to rule them all:
93 |
94 | `git clone https://github.com/clintonwoo/hackernews-remix-react.git && cd hackernews-remix-react && npm install && npm run build && npm run dev`
95 |
96 | ## Contributing
97 |
98 | File an issue for ideas, conversation or feedback. Pull requests are welcome.
99 |
100 | ## Community
101 |
102 | After you ★Star this project, follow [@ClintonDAnnolfo](https://twitter.com/clintondannolfo) on Twitter.
103 |
--------------------------------------------------------------------------------
/src/routes/__notice/newsguidelines.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function NewsGuidelinesPage(): JSX.Element {
6 | return (
7 |
8 | Hacker News Guidelines
9 |
10 |
11 | What to Submit
12 |
13 | On-Topic: Anything that good hackers would find interesting. That includes more than hacking
14 | and startups. If you had to reduce it to a sentence, the answer might be: anything that
15 | gratifies one's intellectual curiosity.
16 |
17 |
18 | Off-Topic: Most stories about politics, or crime, or sports, unless they're evidence of
19 | some interesting new phenomenon. Ideological or political battle or talking points. Videos
20 | of pratfalls or disasters, or cute animal pictures. If they'd cover it on TV news,
21 | it's probably off-topic.
22 |
23 |
24 | In Submissions
25 |
26 |
27 | Please don't do things to make titles stand out, like using uppercase or exclamation
28 | points, or adding a parenthetical remark saying how great an article is. It's implicit
29 | in submitting something that you think it's important.
30 |
31 |
32 | If you submit a link to a video or pdf, please warn us by appending [video] or [pdf] to the
33 | title.
34 |
35 |
36 | Please submit the original source. If a post reports on something found on another site,
37 | submit the latter.
38 |
39 |
40 | If the original title includes the name of the site, please take it out, because the site
41 | name will be displayed after the link.
42 |
43 |
44 | If the original title begins with a number or number + gratuitous adjective, we'd
45 | appreciate it if you'd crop it. E.g. translate "10 Ways To Do X" to "How
46 | To Do X," and "14 Amazing Ys" to "Ys." Exception: when the number
47 | is meaningful, e.g. "The 5 Platonic Solids."
48 |
49 | Otherwise please use the original title, unless it is misleading or linkbait.
50 |
51 | Please don't post on HN to ask or tell us something. Instead, please send it to
52 | hn@ycombinator.com. Similarly, please don't use HN posts to ask YC-funded companies
53 | questions that you could ask by emailing them.
54 |
55 |
56 | Please don't submit so many links at once that the new page is dominated by your
57 | submissions.
58 |
59 |
60 | In Comments
61 |
62 |
63 | Be civil. Don't say things you wouldn't say face-to-face. Don't be snarky.
64 | Comments should get more civil and substantive, not less, as a topic gets more divisive.
65 |
66 |
67 | When disagreeing, please reply to the argument instead of calling names. "That is
68 | idiotic; 1 + 1 is 2, not 3" can be shortened to "1 + 1 is 2, not 3."
69 |
70 |
71 | Please respond to the strongest plausible interpretation of what someone says, not a weaker
72 | one that's easier to criticize.
73 |
74 |
75 | Eschew flamebait. Don't introduce flamewar topics unless you have something genuinely
76 | new to say. Avoid unrelated controversies and generic tangents.
77 |
78 |
79 | Please don't insinuate that someone hasn't read an article. "Did you even read
80 | the article? It mentions that" can be shortened to "The article mentions
81 | that."
82 |
83 |
84 | Please don't use uppercase for emphasis. If you want to emphasize a word or phrase, put
85 | *asterisks* around it and it will get italicized.
86 |
87 |
88 | Please don't accuse others of astroturfing or shillage. Email us instead and we'll
89 | look into it.
90 |
91 |
92 | Please don't complain that a submission is inappropriate. If a story is spam or
93 | off-topic, flag it. Don't feed egregious comments by replying;{' '}
94 | flag them instead. When you flag something, please
95 | don't also comment that you did.
96 |
97 |
98 | Please don't comment about the voting on comments. It never does any good, and it makes
99 | boring reading.
100 |
101 |
102 | Throwaway accounts are ok for sensitive information, but please don't create them
103 | routinely. On HN, users need an identity that others can relate to.
104 |
105 |
106 | We ban accounts that use Hacker News primarily for political or ideological battle,
107 | regardless of which politics they favor.
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | );
127 | }
128 |
129 | export default NewsGuidelinesPage;
130 |
--------------------------------------------------------------------------------
/src/routes/__blank/login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { ActionFunction, Form, redirect, Link, LoaderFunction, json, useSearchParams } from 'remix';
3 |
4 | import { validateNewUser } from '../../utils/validation/user';
5 | import {
6 | getErrorMessageForLoginErrorCode,
7 | UserLoginErrorCode,
8 | } from '../../utils/user-login-error-code';
9 | import { BlankLayout } from '../../layouts/blank-layout';
10 | import { commitSession, getSession, SessionCookieProperties } from '../../cookies';
11 | import { userService } from '../../server/bootstrap.server';
12 | import { URLSearchParamFields } from '../../utils/http-handlers';
13 | import {
14 | PASSWORD_MAX_LENGTH,
15 | PASSWORD_MIN_LENGTH,
16 | USERID_MAX_LENGTH,
17 | USERID_MIN_LENGTH,
18 | } from '../../config';
19 |
20 | export const loader: LoaderFunction = async ({ request }) => {
21 | const session = await getSession(request.headers.get('Cookie'));
22 |
23 | const data = { error: session.get('error') };
24 |
25 | return json(data, {
26 | headers: {
27 | 'Set-Cookie': await commitSession(session),
28 | },
29 | });
30 | };
31 |
32 | export const action: ActionFunction = async (req) => {
33 | const session = await getSession(req.request.headers.get('Cookie'));
34 |
35 | const formData = await req.request.formData();
36 | const id = formData.get('id') as string | null;
37 | const password = formData.get('password') as string | null;
38 | const goto = formData.get('goto') as string | null;
39 |
40 | const errors = {};
41 | if (!id) errors.id = true;
42 | if (!password) errors.password = true;
43 | if (Object.keys(errors).length > 0) {
44 | return json(errors);
45 | }
46 |
47 | const user = await userService.getUser(id as string);
48 | if (!user) {
49 | return redirect('/login?how=unsuccessful');
50 | }
51 |
52 | if (!(await userService.validatePassword(id as string, password as string))) {
53 | return redirect('/login?how=unsuccessful');
54 | }
55 |
56 | session.set(SessionCookieProperties.USER_ID, user.id);
57 | return redirect(goto || '/', {
58 | headers: {
59 | 'Set-Cookie': await commitSession(session),
60 | },
61 | });
62 | };
63 |
64 | function LoginPage(): JSX.Element {
65 | const [searchParams] = useSearchParams();
66 | const how = searchParams.get(URLSearchParamFields.HOW) as UserLoginErrorCode | undefined;
67 | const goto = searchParams.get(URLSearchParamFields.GOTO);
68 |
69 | const message = how ? getErrorMessageForLoginErrorCode(how) : undefined;
70 |
71 | const [loginId, setLoginId] = useState('');
72 | const [loginPassword, setLoginPassword] = useState('');
73 | const [registerId, setRegisterId] = useState('');
74 | const [registerPassword, setRegisterPassword] = useState('');
75 | const [validationMessage, setValidationMessage] = useState('');
76 |
77 | const validateLogin = (e: React.FormEvent): void => {
78 | try {
79 | validateNewUser({ id: loginId, password: loginPassword });
80 | } catch (err: any) {
81 | e.preventDefault();
82 | setValidationMessage(err.message);
83 | }
84 | };
85 |
86 | const validateRegister = (e: React.FormEvent): void => {
87 | try {
88 | validateNewUser({ id: registerId, password: registerPassword });
89 | } catch (err: any) {
90 | e.preventDefault();
91 | setValidationMessage(err.message);
92 | }
93 | };
94 |
95 | return (
96 |
97 | {message && {message}
}
98 | {validationMessage && {validationMessage}
}
99 | Login
100 |
101 |
102 | validateLogin(e)}
106 | style={{ marginBottom: '1em' }}
107 | >
108 |
109 |
142 |
143 |
144 |
145 | Forgot your password?
146 |
147 |
148 | Create Account
149 |
150 |
151 | validateRegister(e)}
155 | style={{ marginBottom: '1em' }}
156 | >
157 |
190 |
191 |
192 |
193 |
194 | );
195 | }
196 |
197 | export default LoginPage;
198 |
--------------------------------------------------------------------------------
/src/routes/__notice/security.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function SecurityPage(): JSX.Element {
6 | return (
7 |
8 | Hacker News Security
9 |
10 | If you find a security hole, please let us know at{' '}
11 | security@ycombinator.com . We try to respond
12 | (with fixes!) as soon as possible, and really appreciate the help.
13 |
14 |
15 | Thanks to the following people who have discovered and responsibly disclosed security holes
16 | in Hacker News:
17 |
18 |
19 |
20 | 20170430: Michael Flaxman
21 |
22 |
23 |
24 |
25 | The minor version of bcrypt used for passwords was susceptible to a collision in some
26 | cases.
27 |
28 |
29 |
30 |
31 | 20170414: Blake Rand
32 |
33 |
34 | Links in comments were vulnerable to an IDN homograph attack.
35 |
36 |
37 |
38 | 20170315: Blake Rand
39 |
40 |
41 |
42 | The right-to-left override character could be used to obscure link text in comments.
43 |
44 |
45 |
46 |
47 |
48 | 20170301: Jaikishan Tulswani
49 |
50 |
51 |
52 | Logged-in users could bypass 'old password' form field.
53 |
54 |
55 |
56 |
57 | 20160217: Eric Tjossem
58 |
59 |
60 |
61 | Logout and login were vulnerable to CSRF.
62 |
63 |
64 |
65 |
66 | 20160113: Mert Taşçi
67 |
68 |
69 |
70 | The 'forgot password' link was vulnerable to reflected XSS.
71 |
72 |
73 |
74 |
75 | 20150907: Sandeep Singh
76 |
77 |
78 |
79 |
80 | An open redirect was possible by passing a URL with a mixed-case protocol as the{' '}
81 | goto parameter.
82 |
83 |
84 |
85 |
86 |
87 | 20150904: Manish Bhattacharya
88 |
89 |
90 |
91 |
92 | The site name display for stories was vulnerable to an{' '}
93 | IDN homograph attack.
94 |
95 |
96 |
97 |
98 |
99 | 20150827: Chris Marlow
100 |
101 |
102 |
103 | Revisions to HN's markup caused an HTML injection regression.
104 |
105 |
106 |
107 |
108 | 20150624: Stephen Sclafani
109 |
110 |
111 |
120 |
121 |
122 | 20150302: Max Bond
123 |
124 |
125 |
126 | Information leaked during /r processing allowed an attacker to discover valid profile edit
127 | links and the user for which they were valid.
128 |
129 |
130 | goto parameters functioned as open redirects.
131 |
132 |
133 |
134 |
135 | 20141101: Ovidiu Toader
136 |
137 |
138 |
139 | In rare cases some users' profiles (including email addresses and password hashes)
140 | were mistakenly published to the Firebase API.
141 |
142 |
143 |
144 | See{' '}
145 |
146 | https://news.ycombinator.com/item?id=8604586
147 | {' '}
148 | for details.
149 |
150 |
151 |
152 | 20141027: San Tran
153 |
154 |
155 |
156 | Some pages displaying forms were vulnerable to reflected XSS when provided malformed query
157 | string arguments.
158 |
159 |
160 |
161 |
162 |
163 | 20140501: Jonathan Rudenberg
164 |
165 |
166 |
167 | Some YC internal pages were vulnerable to persistent XSS.
168 |
169 |
170 |
171 |
172 | 20120801: Louis Lang
173 |
174 |
175 |
176 |
177 | Redirects were vulnerable to HTTP response splitting via the whence argument.
178 |
179 |
180 | Persistent XSS could be achieved via the X-Forwarded-For header.
181 |
182 |
183 |
184 |
185 |
186 | 20120720: Michael Borohovski
187 |
188 |
189 |
190 |
191 | Incorrect handling of unauthenticated requests meant anyone could change rsvp status for
192 | Demo Day.
193 |
194 |
195 |
196 |
197 |
198 | 20090603: Daniel Fox Franke
199 |
200 |
201 |
202 |
203 | The state of the PRNG used to generate cookies could be determined from observed outputs.
204 | This allowed an attacker to fairly easily determine valid user cookies and compromise
205 | accounts.
206 |
207 |
208 |
209 | See https://news.ycombinator.com/item?id=639976 for
210 | details.
211 |
212 |
213 |
214 | Missing From This List? If you reported a vulnerability to us and don't see your
215 | name, please shoot us an email and we'll happily add you. We crawled through tons of
216 | emails trying to find all reports but inevitably missed some.
217 |
218 |
219 | );
220 | }
221 |
222 | export default SecurityPage;
223 |
--------------------------------------------------------------------------------
/src/routes/__notice/newsfaq.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'remix';
2 |
3 | import { NoticeLayout } from '../../layouts/notice-layout';
4 |
5 | export function NewsFaqPage(): JSX.Element {
6 | return (
7 |
8 | Hacker News FAQ
9 |
10 |
11 | Are there rules about submissions and comments?
12 |
13 | https://news.ycombinator.com/newsguidelines
14 |
15 |
16 | How are stories ranked?
17 |
18 |
19 | The basic algorithm divides points by a power of the time since a story was submitted.
20 | Comments in comment threads are ranked the same way.
21 |
22 |
23 | Other factors affecting rank include user flags, anti-abuse software, software which
24 | downweights overheated discussions, and moderator intervention.
25 |
26 |
27 | How is a user's karma calculated?
28 |
29 |
30 | Roughly, the number of upvotes on their stories and comments minus the number of downvotes.
31 | The numbers don't match up exactly, because some votes aren't counted to prevent
32 | abuse.
33 |
34 |
35 | Why don't I see down arrows?
36 |
37 |
38 | There are no down arrows on stories. They appear on comments after users reach a certain
39 | karma threshold, but never on direct replies.
40 |
41 |
42 | What kind of formatting can you use in comments?
43 |
44 |
45 | http://news.ycombinator.com/formatdoc
46 |
47 |
48 | How do I submit a question?
49 |
50 | Use the submit link in the top bar, and leave the url field blank.
51 |
52 | How do I make a link in a question?
53 |
54 |
55 | You can't. This is to prevent people from submitting a link with their comments in a
56 | privileged position at the top of the page. If you want to submit a link with comments, just
57 | submit it, then add a regular comment.
58 |
59 |
60 | How do I flag a comment?
61 |
62 |
63 | Click on its timestamp to go to its page, then click the 'flag' link at the top.
64 | There's a small karma threshold before flag links appear.
65 |
66 |
67 | Are reposts ok?
68 |
69 |
70 | If a story has had significant attention in the last year or so, we kill reposts as
71 | duplicates. If not, a small number of reposts is ok.
72 |
73 |
74 | Please don't delete and repost the same story, though. Accounts that do that eventually
75 | lose submission privileges.
76 |
77 |
78 | Are paywalls ok?
79 |
80 | It's ok to post stories from sites with paywalls that have workarounds.
81 |
82 | In comments, it's ok to ask how to read an article and to help other users do so. But
83 | please don't post complaints about paywalls. Those are off topic.
84 |
85 |
86 |
87 | Can I ask people to upvote my submission?
88 |
89 |
90 | No. Users should vote for a story because it's intellectually interesting, not because
91 | someone is promoting it.
92 |
93 |
94 | When the software detects a voting ring, it penalizes the post. Accounts that vote like this
95 | eventually get their votes ignored.
96 |
97 |
98 | Can I post a job ad?
99 |
100 | Please do not post job ads as story submissions to HN.
101 |
102 | A regular "Who Is Hiring?" thread appears on the first weekday of each month. Most
103 | job ads are welcome there. (But only an account called{' '}
104 | whoishiring is allowed to submit the thread
105 | itself. This prevents a race to post it first.)
106 |
107 |
108 | The other kind of job ad is reserved for YC-funded startups. These appear on the front page,
109 | but are not stories: they have no vote arrows, points, or comments. They begin part-way
110 | down, then fall steadily, and only one should be on the front page at a time.
111 |
112 |
113 | Why can't I post a comment to a thread?
114 |
115 |
116 | Threads are closed to new comments after two weeks, or if the submission has been killed by
117 | software, moderators, or user flags.
118 |
119 |
120 | In my profile, what does showdead do?
121 |
122 |
123 | If you turn it on, you'll see all the stories and comments that have been killed by
124 | HN's software, moderators, and user flags.
125 |
126 |
127 | In my profile, what is delay?
128 |
129 |
130 | It gives you time to edit your comments before they appear to others. Set it to the number
131 | of minutes you'd like. The maximum is 10.
132 |
133 |
134 | In my profile, what is noprocrast?
135 |
136 |
137 | It's a way to help you prevent yourself from spending too much time on HN. If you turn
138 | it on you'll only be allowed to visit the site for maxvisit minutes at a time, with gaps
139 | of minaway minutes in between. The defaults are 20 and 180, which would let you view the
140 | site for 20 minutes at a time, and then not allow you back in for 3 hours. You can override
141 | noprocrast if you want, in which case your visit clock starts over at zero.
142 |
143 |
144 | How do I submit a poll?
145 |
146 |
147 | http://news.ycombinator.com/newpoll
148 |
149 |
150 | How do I reset my password?
151 |
152 |
153 | If you have an email address in your profile, you can request a password reset{' '}
154 | here. If you haven't, you can create a new account or
155 | email hn@ycombinator.com for help.
156 |
157 |
158 | My IP address seems to be banned. How can I unban it?
159 |
160 |
161 | If you request many pages too quickly, your IP address might get banned. The{' '}
162 | self-serve unbanning procedure works most of the time.
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 | );
184 | }
185 |
186 | export default NewsFaqPage;
187 |
--------------------------------------------------------------------------------
/src/server/database/database.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 | import { child, get, DatabaseReference } from '@firebase/database';
3 |
4 | import type { HnCache } from './cache';
5 | import { CommentModel, FeedType, StoryModel, UserModel } from '../models';
6 | import { sampleData } from '../sample-data';
7 | import { HN_API_URL } from '../config.server';
8 |
9 | const logger = debug('app:Database');
10 | logger.log = console.log.bind(console);
11 |
12 | let newCommentIdCounter = 100_000_000;
13 |
14 | // https://github.com/HackerNews/API
15 |
16 | export class HnDatabase {
17 | db: DatabaseReference;
18 | cache: HnCache;
19 |
20 | constructor(db: DatabaseReference, cache: HnCache) {
21 | this.db = db;
22 | this.cache = cache;
23 | }
24 |
25 | async fetchStory(id: number): Promise {
26 | logger('Fetching item:', `${HN_API_URL}/item/${id}.json`);
27 |
28 | return get(child(this.db, `item/${id}`))
29 | .then((itemSnapshot) => {
30 | const item = itemSnapshot.val();
31 |
32 | if (item !== null) {
33 | const newsItem = new StoryModel({
34 | id: item.id,
35 | commentCount: item.descendants,
36 | comments: item.kids,
37 | creationTime: item.time * 1000,
38 | submitterId: item.by,
39 | title: item.title,
40 | upvoteCount: item.score,
41 | url: item.url,
42 | });
43 |
44 | this.cache.setStory(newsItem.id, newsItem);
45 | logger('Saved item in cache:', item.id);
46 |
47 | return newsItem;
48 | }
49 |
50 | throw item;
51 | })
52 | .catch((reason) => logger('Fetching post failed:', reason));
53 | }
54 |
55 | async fetchComment(id: number): Promise {
56 | logger('Fetching comment:', `${HN_API_URL}/item/${id}.json`);
57 |
58 | return get(child(this.db, `item/${id}`))
59 | .then((itemSnapshot) => {
60 | const item = itemSnapshot.val();
61 |
62 | if (item !== null && !item.deleted && !item.dead) {
63 | const comment = new CommentModel({
64 | id: item.id,
65 | comments: item.kids,
66 | creationTime: item.time * 1000,
67 | parent: item.parent,
68 | submitterId: item.by,
69 | text: item.text,
70 | });
71 |
72 | this.cache.setComment(comment.id, comment);
73 | logger('Created Comment:', item.id);
74 |
75 | return comment;
76 | }
77 |
78 | throw item;
79 | })
80 | .catch((reason) => logger('Fetching comment failed:', reason));
81 | }
82 |
83 | async fetchUser(id: string): Promise {
84 | logger('Fetching user:', `${HN_API_URL}/user/${id}.json`);
85 |
86 | return get(child(this.db, `user/${id}`))
87 | .then((itemSnapshot) => {
88 | const item = itemSnapshot.val();
89 |
90 | if (item !== null && !item.deleted && !item.dead) {
91 | const user = new UserModel({
92 | about: item.about,
93 | creationTime: item.created * 1000,
94 | id: item.id,
95 | karma: item.karma,
96 | posts: item.submitted,
97 | });
98 |
99 | this.cache.setUser(user.id, user);
100 | logger('Created User:', item.id, item);
101 |
102 | return user;
103 | }
104 |
105 | throw item;
106 | })
107 | .catch((reason) => logger('Fetching user failed:', reason));
108 | }
109 |
110 | async getFeed(feedType: FeedType): Promise {
111 | logger('Fetching', `/${feedType}stories.json`);
112 |
113 | return get(child(this.db, `${feedType}stories`))
114 | .then((feedSnapshot) => feedSnapshot.val())
115 | .then((feed) => feed.filter((newsItem) => newsItem !== undefined && newsItem !== null))
116 | .catch((reason) => logger('Fetching news feed failed:', reason));
117 | }
118 |
119 | /* BEGIN NEWS ITEMS */
120 |
121 | getNewsItem(id: number): StoryModel | undefined {
122 | return sampleData.newsItems.find((newsItem) => newsItem.id === id);
123 | }
124 |
125 | createNewsItem(newsItem: StoryModel): StoryModel {
126 | sampleData.newsItems.push(newsItem);
127 |
128 | return newsItem;
129 | }
130 |
131 | // ITEM MUTATIONS
132 |
133 | async upvoteItem(id: number, userId: string): Promise {
134 | // Upvote the News Item in the DB
135 | const item = this.cache.getStory(id);
136 |
137 | if (item && !item.upvotes.has(userId)) {
138 | item.upvotes.add(userId);
139 | item.upvoteCount += 1;
140 | this.cache.setStory(id, item);
141 | }
142 |
143 | return item;
144 | }
145 |
146 | async unvoteItem(id: number, userId: string): Promise {
147 | const item = this.cache.getStory(id);
148 |
149 | if (item && !item.upvotes.has(userId)) {
150 | item.upvotes.delete(userId);
151 | item.upvoteCount -= 1;
152 | this.cache.setStory(id, item);
153 | }
154 |
155 | return item;
156 | }
157 |
158 | async hideNewsItem(id: number, userId: string): Promise {
159 | logger('Hiding News Item id by userId:', id, userId);
160 |
161 | const newsItem = this.cache.getStory(id);
162 | const user = this.cache.getUser(userId);
163 |
164 | if (user && !user.hides.includes(id) && newsItem && !newsItem.hides.includes(userId)) {
165 | user.hides.push(id);
166 | this.cache.setUser(userId, user);
167 |
168 | newsItem.hides.push(userId);
169 | this.cache.setStory(id, newsItem);
170 |
171 | logger('Hid News Item id by userId:', id, userId);
172 | } else {
173 | throw new Error(`Data error, user has already hidden ${id} by ${userId}`);
174 | }
175 |
176 | return newsItem;
177 | }
178 |
179 | async submitNewsItem(id: number, newsItem: StoryModel): Promise {
180 | // Submit the News Item in the DB
181 | if (this.cache.setStory(id, newsItem)) {
182 | // FeedSingleton.new.unshift(id);
183 | // FeedSingleton.new.pop();
184 | return newsItem;
185 | }
186 |
187 | throw new Error('Unable to submit News Item.');
188 | }
189 |
190 | /* END NEWS ITEMS */
191 |
192 | /* BEGIN COMMENTS */
193 |
194 | async createComment(parent: number, submitterId: string, text: string) {
195 | return new CommentModel({ parent, submitterId, text, id: newCommentIdCounter++ });
196 | }
197 |
198 | /* END COMMENTS */
199 |
200 | /* BEGIN FEED */
201 |
202 | async getNewNewsItems(first: number, skip: number): Promise {
203 | return sampleData.newsItems.slice(skip, skip + first);
204 | }
205 |
206 | async getTopNewsItems(first: number, skip: number): Promise {
207 | return sampleData.newsItems.slice(skip, skip + first);
208 | }
209 |
210 | async getHotNews(): Promise {
211 | return sampleData.newsItems;
212 | }
213 |
214 | async getNewsItems(): Promise {
215 | return sampleData.newsItems;
216 | }
217 |
218 | /* END FEED */
219 |
220 | /* BEGIN USERS */
221 |
222 | async getUser(id: string): Promise {
223 | return sampleData.users.find((user) => user.id === id);
224 | }
225 |
226 | async getUsers(): Promise {
227 | return sampleData.users;
228 | }
229 |
230 | async createUser(user: UserModel): Promise {
231 | sampleData.users.push(user);
232 |
233 | return user;
234 | }
235 |
236 | /* END USERS */
237 | }
238 |
--------------------------------------------------------------------------------
/src/routes/__blank/dmca.tsx:
--------------------------------------------------------------------------------
1 | import { MetaFunction } from 'remix';
2 |
3 | import { BlankLayout } from '../../layouts/blank-layout';
4 |
5 | import dmcaCss from '../../assets/dmca.css';
6 |
7 | const spanStyle = { fontSize: '11.5pt', fontFamily: 'Helvetica', color: '#444444' };
8 | const paragraphStyle = { marginBottom: '7.5pt', lineHeight: 'normal', background: 'white' };
9 | const liStyle = {
10 | color: '#444444',
11 | marginBottom: '15.0pt',
12 | lineHeight: 'normal',
13 | background: 'white',
14 | };
15 | const liSpanStyle = { fontSize: '10.5pt', fontFamily: 'Helvetica' };
16 | const paragraphStyle2 = {
17 | marginTop: '7.5pt',
18 | marginRight: '0in',
19 | marginBottom: '15.0pt',
20 | marginLeft: '0in',
21 | lineHeight: 'normal',
22 | background: 'white',
23 | };
24 | const bSpanStyle = { fontSize: '13.5pt', fontFamily: 'Helvetica', color: '#444444' };
25 |
26 | export const links = () => [{ rel: 'stylesheet', href: dmcaCss, type: 'text/css' }];
27 |
28 | export const meta: MetaFunction = () => {
29 | return { title: 'DMCA | Hacker News Clone' };
30 | };
31 |
32 | export function DMCAPage(): JSX.Element {
33 | return (
34 |
35 |
43 |
44 | Y Combinator has adopted the following policy toward copyright infringement on the
45 | Services in accordance with the Digital Millennium Copyright Act (a copy of which is
46 | located at{' '}
47 |
48 |
49 |
50 | http://www.loc.gov/copyright/legislation/dmca.pdf
51 |
52 |
53 |
54 | , the "DMCA "). The address of Y Combinator's Designated Agent for copyright
55 | takedown notices ("
56 | Designated Agent ") is listed below.
57 |
58 |
59 |
60 |
61 | Reporting Instances of Copyright Infringement:
62 |
63 |
64 |
65 |
66 | If you believe that content residing or accessible on or through the our website
67 | (“Services”) infringes a copyright, please send a written notice (by fax or regular mail)
68 | to the Designated Agent at the address below.{' '}
69 | You may not communicate the information specified below by email. Please note that
70 | you will be liable for damages (including costs and attorney’s fees) if you materially
71 | misrepresent that material is infringing your copyright(s). Please use the following
72 | format (including section numbers) when you send written notice to us:{' '}
73 |
74 |
75 |
76 |
77 |
78 | Identification of the work or material being infringed.
79 |
80 |
81 |
82 |
83 | Identification of the material that is claimed to be infringing, including its location,
84 | with sufficient detail so that Y Combinator is capable of finding it and verifying its
85 | existence.
86 |
87 |
88 |
89 |
90 | Contact information for the notifying party (the "Notifying Party "), including
91 | name, address, telephone number and e-mail address.
92 |
93 |
94 |
95 |
96 | A statement that the Notifying Party has a good faith belief that the material is not
97 | authorized by the copyright owner, its agent or law.
98 |
99 |
100 |
101 |
102 | A statement made under penalty of perjury that the information provided in the notice is
103 | accurate and that the Notifying Party is either the copyright owner, or authorized to
104 | make the complaint on behalf of the copyright owner.
105 |
106 |
107 |
108 |
109 | A signature of the copyright owner, or a person authorized to act on behalf of the owner
110 | of the copyright that has been allegedly infringed.
111 |
112 |
113 |
114 |
115 |
116 | After removing material pursuant to a valid DMCA notice, Y Combinator will notify the
117 | Subscriber responsible for the allegedly infringing material that it has removed or
118 | disabled access to the material. Y Combinator reserves the right, in its sole discretion,
119 | to immediately terminate the account of any Subscriber who is the subject of repeated DMCA
120 | notifications.
121 |
122 |
123 |
124 |
125 | Submitting a DMCA Counter-Notification:
126 |
127 |
128 |
129 |
130 | If you believe you are the wrongful subject of a DMCA notification, you may file a
131 | counter-notification with Y Combinator by providing the following information to the
132 | Designated Agent at the address below:
133 |
134 |
135 |
136 |
137 |
138 | The specific URLs of material that Y Combinator has removed or to which Y Combinator has
139 | disabled access.
140 |
141 |
142 |
143 | Your name, address, telephone number, and email address.
144 |
145 |
146 |
147 | A statement that you consent to the jurisdiction of Federal District Court for the
148 | judicial district in which your address is located (or the federal district courts
149 | located in San Francisco, CA if your address is outside of the United States), and that
150 | you will accept service of process from the person who provided the original DMCA
151 | notification or an agent of such person.
152 |
153 |
154 |
155 |
156 | The following statement: "I swear, under penalty of perjury, that I have a good faith
157 | belief that the material was removed or disabled as a result of a mistake or
158 | misidentification of the material to be removed or disabled."
159 |
160 |
161 |
162 | Sign the written document.
163 |
164 |
165 |
166 |
167 | Upon receipt of a valid counter-notification, Y Combinator will forward it to Notifying
168 | Party who submitted the original DMCA notification. The original Notifying Party (or the
169 | copyright holder he or she represents) will then have ten (10) days to notify us that he
170 | or she has filed legal action relating to the allegedly infringing material. If Y
171 | Combinator does not receive any such notification within ten (10) days, we may restore the
172 | material to the Services.
173 |
174 |
175 |
176 |
177 | Designated Agent
178 |
179 |
180 |
181 |
182 | Y Combinator
183 |
184 | 320 Pioneer Way, Mountain View, CA 94041
185 |
186 | Attn: Copyright Agent; Legal
187 | Fax: 650.360.3189
188 |
189 |
190 |
191 |
192 |
193 |
194 | );
195 | }
196 |
197 | export default DMCAPage;
198 |
--------------------------------------------------------------------------------
96 | This might help in forensic investigation afterwards. Less crap to wade through. 97 |
98 |103 | 106 | reply 107 | 108 |
109 |