├── .gitignore
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.tsx
├── components
│ ├── backbutton.module.css
│ ├── backbutton.tsx
│ ├── button.module.css
│ ├── button.tsx
│ ├── comment.module.css
│ ├── comment.tsx
│ ├── comments.tsx
│ ├── commentskeleton.module.css
│ ├── commentskeleton.tsx
│ ├── home.module.css
│ ├── home.tsx
│ ├── logo.module.css
│ ├── logo.tsx
│ ├── navbar.module.css
│ ├── navbar.tsx
│ ├── navmenu.module.css
│ ├── navmenu.tsx
│ ├── storydetail.module.css
│ ├── storydetail.tsx
│ ├── storydetailskeleton.module.css
│ ├── storyitem.module.css
│ ├── storyitem.tsx
│ ├── storylist.module.css
│ ├── storylist.tsx
│ ├── threadline.module.css
│ └── threadline.tsx
├── constants.tsx
├── firebase.ts
├── hooks
│ ├── useCategory.ts
│ ├── useStories.ts
│ ├── useStory.ts
│ └── useWindowSize.ts
├── images
│ └── choose.svg
├── index.css
├── index.tsx
├── react-app-env.d.ts
├── serviceWorker.ts
├── setupTests.ts
├── types.d.ts
└── utils.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .vercel
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Hacker News with a cleaner UI. Open site
9 |
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hackernews-cra-migration",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^4.2.4",
7 | "@testing-library/react": "^9.3.2",
8 | "@testing-library/user-event": "^7.1.2",
9 | "@types/jest": "^24.0.0",
10 | "@types/node": "^12.0.0",
11 | "@types/react": "^16.9.41",
12 | "@types/react-dom": "^16.9.0",
13 | "@types/react-router-dom": "^5.1.5",
14 | "clsx": "^1.1.1",
15 | "firebase": "^7.15.5",
16 | "react": "^16.13.1",
17 | "react-dom": "^16.13.1",
18 | "react-icons": "^3.10.0",
19 | "react-router-dom": "^5.2.0",
20 | "react-scripts": "3.4.1",
21 | "timeago.js": "^4.0.2",
22 | "typescript": "~3.7.2"
23 | },
24 | "scripts": {
25 | "start": "react-scripts start",
26 | "build": "react-scripts build",
27 | "test": "react-scripts test",
28 | "eject": "react-scripts eject",
29 | "deploy": "vercel --prod"
30 | },
31 | "eslintConfig": {
32 | "extends": "react-app"
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.2%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | },
46 | "devDependencies": {
47 | "vercel": "^19.1.1"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mickykebe/hackernews/32e9c811cbacdd2e73147ba7123b4b5646572298/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | Hacker News
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mickykebe/hackernews/32e9c811cbacdd2e73147ba7123b4b5646572298/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mickykebe/hackernews/32e9c811cbacdd2e73147ba7123b4b5646572298/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
3 | import { Home } from "./components/home";
4 |
5 | function App() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default App;
19 |
--------------------------------------------------------------------------------
/src/components/backbutton.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | align-items: center;
4 | background-color: white;
5 | color: var(--primary-color);
6 | padding: 0.5rem 1rem;
7 | border: 1px solid rgba(0, 0, 0, 0.1);
8 | font-weight: 300;
9 | font-size: 1rem;
10 | margin: 0 0 0.5rem;
11 | cursor: pointer;
12 | outline: none;
13 | }
14 |
15 | .root:hover {
16 | background-color: #fafafa;
17 | }
18 |
19 | .root:active {
20 | background-color: #e1e1e1;
21 | }
22 |
23 | .root:focus {
24 | border: 1px solid rgba(0, 0, 0, 0.1);
25 | }
26 |
27 | .icon {
28 | margin-right: 0.25rem;
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/backbutton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./backbutton.module.css";
3 | import { IoIosArrowBack as BackIcon } from "react-icons/io";
4 |
5 | interface Props {
6 | onClick?: () => void;
7 | }
8 |
9 | export function BackButton({ onClick }: Props) {
10 | return (
11 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/button.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | color: white;
3 | position: relative;
4 | padding: 0.75rem 1rem;
5 | line-height: 1.25rem;
6 | font-size: 0.875rem;
7 | display: flex;
8 | justify-content: center;
9 | border: 1px solid transparent;
10 | background-color: var(--primary-color);
11 | transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
12 | font-weight: bold;
13 | cursor: pointer;
14 | }
15 |
16 | .root:hover {
17 | background-color: var(--primary-color-light);
18 | }
19 |
20 | .root:active {
21 | background-color: var(--primary-color-dark);
22 | }
23 |
24 | .root:focus {
25 | box-shadow: 0 0 0 3px rgb(255, 102, 0, 0.45);
26 | outline: 0;
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./button.module.css";
3 |
4 | export function Button(props: JSX.IntrinsicElements["button"]) {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/comment.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: grid;
3 | grid-template-columns: 1.5rem 1fr;
4 | grid-gap: 0.25rem;
5 | padding-top: 0.25rem;
6 | }
7 |
8 | .content {
9 | padding: 0.25rem 0 0.5rem;
10 | }
11 |
12 | .userIcon {
13 | margin: auto;
14 | color: var(--text-secondary);
15 | font-size: 1.25rem;
16 | }
17 |
18 | .username {
19 | display: flex;
20 | align-items: center;
21 | color: var(--text-secondary);
22 | }
23 |
24 | .content a {
25 | overflow: hidden;
26 | text-overflow: ellipsis;
27 | color: var(--text-secondary);
28 | display: inline-block;
29 | white-space: nowrap;
30 | vertical-align: top;
31 | }
32 |
33 | .content pre {
34 | overflow: auto;
35 | padding: 0.5rem;
36 | }
37 |
38 | .collapsedRoot {
39 | display: flex;
40 | align-items: center;
41 | color: var(--text-secondary);
42 | cursor: pointer;
43 | background-color: rgba(211, 211, 211, 0.2);
44 | }
45 |
46 | .collapsedRoot > * + * {
47 | margin-left: 0.5rem;
48 | }
49 |
50 | .collapsedRoot .addIcon {
51 | font-size: 1.25rem;
52 | color: var(--primary-color);
53 | }
54 |
55 | @media (max-width: 500px) {
56 | .content a {
57 | max-width: 125px;
58 | }
59 | .content pre {
60 | max-width: 125px;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/comment.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { AiOutlineUser as UserIcon } from "react-icons/ai";
3 | import { useStory } from "../hooks/useStory";
4 | import { Comments } from "./comments";
5 | import styles from "./comment.module.css";
6 | import { CommentSkeleton } from "./commentskeleton";
7 | import { ThreadLine } from "./threadline";
8 | import { MdAddBox as AddIcon } from "react-icons/md";
9 |
10 | interface Props {
11 | id: number;
12 | }
13 |
14 | export function Comment({ id }: Props) {
15 | const comment = useStory(id);
16 | const [collapsed, setCollapsed] = React.useState(false);
17 |
18 | if (comment && (comment.dead || comment.deleted)) {
19 | return null;
20 | }
21 |
22 | const renderComment = () => {
23 | if (!comment) {
24 | return null;
25 | }
26 | if (!collapsed) {
27 | return (
28 |
29 |
30 |
31 | {comment.by}
32 |
33 |
setCollapsed(true)} />
34 |
41 |
42 | );
43 | }
44 | const numChildren = comment.kids?.length || 0;
45 | return (
46 | setCollapsed(false)}>
47 |
48 |
{comment.by}
49 | {numChildren > 0 && (
50 |
{`${numChildren} ${
51 | numChildren === 1 ? `child` : `children`
52 | }`}
53 | )}
54 |
55 | );
56 | };
57 |
58 | return {renderComment()};
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/comments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Comment } from "./comment";
3 |
4 | interface Props {
5 | ids: number[];
6 | }
7 |
8 | export function Comments({ ids }: Props) {
9 | if (!ids || ids.length === 0) {
10 | return null;
11 | }
12 | return (
13 |
14 | {ids.map((id) => (
15 |
16 | ))}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/commentskeleton.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | --item-padding: 1rem;
4 | --item-height: 5rem;
5 | --blur-width: 200px;
6 | --blur-size: var(--blur-width) 100%;
7 | --user-width: 100px;
8 | --user-height: 1rem;
9 | --title-position: 0 var(--item-padding);
10 | --details-width: 100%;
11 | --details-height: 2rem;
12 | --details-position: 0 calc(var(--item-padding) + var(--user-height) + 0.5rem);
13 | }
14 |
15 | .root:empty::after {
16 | content: "";
17 | display: block;
18 | width: 100%;
19 | min-height: var(--item-height);
20 | background-image: linear-gradient(
21 | 90deg,
22 | transparent 0,
23 | rgba(255, 255, 255, 0.8) 50%,
24 | transparent 100%
25 | ),
26 | linear-gradient(var(--lightgrey) 30px, transparent 0),
27 | linear-gradient(var(--lightgrey) 100%, transparent 0),
28 | linear-gradient(white 100%, transparent 0);
29 |
30 | background-size: var(--blur-size), var(--user-width) var(--user-height),
31 | var(--details-width) var(--details-height), 100% 100%;
32 | background-position: -150% 0, var(--title-position), var(--details-position),
33 | 0 0;
34 | background-repeat: no-repeat;
35 | animation: loading 2.5s infinite;
36 | }
37 |
38 | @keyframes loading {
39 | to {
40 | background-position: 350% 0, var(--title-position), var(--details-position),
41 | 0 0;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/commentskeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./commentskeleton.module.css";
3 |
4 | interface Props {
5 | children?: React.ReactElement | null;
6 | }
7 |
8 | export function CommentSkeleton(props: Props) {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | height: 100vh;
3 | display: grid;
4 | grid-template-columns: minmax(350px, 1fr) 3fr;
5 | padding-top: 64px;
6 | }
7 |
8 | .detail {
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .detail img {
16 | max-width: 20%;
17 | margin-bottom: 16px;
18 | }
19 |
20 | .detail p {
21 | font-weight: 300;
22 | font-size: 1.5rem;
23 | text-align: center;
24 | }
25 |
26 | @media (max-width: 960px) {
27 | .container {
28 | grid-template-columns: 1fr;
29 | }
30 |
31 | .detail {
32 | display: none;
33 | }
34 |
35 | .container.itemSelected .detail {
36 | display: flex;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/home.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useParams } from "react-router-dom";
3 | import clsx from "clsx";
4 | import { NavBar } from "./navbar";
5 | import styles from "./home.module.css";
6 | import { StoryList } from "./storylist";
7 | import { StoryDetail } from "./storydetail";
8 | import chooseImg from "../images/choose.svg";
9 |
10 | export function Home() {
11 | const { itemId } = useParams();
12 | const itemSelected = !!itemId;
13 | return (
14 |
15 |
16 |
20 |
21 | {itemSelected ? (
22 |
23 | ) : (
24 |
25 |

26 |
No Story Selected
27 |
28 | )}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/logo.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | flex: 1;
3 | display: flex;
4 | align-items: center;
5 | }
6 |
7 | .logo {
8 | width: 2.5rem;
9 | height: 2.5rem;
10 | border: 2px solid var(--primary-color);
11 | color: var(--primary-color);
12 | font-weight: bold;
13 | font-size: 1.25rem;
14 | display: flex;
15 | align-items: center;
16 | justify-content: center;
17 | margin-right: 0.5rem;
18 | }
19 |
20 | .logoText {
21 | display: flex;
22 | flex-direction: column;
23 | }
24 |
25 | .pageName {
26 | font-size: 1rem;
27 | font-weight: bold;
28 | color: var(--primary-color);
29 | }
30 |
31 | .poweredBy {
32 | font-size: 0.75rem;
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./logo.module.css";
3 |
4 | interface Props {
5 | pageName: string;
6 | }
7 |
8 | export function Logo({ pageName }: Props) {
9 | return (
10 |
11 |
Y
12 |
13 |
{pageName}
14 |
Powered by Hacker News
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/navbar.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: white;
3 | min-height: 64px;
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | right: 0;
8 | display: flex;
9 | align-items: center;
10 | padding: 0 1rem;
11 | border-bottom: 1px solid var(--lightgrey);
12 | }
13 | .logo {
14 | flex: 1;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Logo } from "./logo";
3 | import styles from "./navbar.module.css";
4 | import { useCategory } from "../hooks/useCategory";
5 | import { NavMenu } from "./navmenu";
6 |
7 | const pageNames: { [key in StoryCategory]: string } = {
8 | topstories: "The Front Page",
9 | askstories: "Ask HackerNews",
10 | newstories: "New on HackerNews",
11 | jobstories: "Jobs on HackerNews",
12 | showstories: "Show HackerNews",
13 | beststories: "Best on HackerNews",
14 | };
15 |
16 | export function NavBar() {
17 | const category = useCategory();
18 | return (
19 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/navmenu.module.css:
--------------------------------------------------------------------------------
1 | .toggle,
2 | .hamburger {
3 | position: absolute;
4 | top: 1.5rem;
5 | right: 1.5rem;
6 | width: 1.5rem;
7 | height: 1.5rem;
8 | display: none;
9 | }
10 |
11 | .toggle {
12 | opacity: 0;
13 | z-index: 3;
14 | cursor: pointer;
15 | }
16 |
17 | .hamburger {
18 | justify-content: center;
19 | align-items: center;
20 | z-index: 2;
21 | }
22 |
23 | .hamburger > span {
24 | position: relative;
25 | transition: all 0.2s ease-in;
26 | }
27 |
28 | .hamburger > span,
29 | .hamburger > span:before,
30 | .hamburger > span::after {
31 | width: 100%;
32 | height: 2px;
33 | background-color: var(--primary-color);
34 | }
35 |
36 | .hamburger > span:before,
37 | .hamburger > span::after {
38 | content: "";
39 | position: absolute;
40 | }
41 |
42 | .hamburger > span:before {
43 | top: -0.5rem;
44 | /* transform: translateY(-10px); */
45 | }
46 |
47 | .hamburger > span:after {
48 | top: 0.5rem;
49 | }
50 |
51 | .toggle:checked + .hamburger > span {
52 | transform: rotate(135deg);
53 | }
54 |
55 | .toggle:checked + .hamburger > span:before,
56 | .toggle:checked + .hamburger > span:after {
57 | top: 0;
58 | transform: rotate(90deg);
59 | }
60 |
61 | .toggle:checked:hover + .hamburger > span {
62 | transform: rotate(225deg);
63 | }
64 |
65 | .links {
66 | list-style: none;
67 | display: flex;
68 | }
69 | .links li {
70 | padding: 1rem;
71 | }
72 | .links a {
73 | color: var(--primary-color);
74 | text-decoration: none;
75 | font-weight: bold;
76 | padding: 0;
77 | }
78 |
79 | @media (max-width: 600px) {
80 | .toggle {
81 | display: flex;
82 | }
83 |
84 | .hamburger {
85 | display: flex;
86 | }
87 |
88 | .linksContainer {
89 | position: fixed;
90 | width: 100vw;
91 | height: 100vh;
92 | background: white;
93 | z-index: 1;
94 | justify-content: center;
95 | align-items: center;
96 | top: 0;
97 | left: 0;
98 | display: none;
99 | transition: all 0.4s ease-in;
100 | }
101 |
102 | .toggle:checked ~ .linksContainer {
103 | display: flex;
104 | }
105 |
106 | .links {
107 | flex-direction: column;
108 | align-items: center;
109 | }
110 |
111 | .links a {
112 | font-size: 1.5rem;
113 | }
114 | }
115 |
116 | @media (max-width: 500px) {
117 | .toggle,
118 | .hamburger {
119 | top: 1.75rem;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/navmenu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Link, useParams } from "react-router-dom";
3 | import styles from "./navmenu.module.css";
4 | import { useWindowSize } from "../hooks/useWindowSize";
5 |
6 | export function NavMenu() {
7 | const { itemId } = useParams();
8 | const { width } = useWindowSize();
9 | let itemIdParam = "";
10 | if (itemId && width && width > 600) {
11 | itemIdParam = `${itemId}`;
12 | }
13 | const [menuToggled, setMenuToggled] = React.useState(false);
14 | const closeMenu = () => {
15 | setMenuToggled(false);
16 | };
17 |
18 | return (
19 |
20 |
{
25 | setMenuToggled(ev.target.checked);
26 | }}
27 | />
28 |
29 |
30 |
31 |
32 |
33 | -
34 |
35 | top
36 |
37 |
38 | -
39 |
40 | new
41 |
42 |
43 | -
44 |
45 | best
46 |
47 |
48 | -
49 |
50 | ask
51 |
52 |
53 | -
54 |
55 | show
56 |
57 |
58 | -
59 |
60 | jobs
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/storydetail.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 0.5rem 1rem;
3 | overflow-y: scroll;
4 | }
5 |
6 | .head {
7 | padding-bottom: 1rem;
8 | }
9 |
10 | .title {
11 | font-size: 1.5rem;
12 | margin-bottom: 0.5rem;
13 | }
14 |
15 | .storyDetails {
16 | display: grid;
17 | grid-gap: 1.25rem;
18 | grid-template-columns: repeat(3, max-content);
19 | grid-auto-flow: column;
20 | color: var(--text-secondary);
21 | margin-bottom: 0.5rem;
22 | }
23 |
24 | .storyDetailItem {
25 | display: flex;
26 | align-items: center;
27 | }
28 |
29 | .storyDetailIcon {
30 | margin-right: 0.5rem;
31 | }
32 |
33 | .storyLink {
34 | display: flex;
35 | align-items: center;
36 | color: var(--text-secondary);
37 | font-weight: 700;
38 | font-size: 0.9rem;
39 | }
40 |
41 | .storyLink svg {
42 | margin-right: 0.5rem;
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/storydetail.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useStory } from "../hooks/useStory";
3 | import styles from "./storydetail.module.css";
4 | import { AiOutlineUser } from "react-icons/ai";
5 | import {
6 | GoCommentDiscussion as CommentIcon,
7 | GoChevronUp as UpIcon,
8 | } from "react-icons/go";
9 | import { Comments } from "./comments";
10 | import { useWindowSize } from "../hooks/useWindowSize";
11 | import { BackButton } from "./backbutton";
12 | import { useParams, useHistory } from "react-router-dom";
13 | import { FiExternalLink as LinkIcon } from "react-icons/fi";
14 | import skeletonStyles from "./storydetailskeleton.module.css";
15 |
16 | interface Props {
17 | id: number;
18 | }
19 |
20 | interface SkeletonProps {
21 | children?: React.ReactElement;
22 | }
23 |
24 | function StoryDetailSekeleton(props: SkeletonProps) {
25 | return ;
26 | }
27 |
28 | export function StoryDetail({ id }: Props) {
29 | const story = useStory(id);
30 | const { width } = useWindowSize();
31 | const { page } = useParams();
32 | const history = useHistory();
33 | const handleBackClick = () => {
34 | let currentPage = page || "top";
35 | history.push(`/${currentPage}`);
36 | };
37 | return (
38 |
39 |
40 | {story && (
41 |
42 | {(width as number) < 960 && (
43 |
44 | )}
45 |
46 |
{story.title}
47 |
48 |
49 |
50 | {story.score}
51 |
52 |
53 |
54 | {story.by}
55 |
56 |
57 | {" "}
58 | {story.descendants}
59 |
60 |
61 |
62 | {story.url}
63 |
64 |
65 |
66 |
67 | )}
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/storydetailskeleton.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | min-height: var(--item-height);
4 | --item-padding: 1rem;
5 | --item-height: 8rem;
6 | --blur-width: 200px;
7 | --blur-size: var(--blur-width) 100%;
8 | --title-width: 100%;
9 | --title-height: 2rem;
10 | --title-position: 0 var(--item-padding);
11 | --details-width: 200px;
12 | --details-height: 1rem;
13 | --url-width: 75%;
14 | --url-height: 1rem;
15 | --details-position: 0 calc(var(--item-padding) + var(--title-height) + 0.5rem);
16 | --url-position: 0
17 | calc(
18 | var(--item-padding) + var(--title-height) + var(--details-height) + 1rem
19 | );
20 | }
21 |
22 | .root:empty::after {
23 | content: "";
24 | display: block;
25 | width: 100%;
26 | height: var(--item-height);
27 | background-image: linear-gradient(
28 | 90deg,
29 | transparent 0,
30 | rgba(255, 255, 255, 0.8) 50%,
31 | transparent 100%
32 | ),
33 | linear-gradient(var(--lightgrey) 30px, transparent 0),
34 | linear-gradient(var(--lightgrey) 100%, transparent 0),
35 | linear-gradient(var(--lightgrey) 100%, transparent 0),
36 | linear-gradient(white 100%, transparent 0);
37 |
38 | background-size: var(--blur-size), var(--title-width) var(--title-height),
39 | var(--details-width) var(--details-height),
40 | var(--url-width) var(--url-height), 100% 100%;
41 | background-position: -150% 0, var(--title-position), var(--details-position),
42 | var(--url-position), 0 0;
43 | background-repeat: no-repeat;
44 | animation: loading 2.5s infinite;
45 | }
46 |
47 | @keyframes loading {
48 | to {
49 | background-position: 350% 0, var(--title-position), var(--details-position),
50 | var(--url-position), 0 0;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/storyitem.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | min-height: var(--item-height);
4 | border-bottom: 1px solid var(--lightgrey);
5 | --item-padding: 1rem;
6 | --item-height: 6.6rem;
7 | --blur-width: 200px;
8 | --blur-size: var(--blur-width) 100%;
9 | --title-width: 50%;
10 | --title-height: 1.25rem;
11 | --author-width: 25%;
12 | --author-height: 1.25rem;
13 | --author-position: var(--item-padding)
14 | calc(var(--item-padding) + var(--title-height) + 0.5rem);
15 | --url-width: calc(100% - var(--reactions-width) - (2 * var(--item-padding)));
16 | --url-height: 1.25rem;
17 | --reactions-width: 5rem;
18 | --reactions-height: 100%;
19 | --reactions-position: 100% 100%;
20 | --title-position: var(--item-padding) var(--item-padding);
21 | --url-position: var(--item-padding)
22 | calc(2 * var(--item-padding) + var(--title-height) + var(--author-height));
23 | }
24 |
25 | .root:empty::after {
26 | content: "";
27 | display: block;
28 | width: 100%;
29 | height: var(--item-height);
30 | background-image: linear-gradient(
31 | 90deg,
32 | transparent 0,
33 | rgba(255, 255, 255, 0.8) 50%,
34 | transparent 100%
35 | ),
36 | linear-gradient(var(--lightgrey) 30px, transparent 0),
37 | linear-gradient(var(--lightgrey) 20px, transparent 0),
38 | linear-gradient(var(--lightgrey) 20px, transparent 0),
39 | linear-gradient(var(--lightgrey) 100%, transparent 0),
40 | linear-gradient(white 100%, transparent 0);
41 |
42 | background-size: var(--blur-size), var(--title-width) var(--title-height),
43 | var(--author-width) var(--author-height), var(--url-width) var(--url-height),
44 | var(--reactions-width) var(--reactions-height), 100% 100%;
45 | background-position: -150% 0, var(--title-position), var(--author-position),
46 | var(--url-position), var(--reactions-position), 0 0;
47 | background-repeat: no-repeat;
48 | animation: loading 2.5s infinite;
49 | }
50 |
51 | @keyframes loading {
52 | to {
53 | background-position: 350% 0, var(--title-position), var(--author-position),
54 | var(--url-position), var(--reactions-position), 0 0;
55 | }
56 | }
57 |
58 | .rootInner {
59 | display: grid;
60 | grid-template-columns: 1fr 64px;
61 | }
62 |
63 | .contentContainer {
64 | display: flex;
65 | overflow-x: hidden;
66 | }
67 |
68 | .storyContent {
69 | padding: 1rem;
70 | display: flex;
71 | flex-direction: column;
72 | color: var(--text-primary);
73 | overflow-x: hidden;
74 | }
75 |
76 | .storyTitle {
77 | font-size: 1.25rem;
78 | margin-bottom: 0.25rem;
79 | color: inherit;
80 | text-decoration: none;
81 | }
82 |
83 | .storyBy {
84 | margin-bottom: 0.25rem;
85 | font-weight: bold;
86 | font-size: 0.75rem;
87 | }
88 |
89 | .storyTime {
90 | margin-left: 0.5rem;
91 | }
92 |
93 | .storyUrl {
94 | color: var(--text-secondary);
95 | font-size: 0.75rem;
96 | width: 100%;
97 | white-space: nowrap;
98 | overflow: hidden;
99 | text-overflow: ellipsis;
100 | }
101 |
102 | .storyReactions {
103 | background: rgba(211, 211, 211, 0.2);
104 | padding: 1rem;
105 | display: flex;
106 | flex-direction: column;
107 | align-items: center;
108 | color: var(--text-secondary);
109 | text-decoration: none;
110 | }
111 |
112 | .storyReaction {
113 | display: inline-flex;
114 | align-items: center;
115 | font-size: 0.875rem;
116 | }
117 |
118 | .storyReaction svg {
119 | margin-right: 0.25rem;
120 | font-size: 1.25rem;
121 | }
122 |
123 | .score {
124 | margin-bottom: auto;
125 | }
126 |
127 | .comments {
128 | }
129 |
130 | .selected {
131 | min-width: 0.25rem;
132 | background-color: var(--primary-color);
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/storyitem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./storyitem.module.css";
3 | import {
4 | GoCommentDiscussion as CommentIcon,
5 | GoChevronUp as UpIcon,
6 | } from "react-icons/go";
7 | import { format } from "timeago.js";
8 | import { Link, useParams } from "react-router-dom";
9 | import { useStory } from "../hooks/useStory";
10 |
11 | interface Props {
12 | id: number;
13 | }
14 |
15 | export function StoryItemContainer(props: {
16 | key?: number;
17 | children?: React.ReactNode;
18 | }) {
19 | return ;
20 | }
21 |
22 | export function StoryItem({ id }: Props) {
23 | const story = useStory(id);
24 | const { page, itemId } = useParams();
25 | return (
26 |
27 | {story ? (
28 |
29 |
30 | {parseInt(itemId) === id &&
}
31 |
32 |
37 | {story.title}
38 |
39 |
40 | {story.by}
41 |
42 | {format(story.time * 1000)}
43 |
44 |
45 |
{story.url}
46 |
47 |
48 |
51 |
52 | {story.score}
53 |
54 |
55 | {story.descendants}
56 |
57 |
58 |
59 | ) : null}
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/storylist.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | overflow-y: scroll;
3 | }
4 |
5 | .loadMoreButton {
6 | width: 100%;
7 | }
8 |
9 | @media (max-width: 960px) {
10 | .root.itemSelected {
11 | display: none;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/storylist.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import clsx from "clsx";
3 | import styles from "./storylist.module.css";
4 | import { StoryItem, StoryItemContainer } from "./storyitem";
5 | import { STORIES_PER_PAGE } from "../constants";
6 | import { Button } from "./button";
7 | import { useStories } from "../hooks/useStories";
8 | import { useCategory } from "../hooks/useCategory";
9 | import { useParams } from "react-router-dom";
10 |
11 | export function StoryList() {
12 | const { itemId } = useParams();
13 | const itemSelected = !!itemId;
14 | const category = useCategory();
15 | const allStoryIds = useStories(category);
16 | const [currentPage, setCurrentPage] = React.useState(1);
17 | const totalPages = Math.ceil((allStoryIds.length * 1.0) / STORIES_PER_PAGE);
18 | const storyIds = allStoryIds.slice(0, STORIES_PER_PAGE * currentPage);
19 | const incCurrentPage = () => {
20 | setCurrentPage((pages) => pages + 1);
21 | };
22 | const rootEl = React.useRef(null);
23 | React.useEffect(() => {
24 | setCurrentPage(1); // Back to first page when category changes.
25 | if (rootEl.current) {
26 | rootEl.current.scrollTop = 0; //Scroll to top on category change.
27 | }
28 | }, [category]);
29 | return (
30 |
35 | {storyIds.length === 0 &&
36 | Array.from({ length: STORIES_PER_PAGE }).map((_, i) => (
37 |
38 | ))}
39 | {storyIds.length > 0 && (
40 |
41 | {storyIds.map((id) => (
42 |
43 | ))}
44 | {currentPage < totalPages && (
45 |
48 | )}
49 |
50 | )}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/threadline.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | cursor: pointer;
6 | }
7 |
8 | .emptySpace {
9 | width: 2px;
10 | height: 100%;
11 | }
12 |
13 | .line {
14 | width: 1px;
15 | background-color: rgba(0, 0, 0, 0.3);
16 | height: 100%;
17 | }
18 |
19 | .root:hover .emptySpace {
20 | width: 2px;
21 | }
22 |
23 | .root:hover .line {
24 | width: 3px;
25 | background-color: var(--primary-color);
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/threadline.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import styles from "./threadline.module.css";
3 |
4 | interface Props {
5 | onClick: () => void;
6 | }
7 |
8 | export function ThreadLine({ onClick }: Props) {
9 | return (
10 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/constants.tsx:
--------------------------------------------------------------------------------
1 | export const STORIES_PER_PAGE = 30;
2 |
--------------------------------------------------------------------------------
/src/firebase.ts:
--------------------------------------------------------------------------------
1 | import * as firebase from "firebase/app";
2 | import "firebase/database";
3 | import "firebase/analytics";
4 |
5 | class Firebase {
6 | private static _instance: Firebase;
7 | private db: firebase.database.Reference;
8 | private analytics: firebase.analytics.Analytics;
9 | private constructor() {
10 | const config = {
11 | databaseURL: "https://hacker-news.firebaseio.com",
12 | };
13 | const myAppConfig = {
14 | apiKey: process.env.REACT_APP_API_KEY,
15 | authDomain: process.env.REACT_APP_AUTH_DOMAIN,
16 | databaseURL: process.env.REACT_APP_DB_URL,
17 | projectId: process.env.REACT_APP_PROJECT_ID,
18 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
19 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
20 | appId: process.env.REACT_APP_APP_ID,
21 | measurementId: process.env.REACT_APP_MEASUREMENT_ID,
22 | };
23 | const hnApp = firebase.initializeApp(config, "hnapp");
24 | const myApp = firebase.initializeApp(myAppConfig, "myapp");
25 | this.db = hnApp.database().ref("/v0");
26 | this.analytics = myApp.analytics();
27 | }
28 | static getInstance() {
29 | if (!this._instance) {
30 | this._instance = new Firebase();
31 | }
32 | return this._instance;
33 | }
34 |
35 | storiesRef(category: StoryCategory) {
36 | return this.db.child(category);
37 | }
38 |
39 | item(itemId: number) {
40 | return this.db.child(`item/${itemId}`);
41 | }
42 | }
43 |
44 | export { Firebase };
45 |
--------------------------------------------------------------------------------
/src/hooks/useCategory.ts:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 | import { pageCategory } from "../utils";
3 |
4 | export function useCategory(page?: PageUrl) {
5 | const { page: pageMatch } = useParams();
6 | let pageValue: PageUrl;
7 | if (page) {
8 | pageValue = page;
9 | } else if (pageMatch in pageCategory) {
10 | pageValue = pageMatch;
11 | } else {
12 | pageValue = "top";
13 | }
14 | return pageCategory[pageValue];
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/useStories.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Firebase } from "../firebase";
3 |
4 | export function useStories(category: StoryCategory) {
5 | const [storyIds, setStoryIds] = React.useState([]);
6 | React.useEffect(() => {
7 | const handler = (snapshot: firebase.database.DataSnapshot) => {
8 | setStoryIds(snapshot.val());
9 | };
10 | const storiesRef = Firebase.getInstance().storiesRef(category);
11 | storiesRef.on("value", handler);
12 | return () => {
13 | storiesRef.off("value", handler);
14 | };
15 | }, [category]);
16 |
17 | return storyIds;
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useStory.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Firebase } from "../firebase";
3 |
4 | export function useStory(id: number) {
5 | const [story, setStory] = React.useState(() => {
6 | const storyStr = sessionStorage.getItem(String(id));
7 | if (storyStr) {
8 | return JSON.parse(storyStr);
9 | }
10 | });
11 | React.useEffect(() => {
12 | const handler = (snapshot: firebase.database.DataSnapshot) => {
13 | setStory(snapshot.val());
14 | };
15 | const storyRef = Firebase.getInstance().item(id);
16 | storyRef.on("value", handler);
17 | return () => {
18 | storyRef.off("value", handler);
19 | };
20 | }, [id]);
21 | React.useEffect(() => {
22 | if (story) {
23 | try {
24 | sessionStorage.setItem(String(story.id), JSON.stringify(story));
25 | } catch (err) {}
26 | }
27 | }, [story]);
28 | return story;
29 | }
30 |
--------------------------------------------------------------------------------
/src/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useWindowSize() {
4 | const isClient = typeof window === "object";
5 |
6 | const getSize = React.useCallback(() => {
7 | return {
8 | width: isClient ? window.innerWidth : undefined,
9 | height: isClient ? window.innerHeight : undefined,
10 | };
11 | }, [isClient]);
12 |
13 | const [windowSize, setWindowSize] = React.useState(getSize);
14 |
15 | React.useEffect(() => {
16 | if (!isClient) {
17 | return;
18 | }
19 | function handleResize() {
20 | setWindowSize(getSize());
21 | }
22 | window.addEventListener("resize", handleResize);
23 | return () => window.removeEventListener("resize", handleResize);
24 | }, [getSize, isClient]);
25 |
26 | return windowSize;
27 | }
28 |
--------------------------------------------------------------------------------
/src/images/choose.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@300;400;700&display=swap");
2 |
3 | :root {
4 | box-sizing: border-box;
5 | --primary-color: #ff6600;
6 | --primary-color-dark: #b44600;
7 | --primary-color-light: rgba(255, 102, 0, 0.7);
8 | --text-primary: #272727;
9 | --text-secondary: #7d7f80;
10 | --lightgrey: rgba(211, 211, 211, 0.5);
11 | font-family: "Roboto Slab", serif;
12 | line-height: 1.5;
13 | }
14 | * {
15 | box-sizing: inherit;
16 | margin: 0;
17 | padding: 0;
18 | }
19 |
20 | main {
21 | height: 100vh;
22 | }
23 |
24 | @media (max-width: 960px) {
25 | :root {
26 | font-size: 87.5%;
27 | }
28 | }
29 |
30 | @media (max-width: 500px) {
31 | :root {
32 | font-size: 75%;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { BrowserRouter as Router } from "react-router-dom";
4 | import "./index.css";
5 | import App from "./App";
6 | import * as serviceWorker from "./serviceWorker";
7 |
8 | ReactDOM.render(
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById("root")
15 | );
16 |
17 | // If you want your app to work offline and load faster, you can change
18 | // unregister() to register() below. Note this comes with some pitfalls.
19 | // Learn more about service workers: https://bit.ly/CRA-PWA
20 | serviceWorker.unregister();
21 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | process.env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl, {
112 | headers: { 'Service-Worker': 'script' }
113 | })
114 | .then(response => {
115 | // Ensure service worker exists, and that we really are getting a JS file.
116 | const contentType = response.headers.get('content-type');
117 | if (
118 | response.status === 404 ||
119 | (contentType != null && contentType.indexOf('javascript') === -1)
120 | ) {
121 | // No service worker found. Probably a different app. Reload the page.
122 | navigator.serviceWorker.ready.then(registration => {
123 | registration.unregister().then(() => {
124 | window.location.reload();
125 | });
126 | });
127 | } else {
128 | // Service worker found. Proceed as normal.
129 | registerValidSW(swUrl, config);
130 | }
131 | })
132 | .catch(() => {
133 | console.log(
134 | 'No internet connection found. App is running in offline mode.'
135 | );
136 | });
137 | }
138 |
139 | export function unregister() {
140 | if ('serviceWorker' in navigator) {
141 | navigator.serviceWorker.ready
142 | .then(registration => {
143 | registration.unregister();
144 | })
145 | .catch(error => {
146 | console.error(error.message);
147 | });
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | type PageUrl = "top" | "new" | "show" | "ask" | "jobs" | "best";
2 |
3 | type StoryCategory =
4 | | "askstories"
5 | | "jobstories"
6 | | "newstories"
7 | | "showstories"
8 | | "topstories"
9 | | "beststories";
10 |
11 | interface Story {
12 | id: number;
13 | deleted: boolean;
14 | type: "job" | "story" | "comment" | "poll" | "pollopt";
15 | by: string;
16 | time: number;
17 | text: string;
18 | dead: boolean;
19 | parent: number;
20 | poll: number;
21 | kids: number[];
22 | url: string;
23 | score: number;
24 | title: string;
25 | parts: number[];
26 | descendants: number;
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const pageCategory: { [key in PageUrl]: StoryCategory } = {
2 | top: "topstories",
3 | new: "newstories",
4 | ask: "askstories",
5 | show: "showstories",
6 | jobs: "jobstories",
7 | best: "beststories",
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------