): void => {
48 | if (data?.me) {
49 | e.preventDefault();
50 | Router.push('/login?how=loggedin');
51 | } else {
52 | try {
53 | validateNewUser({ id: registerId, password: registerPassword });
54 | } catch (err: any) {
55 | e.preventDefault();
56 | setValidationMessage(err.message);
57 | }
58 | }
59 | };
60 |
61 | return (
62 |
63 | {message && {message}
}
64 | {validationMessage && {validationMessage}
}
65 | Login
66 |
67 |
68 |
107 |
108 | Forgot your password?
109 |
110 |
111 |
112 | Create Account
113 |
114 |
115 |
153 |
154 | );
155 | }
156 |
157 | export default withDataAndRouter(LoginPage);
158 |
--------------------------------------------------------------------------------
/pages/newcomments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export function NewCommentsPage(props): JSX.Element {
9 | const { router } = props;
10 |
11 | const pageNumber = (router.query && +router.query.p) || 0;
12 |
13 | return (
14 |
15 |
21 |
22 | );
23 | }
24 |
25 | export default withDataAndRouter(NewCommentsPage);
26 |
--------------------------------------------------------------------------------
/pages/newest.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import gql from 'graphql-tag';
3 | import * as React from 'react';
4 |
5 | import { NewsFeed, newsFeedNewsItemFragment } from '../src/components/news-feed';
6 | import { withDataAndRouter } from '../src/helpers/with-data';
7 | import { MainLayout } from '../src/layouts/main-layout';
8 | import { FeedType } from '../src/data/models';
9 | import { POSTS_PER_PAGE } from '../src/config';
10 |
11 | const query = gql`
12 | query NewestFeed($type: FeedType!, $first: Int!, $skip: Int!) {
13 | feed(type: $type, first: $first, skip: $skip) {
14 | ...NewsFeed
15 | }
16 | }
17 | ${newsFeedNewsItemFragment}
18 | `;
19 |
20 | export interface INewestNewsFeedProps {
21 | options: {
22 | currentUrl: string;
23 | first: number;
24 | skip: number;
25 | };
26 | }
27 |
28 | export function NewestPage(props): JSX.Element {
29 | const { router } = props;
30 |
31 | const pageNumber = (router.query && +router.query.p) || 0;
32 |
33 | const first = POSTS_PER_PAGE;
34 | const skip = POSTS_PER_PAGE * pageNumber;
35 |
36 | const { data } = useQuery(query, { variables: { first, skip, type: FeedType.NEW } });
37 |
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default withDataAndRouter(NewestPage);
46 |
--------------------------------------------------------------------------------
/pages/newpoll.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export function NewPollPage(props): JSX.Element {
9 | const { router } = props;
10 |
11 | const pageNumber = (router.query && +router.query.p) || 0;
12 |
13 | return (
14 |
15 |
21 |
22 | );
23 | }
24 |
25 | export default withDataAndRouter(NewPollPage);
26 |
--------------------------------------------------------------------------------
/pages/newsguidelines.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | import { NoticeLayout } from '../src/layouts/notice-layout';
5 |
6 | export function NewsGuidelinesPage(): JSX.Element {
7 | return (
8 |
9 | Hacker News Guidelines
10 |
11 |
12 | What to Submit
13 |
14 | On-Topic: Anything that good hackers would find interesting. That includes more than hacking
15 | and startups. If you had to reduce it to a sentence, the answer might be: anything that
16 | gratifies one's intellectual curiosity.
17 |
18 |
19 | Off-Topic: Most stories about politics, or crime, or sports, unless they're evidence of
20 | some interesting new phenomenon. Ideological or political battle or talking points. Videos
21 | of pratfalls or disasters, or cute animal pictures. If they'd cover it on TV news,
22 | it's probably off-topic.
23 |
24 |
25 | In Submissions
26 |
27 |
28 | Please don't do things to make titles stand out, like using uppercase or exclamation
29 | points, or adding a parenthetical remark saying how great an article is. It's implicit
30 | in submitting something that you think it's important.
31 |
32 |
33 | If you submit a link to a video or pdf, please warn us by appending [video] or [pdf] to the
34 | title.
35 |
36 |
37 | Please submit the original source. If a post reports on something found on another site,
38 | submit the latter.
39 |
40 |
41 | If the original title includes the name of the site, please take it out, because the site
42 | name will be displayed after the link.
43 |
44 |
45 | If the original title begins with a number or number + gratuitous adjective, we'd
46 | appreciate it if you'd crop it. E.g. translate "10 Ways To Do X" to "How
47 | To Do X," and "14 Amazing Ys" to "Ys." Exception: when the number
48 | is meaningful, e.g. "The 5 Platonic Solids."
49 |
50 | Otherwise please use the original title, unless it is misleading or linkbait.
51 |
52 | Please don't post on HN to ask or tell us something. Instead, please send it to
53 | hn@ycombinator.com. Similarly, please don't use HN posts to ask YC-funded companies
54 | questions that you could ask by emailing them.
55 |
56 |
57 | Please don't submit so many links at once that the new page is dominated by your
58 | submissions.
59 |
60 |
61 | In Comments
62 |
63 |
64 | Be civil. Don't say things you wouldn't say face-to-face. Don't be snarky.
65 | Comments should get more civil and substantive, not less, as a topic gets more divisive.
66 |
67 |
68 | When disagreeing, please reply to the argument instead of calling names. "That is
69 | idiotic; 1 + 1 is 2, not 3" can be shortened to "1 + 1 is 2, not 3."
70 |
71 |
72 | Please respond to the strongest plausible interpretation of what someone says, not a weaker
73 | one that's easier to criticize.
74 |
75 |
76 | Eschew flamebait. Don't introduce flamewar topics unless you have something genuinely
77 | new to say. Avoid unrelated controversies and generic tangents.
78 |
79 |
80 | Please don't insinuate that someone hasn't read an article. "Did you even read
81 | the article? It mentions that" can be shortened to "The article mentions
82 | that."
83 |
84 |
85 | Please don't use uppercase for emphasis. If you want to emphasize a word or phrase, put
86 | *asterisks* around it and it will get italicized.
87 |
88 |
89 | Please don't accuse others of astroturfing or shillage. Email us instead and we'll
90 | look into it.
91 |
92 |
93 | Please don't complain that a submission is inappropriate. If a story is spam or
94 | off-topic, flag it. Don't feed egregious comments by replying;{' '}
95 |
96 | flag
97 | {' '}
98 | them instead. When you flag something, please don't also comment that you did.
99 |
100 |
101 | Please don't comment about the voting on comments. It never does any good, and it makes
102 | boring reading.
103 |
104 |
105 | Throwaway accounts are ok for sensitive information, but please don't create them
106 | routinely. On HN, users need an identity that others can relate to.
107 |
108 |
109 | We ban accounts that use Hacker News primarily for political or ideological battle,
110 | regardless of which politics they favor.
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | );
130 | }
131 |
132 | export default NewsGuidelinesPage;
133 |
--------------------------------------------------------------------------------
/pages/newswelcome.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | import { NoticeLayout } from '../src/layouts/notice-layout';
5 |
6 | export function NewsWelcomePage(): JSX.Element {
7 | return (
8 |
9 | Welcome to Hacker News
10 |
11 |
12 |
13 |
14 | Hacker News
15 | {' '}
16 | is a bit different from other community sites, and we'd appreciate it if you'd take
17 | a minute to read the following as well as the{' '}
18 |
19 | official guidelines
20 |
21 | .
22 |
23 |
24 | HN is an experiment. As a rule, a community site that becomes popular will decline in
25 | quality. Our hypothesis is that this is not inevitable—that by making a conscious effort to
26 | resist decline, we can keep it from happening.
27 |
28 |
29 | Essentially there are two rules here: don't post or upvote crap links, and don't be
30 | rude or dumb in comment threads.
31 |
32 |
33 | A crap link is one that's only superficially interesting. Stories on HN don't have
34 | to be about hacking, because good hackers aren't only interested in hacking, but they do
35 | have to be deeply interesting.
36 |
37 |
38 | What does "deeply interesting" mean? It means stuff that teaches you about the
39 | world. A story about a robbery, for example, would probably not be deeply interesting. But
40 | if this robbery was a sign of some bigger, underlying trend, perhaps it could be.
41 |
42 |
43 | The worst thing to post or upvote is something that's intensely but shallowly
44 | interesting: gossip about famous people, funny or cute pictures or videos, partisan
45 | political articles, etc. If you let{' '}
46 |
47 | that sort of thing onto a news site, it will push aside the deeply interesting stuff,
48 | which tends to be quieter.
49 |
50 |
51 |
52 | The most important principle on HN, though, is to make thoughtful comments. Thoughtful in
53 | both senses: civil and substantial.
54 |
55 |
56 | The test for substance is a lot like it is for links. Does your comment teach us anything?
57 | There are two ways to do that: by pointing out some consideration that hadn't previously
58 | been mentioned, and by giving more information about the topic, perhaps from personal
59 | experience. Whereas comments like "LOL!" or worse still, "That's
60 | retarded!" teach us nothing.
61 |
62 |
63 | Empty comments can be ok if they're positive. There's nothing wrong with submitting
64 | a comment saying just "Thanks." What we especially discourage are comments that
65 | are empty and negative—comments that are mere name-calling.
66 |
67 |
68 | Which brings us to the most important principle on HN: civility. Since long before the web,
69 | the anonymity of online conversation has lured people into being much ruder than they'd
70 | be in person. So the principle here is: don't say anything you wouldn't say face to
71 | face. This doesn't mean you can't disagree. But disagree without calling names. If
72 | you're right, your argument will be more convincing without them.
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
94 | export default NewsWelcomePage;
95 |
--------------------------------------------------------------------------------
/pages/noobcomments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export function NoobCommentsPage(props): JSX.Element {
9 | const { router } = props;
10 |
11 | const pageNumber = (router.query && +router.query.p) || 0;
12 |
13 | return (
14 |
15 |
21 |
22 | );
23 | }
24 |
25 | export default withDataAndRouter(NoobCommentsPage);
26 |
--------------------------------------------------------------------------------
/pages/noobstories.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export function NoobStoriesPage(props): JSX.Element {
9 | const { router } = props;
10 |
11 | const pageNumber = (router.query && +router.query.p) || 0;
12 |
13 | return (
14 |
15 |
21 |
22 | );
23 | }
24 |
25 | export default withDataAndRouter(NoobStoriesPage);
26 |
--------------------------------------------------------------------------------
/pages/reply.tsx:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import * as React from 'react';
3 | import { useQuery } from '@apollo/client';
4 |
5 | import { commentFragment } from '../src/components/comment';
6 | import { withDataAndRouter } from '../src/helpers/with-data';
7 | import { MainLayout } from '../src/layouts/main-layout';
8 |
9 | const query = gql`
10 | query Comment($id: Int!) {
11 | comment(id: $id) {
12 | id
13 | ...Comment
14 | }
15 | }
16 | ${commentFragment}
17 | `;
18 |
19 | export interface IReplyPageProps {
20 | router;
21 | }
22 |
23 | function ReplyPage(props: IReplyPageProps): JSX.Element {
24 | const { router } = props;
25 |
26 | const { data } = useQuery(query, {
27 | variables: { id: (router.query && +router.query.id) || 0 },
28 | });
29 |
30 | const vote = (): void => {
31 | console.log('onclick');
32 | };
33 |
34 | const toggle = (): void => {
35 | console.log('toggle');
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
56 |
57 |
58 |
81 |
82 |
83 |
84 |
85 | Because the vehicle is electric, there is no need to “heat up” the brakes
86 | when descending. This is because the enormous electric engine acts as a
87 | generator and recharges the battery pack. That same energy is then used to
88 | help the vehicle travel back up the hill. Phys reports, “If all goes as
89 | planned, the electric dumper truck will even harvest more electricity while
90 | traveling downhill than it needs for the ascent. Instead of consuming fossil
91 | fuels, it would then feed surplus electricity into the grid.”
92 |
93 |
94 | Clever. It can do this because it travels uphill empty and comes downhill
95 | full.
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | );
128 | }
129 |
130 | export default withDataAndRouter(ReplyPage);
131 |
--------------------------------------------------------------------------------
/pages/security.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | import { NoticeLayout } from '../src/layouts/notice-layout';
5 |
6 | export function SecurityPage(): JSX.Element {
7 | return (
8 |
9 | Hacker News Security
10 |
11 | If you find a security hole, please let us know at{' '}
12 | security@ycombinator.com . We try to respond
13 | (with fixes!) as soon as possible, and really appreciate the help.
14 |
15 |
16 | Thanks to the following people who have discovered and responsibly disclosed security holes
17 | in Hacker News:
18 |
19 |
20 |
21 | 20170430: Michael Flaxman
22 |
23 |
24 |
25 |
26 | The minor version of bcrypt used for passwords was susceptible to a collision in some
27 | cases.
28 |
29 |
30 |
31 |
32 | 20170414: Blake Rand
33 |
34 |
35 | Links in comments were vulnerable to an IDN homograph attack.
36 |
37 |
38 |
39 | 20170315: Blake Rand
40 |
41 |
42 |
43 | The right-to-left override character could be used to obscure link text in comments.
44 |
45 |
46 |
47 |
48 |
49 | 20170301: Jaikishan Tulswani
50 |
51 |
52 |
53 | Logged-in users could bypass 'old password' form field.
54 |
55 |
56 |
57 |
58 | 20160217: Eric Tjossem
59 |
60 |
61 |
62 | Logout and login were vulnerable to CSRF.
63 |
64 |
65 |
66 |
67 | 20160113: Mert Taşçi
68 |
69 |
70 |
71 | The 'forgot password' link was vulnerable to reflected XSS.
72 |
73 |
74 |
75 |
76 | 20150907: Sandeep Singh
77 |
78 |
79 |
80 |
81 | An open redirect was possible by passing a URL with a mixed-case protocol as the{' '}
82 | goto parameter.
83 |
84 |
85 |
86 |
87 |
88 | 20150904: Manish Bhattacharya
89 |
90 |
91 |
92 |
93 | The site name display for stories was vulnerable to an{' '}
94 | IDN homograph attack.
95 |
96 |
97 |
98 |
99 |
100 | 20150827: Chris Marlow
101 |
102 |
103 |
104 | Revisions to HN's markup caused an HTML injection regression.
105 |
106 |
107 |
108 |
109 | 20150624: Stephen Sclafani
110 |
111 |
112 |
121 |
122 |
123 | 20150302: Max Bond
124 |
125 |
126 |
127 | Information leaked during /r processing allowed an attacker to discover valid profile edit
128 | links and the user for which they were valid.
129 |
130 |
131 | goto parameters functioned as open redirects.
132 |
133 |
134 |
135 |
136 | 20141101: Ovidiu Toader
137 |
138 |
139 |
140 | In rare cases some users' profiles (including email addresses and password hashes)
141 | were mistakenly published to the Firebase API.
142 |
143 |
144 |
145 | See{' '}
146 |
147 | https://news.ycombinator.com/item?id=8604586
148 | {' '}
149 | for details.
150 |
151 |
152 |
153 | 20141027: San Tran
154 |
155 |
156 |
157 | Some pages displaying forms were vulnerable to reflected XSS when provided malformed query
158 | string arguments.
159 |
160 |
161 |
162 |
163 |
164 | 20140501: Jonathan Rudenberg
165 |
166 |
167 |
168 | Some YC internal pages were vulnerable to persistent XSS.
169 |
170 |
171 |
172 |
173 | 20120801: Louis Lang
174 |
175 |
176 |
177 |
178 | Redirects were vulnerable to HTTP response splitting via the whence argument.
179 |
180 |
181 | Persistent XSS could be achieved via the X-Forwarded-For header.
182 |
183 |
184 |
185 |
186 |
187 | 20120720: Michael Borohovski
188 |
189 |
190 |
191 |
192 | Incorrect handling of unauthenticated requests meant anyone could change rsvp status for
193 | Demo Day.
194 |
195 |
196 |
197 |
198 |
199 | 20090603: Daniel Fox Franke
200 |
201 |
202 |
203 |
204 | The state of the PRNG used to generate cookies could be determined from observed outputs.
205 | This allowed an attacker to fairly easily determine valid user cookies and compromise
206 | accounts.
207 |
208 |
209 |
210 | See{' '}
211 |
212 | https://news.ycombinator.com/item?id=639976
213 | {' '}
214 | for details.
215 |
216 |
217 |
218 | Missing From This List? If you reported a vulnerability to us and don't see your
219 | name, please shoot us an email and we'll happily add you. We crawled through tons of
220 | emails trying to find all reports but inevitably missed some.
221 |
222 |
223 | );
224 | }
225 |
226 | export default SecurityPage;
227 |
--------------------------------------------------------------------------------
/pages/show.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import gql from 'graphql-tag';
3 | import Link from 'next/link';
4 | import * as React from 'react';
5 |
6 | import { NewsFeed, newsFeedNewsItemFragment } from '../src/components/news-feed';
7 | import { withDataAndRouter } from '../src/helpers/with-data';
8 | import { MainLayout } from '../src/layouts/main-layout';
9 | import { FeedType } from '../src/data/models';
10 | import { POSTS_PER_PAGE } from '../src/config';
11 |
12 | const query = gql`
13 | query topNewsItems($type: FeedType!, $first: Int!, $skip: Int!) {
14 | feed(type: $type, first: $first, skip: $skip) {
15 | ...NewsFeed
16 | }
17 | }
18 | ${newsFeedNewsItemFragment}
19 | `;
20 |
21 | export interface IShowHNNewsFeedProps {
22 | options: {
23 | currentUrl: string;
24 | first: number;
25 | notice: JSX.Element;
26 | skip: number;
27 | };
28 | }
29 |
30 | export function ShowHNPage(props): JSX.Element {
31 | const { router } = props;
32 | const pageNumber = (router.query && +router.query.p) || 0;
33 |
34 | const first = POSTS_PER_PAGE;
35 | const skip = POSTS_PER_PAGE * pageNumber;
36 |
37 | const { data } = useQuery(query, { variables: { first, skip, type: FeedType.SHOW } });
38 |
39 | return (
40 |
41 |
48 |
49 |
50 |
51 |
52 | Please read the{' '}
53 |
54 |
55 | rules
56 |
57 |
58 | . You can also browse the{' '}
59 |
60 |
61 | newest
62 |
63 | {' '}
64 | Show HNs.
65 |
66 |
67 |
68 | >
69 | }
70 | />
71 |
72 | );
73 | }
74 |
75 | export default withDataAndRouter(ShowHNPage);
76 |
--------------------------------------------------------------------------------
/pages/showhn.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | import { NoticeLayout } from '../src/layouts/notice-layout';
5 |
6 | export function ShowHNRulesPage(): JSX.Element {
7 | return (
8 |
9 | Show HN
10 |
11 |
12 | Show HN is a way to share something that you've made on Hacker News.
13 |
14 | The current Show HNs can be found via{' '}
15 |
16 | show
17 | {' '}
18 | in the top bar, and the newest are{' '}
19 |
20 | here
21 |
22 | . To post one, simply{' '}
23 |
24 | submit
25 | {' '}
26 | a story whose title begins with "Show HN".
27 |
28 |
29 | What to Submit
30 |
31 |
32 | Show HN is for something you've made that other people can play with. HN users can try it
33 | out, give you feedback, and ask questions in the thread.
34 |
35 |
36 | A Show HN needn't be complicated or look slick. The community is comfortable with work
37 | that's at an early stage.
38 |
39 |
40 | If your work isn't ready for people to try out yet, please don't do a Show HN. Once it's
41 | ready, come back and do it then.
42 |
43 |
44 | Blog posts, sign-up pages, and fundraisers can't be tried out, so they can't be Show HNs.
45 |
46 |
47 | New features and upgrades ("Foo 1.3.1 is out") generally aren't substantive enough to be
48 | Show HNs. A major overhaul is probably ok.
49 |
50 |
51 | In Comments
52 |
53 | Be respectful. Anyone sharing work is making a contribution, however modest.
54 | Ask questions out of curiosity. Don't cross-examine.
55 |
56 | Instead of "you're doing it wrong", suggest alternatives. When someone is learning, help
57 | them learn more.
58 |
59 |
60 | When something isn't good, you needn't pretend that it is. But don't be gratuitously
61 | negative.
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | export default ShowHNRulesPage;
85 |
--------------------------------------------------------------------------------
/pages/shownew.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import gql from 'graphql-tag';
3 | import Link from 'next/link';
4 | import * as React from 'react';
5 |
6 | import { NewsFeed, newsFeedNewsItemFragment } from '../src/components/news-feed';
7 | import { POSTS_PER_PAGE } from '../src/config';
8 | import { FeedType } from '../src/data/models';
9 | import { withDataAndRouter } from '../src/helpers/with-data';
10 | import { MainLayout } from '../src/layouts/main-layout';
11 |
12 | const query = gql`
13 | query topNewsItems($type: FeedType!, $first: Int!, $skip: Int!) {
14 | feed(type: $type, first: $first, skip: $skip) {
15 | ...NewsFeed
16 | }
17 | }
18 | ${newsFeedNewsItemFragment}
19 | `;
20 |
21 | export interface IShowHNNewsFeedProps {
22 | options: {
23 | currentUrl: string;
24 | first: number;
25 | skip: number;
26 | notice: JSX.Element;
27 | };
28 | }
29 |
30 | export function ShowNewPage(props): JSX.Element {
31 | const { router } = props;
32 |
33 | const pageNumber = (router.query && +router.query.p) || 0;
34 |
35 | const first = POSTS_PER_PAGE;
36 | const skip = POSTS_PER_PAGE * pageNumber;
37 |
38 | const { data } = useQuery(query, { variables: { first, skip, type: FeedType.SHOW } });
39 |
40 | return (
41 |
42 |
49 |
50 |
51 |
52 |
53 | Please read the{' '}
54 |
55 |
56 | rules
57 |
58 |
59 | . You can also browse the{' '}
60 |
61 |
62 | newest
63 |
64 | {' '}
65 | Show HNs.
66 |
67 |
68 |
69 | >
70 | }
71 | />
72 |
73 | );
74 | }
75 |
76 | export default withDataAndRouter(ShowNewPage);
77 |
--------------------------------------------------------------------------------
/pages/submit.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Router from 'next/router';
3 | import React, { useState } from 'react';
4 | import { useMutation } from '@apollo/client';
5 |
6 | import { SUBMIT_NEWS_ITEM_MUTATION } from '../src/data/mutations/submit-news-item-mutation';
7 | import { withDataAndRouter } from '../src/helpers/with-data';
8 | import { MainLayout } from '../src/layouts/main-layout';
9 |
10 | interface ISubmitPageProps {
11 | router;
12 | }
13 |
14 | function SubmitPage(props: ISubmitPageProps): JSX.Element {
15 | const { router } = props;
16 |
17 | const [title, setTitle] = useState('');
18 | const [url, setUrl] = useState('');
19 | const [text, setText] = useState('');
20 |
21 | const [submitNewsItem] = useMutation(SUBMIT_NEWS_ITEM_MUTATION, {
22 | variables: { title, url, text },
23 | onCompleted(res) {
24 | if (res && res.data) {
25 | void Router.push(`/item?id=${res.data.submitNewsItem.id}`);
26 | }
27 | },
28 | onError(err) {
29 | console.error(err);
30 | },
31 | });
32 |
33 | return (
34 |
40 |
41 |
42 | e.preventDefault()}>
43 |
44 |
45 |
50 |
133 |
134 |
135 |
136 |
137 | );
138 | }
139 |
140 | export default withDataAndRouter(SubmitPage);
141 |
--------------------------------------------------------------------------------
/pages/submitted.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export function SubmittedPage(props): JSX.Element {
9 | const { router } = props;
10 |
11 | const pageNumber = (router.query && +router.query.p) || 0;
12 |
13 | return (
14 |
15 |
21 |
22 | );
23 | }
24 |
25 | export default withDataAndRouter(SubmittedPage);
26 |
--------------------------------------------------------------------------------
/pages/threads.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsFeedView } from '../src/components/news-feed';
4 | import { sampleData } from '../src/data/sample-data';
5 | import { withDataAndRouter } from '../src/helpers/with-data';
6 | import { MainLayout } from '../src/layouts/main-layout';
7 |
8 | export interface IThreadsPageProps {
9 | router;
10 | }
11 |
12 | export function ThreadsPage(props: IThreadsPageProps): JSX.Element {
13 | const { router } = props;
14 |
15 | const pageNumber = (router.query && +router.query.p) || 0;
16 |
17 | return (
18 |
19 |
25 |
26 | );
27 | }
28 |
29 | export default withDataAndRouter(ThreadsPage);
30 |
--------------------------------------------------------------------------------
/pages/upvoted.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import gql from 'graphql-tag';
3 | import * as React from 'react';
4 |
5 | import { NewsFeed, newsFeedNewsItemFragment } from '../src/components/news-feed';
6 | import { POSTS_PER_PAGE } from '../src/config';
7 | import { FeedType } from '../src/data/models';
8 | import { withDataAndRouter } from '../src/helpers/with-data';
9 | import { MainLayout } from '../src/layouts/main-layout';
10 |
11 | const query = gql`
12 | query NewestFeed($type: FeedType!, $first: Int!, $skip: Int!) {
13 | feed(type: $type, first: $first, skip: $skip) {
14 | ...NewsFeed
15 | }
16 | }
17 | ${newsFeedNewsItemFragment}
18 | `;
19 |
20 | export interface IUpvotedPageProps {
21 | options: {
22 | currentUrl: string;
23 | first: number;
24 | skip: number;
25 | };
26 | }
27 |
28 | export function UpvotedPage(props): JSX.Element {
29 | const { router } = props;
30 |
31 | const pageNumber = (router.query && +router.query.p) || 0;
32 |
33 | const first = POSTS_PER_PAGE;
34 | const skip = POSTS_PER_PAGE * pageNumber;
35 |
36 | const { data } = useQuery(query, {
37 | variables: { type: FeedType.NEW, first, skip },
38 | });
39 |
40 | return (
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | export default withDataAndRouter(UpvotedPage);
48 |
--------------------------------------------------------------------------------
/pages/x.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useRouter } from 'next/router';
3 |
4 | import { MainLayout } from '../src/layouts/main-layout';
5 | import { withData } from '../src/helpers/with-data';
6 |
7 | /** Password recovery email sent page after submitting forgot password */
8 | function PasswordRecoveryPage(): JSX.Element {
9 | const router = useRouter();
10 |
11 | return (
12 |
19 | <>
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Password recovery message sent. If you don't see it, you might want to
29 | check your spam folder.
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | >
40 |
41 | );
42 | }
43 |
44 | export default withData(PasswordRecoveryPage);
45 |
--------------------------------------------------------------------------------
/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/README.md:
--------------------------------------------------------------------------------
1 | This folder holds all of the static assets for build.
2 |
--------------------------------------------------------------------------------
/public/static/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 |
--------------------------------------------------------------------------------
/public/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/favicon.ico
--------------------------------------------------------------------------------
/public/static/grayarrow.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/grayarrow.gif
--------------------------------------------------------------------------------
/public/static/grayarrow2x.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/grayarrow2x.gif
--------------------------------------------------------------------------------
/public/static/s.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/s.gif
--------------------------------------------------------------------------------
/public/static/y18.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/y18.gif
--------------------------------------------------------------------------------
/public/static/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 |
--------------------------------------------------------------------------------
/public/static/yc500.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clintonwoo/hackernews-react-graphql/6875c7233c2e0d82a910f8427c8d230f0858d048/public/static/yc500.gif
--------------------------------------------------------------------------------
/server/database/cache-warmer.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { FeedType, NewsItemModel } from '../../src/data/models';
4 | import type { FeedService } from '../services/feed-service';
5 | import type { HnCache } from './cache';
6 | import type { HnDatabase } from './database';
7 |
8 | const logger = debug('app:cache-warmer');
9 | logger.log = console.log.bind(console);
10 |
11 | const FIFTEEN_MINUTES = 1000 * 60 * 15;
12 |
13 | export function warmCache(db: HnDatabase, cache: HnCache, feedService: FeedService): void {
14 | // Fetch the front pages
15 | feedService.getForType(FeedType.TOP, 30, 0);
16 | feedService.getForType(FeedType.NEW, 30, 0);
17 |
18 | setTimeout(() => warmCache(db, cache, feedService), FIFTEEN_MINUTES);
19 | }
20 |
21 | function rebuildFeed(db: HnDatabase, cache: HnCache, feedType: FeedType): void {
22 | setTimeout(() => rebuildFeed(db, cache, feedType), 1000 * 60 * 15, feedType);
23 |
24 | db.getFeed(feedType)
25 | .then((feed) => {
26 | if (feed) {
27 | return Promise.all(feed.map((id: number) => db.fetchNewsItem(id))).then((newsItems) => {
28 | logger(newsItems);
29 |
30 | cache[`${feedType}NewsItems`] = newsItems.filter(
31 | (newsItem) => newsItem !== undefined && newsItem !== null
32 | ) as NewsItemModel[];
33 |
34 | cache[feedType] = feed;
35 |
36 | logger('Updated Feed ids for type: ', feedType);
37 | });
38 | }
39 |
40 | return undefined;
41 | })
42 | .catch((reason) => logger('Error building feed: ', reason));
43 | }
44 |
45 | /* END NEWS ITEMS */
46 |
47 | /* BEGIN SEED DATA */
48 |
49 | export function seedCache(db: HnDatabase, cache: HnCache, delay: number): void {
50 | logger('Waiting ms before seeding the app with data:', delay);
51 |
52 | // Delay seeding the cache so we don't spam in dev
53 | setTimeout(() => {
54 | logger('Seeding cache');
55 |
56 | [FeedType.TOP, FeedType.NEW, FeedType.BEST, FeedType.SHOW, FeedType.ASK, FeedType.JOB].forEach(
57 | (feedType): void => {
58 | rebuildFeed(db, cache, feedType);
59 | }
60 | );
61 | }, delay);
62 | }
63 |
64 | /* END SEED DATA */
65 |
--------------------------------------------------------------------------------
/server/database/cache.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 | import LRU from 'lru-cache';
3 |
4 | import { NewsItemModel, UserModel, CommentModel, FeedType } from '../../src/data/models';
5 | import { sampleData } from '../../src/data/sample-data';
6 |
7 | const logger = debug('app:Cache');
8 | logger.log = console.log.bind(console);
9 |
10 | // The cache is a singleton
11 |
12 | export class HnCache {
13 | isReady = false;
14 |
15 | /* Feeds - Arrays of post ids in descending rank order */
16 | [FeedType.TOP]: number[] = sampleData.top;
17 |
18 | [FeedType.NEW]: number[] = sampleData.new;
19 |
20 | [FeedType.BEST]: number[] = [];
21 |
22 | [FeedType.SHOW]: number[] = [];
23 |
24 | [FeedType.ASK]: number[] = [];
25 |
26 | [FeedType.JOB]: number[] = [];
27 |
28 | /* Pre constructed cache of news feeds with news item objects */
29 | topNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
30 |
31 | newNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
32 |
33 | bestNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
34 |
35 | showNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
36 |
37 | askNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
38 |
39 | jobNewsItems: NewsItemModel[] = sampleData.topStoriesCache;
40 |
41 | /* BEGIN NEWS ITEMS */
42 |
43 | getNewsItem(id: number): NewsItemModel | undefined {
44 | return this.newsItemsCache.get(id.toString());
45 | }
46 |
47 | setNewsItem(id: number, newsItem: NewsItemModel): boolean {
48 | return this.newsItemsCache.set(id.toString(), newsItem);
49 | }
50 |
51 | /* END NEWS ITEMS */
52 |
53 | /* BEGIN USERS */
54 |
55 | getUser(id: string): UserModel | undefined {
56 | return this.userCache.get(id);
57 | }
58 |
59 | getUsers(): Array> {
60 | return this.userCache.dump();
61 | }
62 |
63 | setUser(id: string, user: UserModel): UserModel {
64 | logger('Cache set user:', user);
65 |
66 | this.userCache.set(id, user);
67 |
68 | return user;
69 | }
70 |
71 | /* END USERS */
72 |
73 | /* BEGIN COMMENTS */
74 |
75 | getComment(id: number): CommentModel | undefined {
76 | return this.commentCache.get(id.toString());
77 | }
78 |
79 | setComment(id: number, comment: CommentModel): CommentModel {
80 | this.commentCache.set(comment.id.toString(), comment);
81 |
82 | logger('Cache set comment:', comment);
83 |
84 | return comment;
85 | }
86 |
87 | /* END COMMENTS */
88 |
89 | /* BEGIN CACHES */
90 |
91 | newNewsItemsCache = new LRU({
92 | max: 500,
93 | maxAge: 1000 * 60 * 60, // 60 Minute cache: ms * s * m
94 | });
95 |
96 | newsItemsCache = new LRU({
97 | max: 1000,
98 | maxAge: 1000 * 60 * 60, // 60 Minute cache: ms * s * m
99 | });
100 |
101 | userCache = new LRU({
102 | max: 500,
103 | maxAge: 1000 * 60 * 60 * 2, // 2 hour cache: ms * s * m
104 | });
105 |
106 | commentCache = new LRU({
107 | max: 5000,
108 | maxAge: 1000 * 60 * 60 * 1, // 1 hour cache: ms * s * m
109 | });
110 |
111 | /* END CACHES */
112 | }
113 |
--------------------------------------------------------------------------------
/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, NewsItemModel, UserModel } from '../../src/data/models';
6 | import { sampleData } from '../../src/data/sample-data';
7 | import { HN_API_URL } from '../../src/config';
8 |
9 | const logger = debug('app:Database');
10 | logger.log = console.log.bind(console);
11 |
12 | // https://github.com/HackerNews/API
13 |
14 | export class HnDatabase {
15 | db: DatabaseReference;
16 | cache: HnCache;
17 |
18 | constructor(db: DatabaseReference, cache: HnCache) {
19 | this.db = db;
20 | this.cache = cache;
21 | }
22 |
23 | async fetchNewsItem(id: number): Promise {
24 | logger('Fetching post:', `${HN_API_URL}/item/${id}.json`);
25 |
26 | return get(child(this.db, `item/${id}`))
27 | .then((postSnapshot) => {
28 | const post = postSnapshot.val();
29 |
30 | if (post !== null) {
31 | const newsItem = new NewsItemModel({
32 | id: post.id,
33 | creationTime: post.time * 1000,
34 | commentCount: post.descendants,
35 | comments: post.kids,
36 | submitterId: post.by,
37 | title: post.title,
38 | upvoteCount: post.score,
39 | url: post.url,
40 | });
41 |
42 | this.cache.setNewsItem(newsItem.id, newsItem);
43 | logger('Created Post:', post.id);
44 |
45 | return newsItem;
46 | }
47 |
48 | throw post;
49 | })
50 | .catch((reason) => logger('Fetching post failed:', reason));
51 | }
52 |
53 | async fetchComment(id: number): Promise {
54 | logger('Fetching comment:', `${HN_API_URL}/item/${id}.json`);
55 |
56 | return get(child(this.db, `item/${id}`))
57 | .then((itemSnapshot) => {
58 | const item = itemSnapshot.val();
59 |
60 | if (item !== null && !item.deleted && !item.dead) {
61 | const comment = new CommentModel({
62 | comments: item.kids,
63 | creationTime: item.time * 1000,
64 | id: item.id,
65 | parent: item.parent,
66 | submitterId: item.by,
67 | text: item.text,
68 | });
69 |
70 | this.cache.setComment(comment.id, comment);
71 | logger('Created Comment:', item.id);
72 |
73 | return comment;
74 | }
75 |
76 | throw item;
77 | })
78 | .catch((reason) => logger('Fetching comment failed:', reason));
79 | }
80 |
81 | async fetchUser(id: string): Promise {
82 | logger('Fetching user:', `${HN_API_URL}/user/${id}.json`);
83 |
84 | return get(child(this.db, `user/${id}`))
85 | .then((itemSnapshot) => {
86 | const item = itemSnapshot.val();
87 |
88 | if (item !== null && !item.deleted && !item.dead) {
89 | const user = new UserModel({
90 | about: item.about,
91 | creationTime: item.created * 1000,
92 | id: item.id,
93 | karma: item.karma,
94 | posts: item.submitted,
95 | });
96 |
97 | this.cache.setUser(user.id, user);
98 | logger('Created User:', item.id, item);
99 |
100 | return user;
101 | }
102 |
103 | throw item;
104 | })
105 | .catch((reason) => logger('Fetching user failed:', reason));
106 | }
107 |
108 | async getFeed(feedType: FeedType): Promise {
109 | logger('Fetching', `/${feedType}stories.json`);
110 |
111 | return get(child(this.db, `${feedType}stories`))
112 | .then((feedSnapshot) => feedSnapshot.val())
113 | .then((feed) => feed.filter((newsItem) => newsItem !== undefined && newsItem !== null))
114 | .catch((reason) => logger('Fetching news feed failed:', reason));
115 | }
116 |
117 | /* BEGIN NEWS ITEMS */
118 |
119 | getNewsItem(id: number): NewsItemModel | undefined {
120 | return sampleData.newsItems.find((newsItem) => newsItem.id === id);
121 | }
122 |
123 | createNewsItem(newsItem: NewsItemModel): NewsItemModel {
124 | sampleData.newsItems.push(newsItem);
125 |
126 | return newsItem;
127 | }
128 |
129 | // NEWS ITEM MUTATIONS
130 |
131 | upvoteNewsItem(id: number, userId: string): NewsItemModel | undefined {
132 | // Upvote the News Item in the DB
133 | const newsItem = this.cache.getNewsItem(id);
134 |
135 | if (newsItem && !newsItem.upvotes.includes(userId)) {
136 | newsItem.upvotes.push(userId);
137 | newsItem.upvoteCount += 1;
138 | this.cache.setNewsItem(id, newsItem);
139 | }
140 |
141 | return newsItem;
142 | }
143 |
144 | unvoteNewsItem(id: number, userId: string): NewsItemModel | undefined {
145 | const newsItem = this.cache.getNewsItem(id);
146 |
147 | if (newsItem && !newsItem.upvotes.includes(userId)) {
148 | newsItem.upvotes.splice(newsItem.upvotes.indexOf(userId), 1);
149 | newsItem.upvoteCount -= 1;
150 | this.cache.setNewsItem(id, newsItem);
151 | }
152 |
153 | return newsItem;
154 | }
155 |
156 | hideNewsItem(id: number, userId: string): NewsItemModel {
157 | logger('Hiding News Item id by userId:', id, userId);
158 |
159 | const newsItem = this.cache.getNewsItem(id);
160 | const user = this.cache.getUser(userId);
161 |
162 | if (user && !user.hides.includes(id) && newsItem && !newsItem.hides.includes(userId)) {
163 | user.hides.push(id);
164 | this.cache.setUser(userId, user);
165 |
166 | newsItem.hides.push(userId);
167 | this.cache.setNewsItem(id, newsItem);
168 |
169 | logger('Hid News Item id by userId:', id, userId);
170 | } else {
171 | throw new Error(`Data error, user has already hidden ${id} by ${userId}`);
172 | }
173 |
174 | return newsItem;
175 | }
176 |
177 | submitNewsItem(id: number, newsItem: NewsItemModel): NewsItemModel {
178 | // Submit the News Item in the DB
179 | if (this.cache.setNewsItem(id, newsItem)) {
180 | // FeedSingleton.new.unshift(id);
181 | // FeedSingleton.new.pop();
182 | return newsItem;
183 | }
184 |
185 | throw new Error('Unable to submit News Item.');
186 | }
187 |
188 | /* END NEWS ITEMS */
189 |
190 | /* BEGIN FEED */
191 |
192 | getNewNewsItems(first: number, skip: number): NewsItemModel[] {
193 | return sampleData.newsItems.slice(skip, skip + first);
194 | }
195 |
196 | getTopNewsItems(first: number, skip: number): NewsItemModel[] {
197 | return sampleData.newsItems.slice(skip, skip + first);
198 | }
199 |
200 | getHotNews(): NewsItemModel[] {
201 | return sampleData.newsItems;
202 | }
203 |
204 | getNewsItems(): NewsItemModel[] {
205 | return sampleData.newsItems;
206 | }
207 |
208 | /* END FEED */
209 |
210 | /* BEGIN USERS */
211 |
212 | getUser(id: string): UserModel | undefined {
213 | return sampleData.users.find((user) => user.id === id);
214 | }
215 |
216 | getUsers(): UserModel[] {
217 | return sampleData.users;
218 | }
219 |
220 | createUser(user: UserModel): UserModel {
221 | sampleData.users.push(user);
222 |
223 | return user;
224 | }
225 |
226 | /* END USERS */
227 | }
228 |
--------------------------------------------------------------------------------
/server/graphql-resolvers.spec.ts:
--------------------------------------------------------------------------------
1 | import { sampleData } from '../src/data/sample-data';
2 | import { resolvers } from './graphql-resolvers';
3 |
4 | describe('graphql-resolvers', () => {
5 | describe('Queries', () => {
6 | it('returns expected data for query on Feed', () => {
7 | expect(true);
8 | });
9 |
10 | it('returns expected data for query on Comment', () => {
11 | const result = (resolvers.Query as any).comment(
12 | undefined,
13 | { id: sampleData.topStoriesCache[0].comments[0].id },
14 | {
15 | CommentService: {
16 | getComment: (id) =>
17 | sampleData.topStoriesCache[0].comments.find((comment) => comment.id === id),
18 | },
19 | }
20 | );
21 | expect(result).toBeDefined();
22 | });
23 |
24 | it('returns expected data for query on Me', () => {
25 | expect(true).toBeTruthy();
26 | });
27 |
28 | it('returns expected data for News Item query', () => {
29 | expect(true).toBeTruthy();
30 | });
31 |
32 | it('returns expected data for User query', () => {
33 | expect(true).toBeTruthy();
34 | });
35 | });
36 |
37 | describe('Mutations', () => {
38 | it('returns data for upvoteNewsItem mutation', () => {
39 | expect(true).toBeTruthy();
40 | });
41 |
42 | it('returns data for submitNewsItem mutation', () => {
43 | expect(true).toBeTruthy();
44 | });
45 | });
46 |
47 | describe('Property Resolvers', () => {
48 | it('newsItem author is a user', () => {
49 | expect(true).toBeTruthy();
50 | });
51 |
52 | it('newsItem comments are comments', () => {
53 | expect(true).toBeTruthy();
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/server/graphql-resolvers.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 | import { GraphQLScalarType } from 'graphql';
3 | import { Kind } from 'graphql/language';
4 | import type { IResolvers } from '@graphql-tools/utils';
5 |
6 | import { NewsItemModel, CommentModel, UserModel } from '../src/data/models';
7 | import type { CommentService } from './services/comment-service';
8 | import type { FeedService } from './services/feed-service';
9 | import type { NewsItemService } from './services/news-item-service';
10 | import type { UserService } from './services/user-service';
11 |
12 | const logger = debug('app:Graphql-Resolvers');
13 | logger.log = console.log.bind(console);
14 |
15 | export interface IGraphQlSchemaContext {
16 | commentService: CommentService;
17 | feedService: FeedService;
18 | newsItemService: NewsItemService;
19 | userService: UserService;
20 | userId: string | undefined;
21 | }
22 |
23 | export const resolvers: IResolvers = {
24 | /*
25 | http://dev.apollodata.com/tools/graphql-tools/resolvers.html
26 |
27 | Resolver function signature:
28 | fieldName(obj, args, context, info) { result }
29 |
30 | obj: The object that contains the result returned from the
31 | resolver on the parent field, or, in the case of a top-level
32 | Query field, the rootValue passed from the server configuration.
33 | This argument enables the nested nature of GraphQL queries.
34 |
35 | context: This is an object shared by all resolvers in a particular
36 | query, and is used to contain per-request state, including
37 | authentication information, dataloader instances, and anything
38 | else that should be taken into account when resolving the query
39 | */
40 |
41 | /* QUERY RESOLVERS */
42 |
43 | Query: {
44 | async comment(_, { id }, context): Promise {
45 | return context.commentService.getComment(id);
46 | },
47 |
48 | async feed(root, { type, first, skip }, context): Promise<(NewsItemModel | void)[]> {
49 | const limit = first < 1 || first > 30 ? 30 : first; // Could put this constant limit of 30 items into config
50 |
51 | return context.feedService.getForType(type, limit, skip);
52 | },
53 |
54 | async me(_, __, context): Promise {
55 | logger('Me: userId:', context.userId);
56 |
57 | return typeof context.userId === 'string'
58 | ? context.userService.getUser(context.userId)
59 | : undefined;
60 | },
61 |
62 | async newsItem(_, { id }, context): Promise {
63 | return context.newsItemService.getNewsItem(id);
64 | },
65 |
66 | async user(_, { id }, context): Promise {
67 | return context.userService.getUser(id);
68 | },
69 | },
70 |
71 | /* MUTATION RESOLVERS */
72 |
73 | Mutation: {
74 | async upvoteNewsItem(_, { id }, context): Promise {
75 | if (!context.userId) throw new Error('Must be logged in to vote.');
76 |
77 | return context.newsItemService.upvoteNewsItem(id, context.userId);
78 | },
79 |
80 | async hideNewsItem(_, { id }, context): Promise {
81 | if (!context.userId) throw new Error('Must be logged in to hide post.');
82 |
83 | return context.newsItemService.hideNewsItem(id, context.userId);
84 | },
85 |
86 | async submitNewsItem(_, newsItem, context): Promise {
87 | if (!context.userId) throw new Error('Must be logged in to submit a news item.');
88 |
89 | return context.newsItemService.submitNewsItem({ ...newsItem, submitterId: context.userId });
90 | },
91 | },
92 |
93 | /* GRAPHQL TYPE RESOLVERS */
94 |
95 | Comment: {
96 | async author(comment, _, context): Promise {
97 | return context.userService.getUser(comment.submitterId);
98 | },
99 | async comments(comment, _, context): Promise {
100 | return context.commentService.getComments(comment.comments);
101 | },
102 | upvoted(comment, _, context): boolean {
103 | return comment.upvotes.includes(context.userId);
104 | },
105 | },
106 |
107 | Date: new GraphQLScalarType({
108 | // http://dev.apollodata.com/tools/graphql-tools/scalars.html#Date-as-a-scalar
109 | name: 'Date',
110 | description: 'UTC number of milliseconds since midnight Jan 1 1970 as in JS date',
111 | parseValue(value: any): number {
112 | // Turn an input into a date which we want as a number
113 | // value from the client
114 | return new Date(value).valueOf();
115 | },
116 | serialize(value: any): number {
117 | // Convert Date to number primitive .getTime() or .valueOf()
118 | // value sent to the client
119 | return value instanceof Date ? value.valueOf() : value;
120 | },
121 | parseLiteral(ast): number | null {
122 | // ast value is always in string format
123 | // parseInt turns a string number into number of a certain base
124 | return ast.kind === Kind.INT ? parseInt(ast.value, 10) : null;
125 | },
126 | }),
127 |
128 | NewsItem: {
129 | async author(newsItem, _, context): Promise {
130 | return context.userService.getUser(newsItem.submitterId);
131 | },
132 | async comments(newsItem, _, context): Promise {
133 | return context.commentService.getComments(newsItem.comments);
134 | },
135 | hidden(newsItem: NewsItemModel, _, context): boolean {
136 | return newsItem.hides.includes(context.userId!);
137 | },
138 | upvoted(newsItem: NewsItemModel, _, context): boolean {
139 | return newsItem.upvotes.includes(context.userId);
140 | },
141 | },
142 |
143 | User: {
144 | async posts(user, _, context): Promise {
145 | return context.userService.getPostsForUser(user.id);
146 | },
147 | },
148 | };
149 |
--------------------------------------------------------------------------------
/server/graphql-schema.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | /*
4 | Schema properties are in following order:
5 | Alphabetical
6 | Resolved fields (requires extra db work)
7 |
8 | Comments are provided when property is not obvious
9 | */
10 | export const typeDefs = gql(`
11 | type Comment {
12 | id: Int!
13 |
14 | creationTime: Date!
15 |
16 | comments: [Comment]!
17 |
18 | # The ID of the item to which the comment was made on
19 | parent: Int!
20 |
21 | # The ID of the user who submitted the comment
22 | submitterId: String!
23 |
24 | text: String
25 |
26 | # Whether the currently logged in user has upvoted the comment
27 | upvoted: Boolean!
28 |
29 | # The User who submitted the comment
30 | author: User
31 | }
32 |
33 | scalar Date
34 |
35 | # A list of options for the sort order of the feed
36 | enum FeedType {
37 | # Sort by a combination of freshness and score, using an algorithm (Could use Reddit's)
38 | top
39 |
40 | # Newest entries first
41 | new
42 |
43 | # Sort by score
44 | best
45 |
46 | # SHOW HN articles
47 | show
48 |
49 | # ASK HN articles
50 | ask
51 |
52 | # Job listings
53 | job
54 | }
55 |
56 | type NewsItem {
57 |
58 | id: Int!
59 |
60 | comments: [Comment]!
61 |
62 | commentCount: Int!
63 |
64 | creationTime: Date!
65 |
66 | # List of user ids who have hidden this post
67 | hides: [String]!
68 |
69 | # Whether the currently logged in user has hidden the post
70 | hidden: Boolean!
71 |
72 | # The ID of the news item submitter
73 | submitterId: String!
74 |
75 | # The news item headline
76 | title: String!
77 |
78 | text: String
79 |
80 | # Whether the currently logged in user has upvoted the post
81 | upvoted: Boolean!
82 |
83 | upvotes: [String]!
84 |
85 | upvoteCount: Int!
86 |
87 | url: String
88 |
89 | # Fetches the author based on submitterId
90 | author: User
91 | }
92 |
93 | type User {
94 | # The user ID is a string of the username
95 | id: String!
96 |
97 | about: String
98 |
99 | creationTime: Date!
100 |
101 | dateOfBirth: Date
102 |
103 | email: String
104 |
105 | favorites: [Int]
106 |
107 | firstName: String
108 |
109 | hides: [Int]!
110 |
111 | karma: Int!
112 |
113 | lastName: String
114 |
115 | likes: [Int]!
116 |
117 | posts: [Int]!
118 | }
119 |
120 | # the schema allows the following queries:
121 | type Query {
122 | # A comment, it's parent could be another comment or a news item.
123 | comment(id: Int!): Comment
124 |
125 | feed(
126 | # The sort order for the feed
127 | type: FeedType!,
128 |
129 | # The number of items to fetch (starting from the skip index), for pagination
130 | first: Int
131 |
132 | # The number of items to skip, for pagination
133 | skip: Int,
134 | ): [NewsItem]
135 |
136 | # The currently logged in user or null if not logged in
137 | me: User
138 |
139 | # A news item
140 | newsItem(id: Int!): NewsItem
141 |
142 | # A user
143 | user(id: String!): User
144 | }
145 |
146 | # This schema allows the following mutations:
147 | type Mutation {
148 | upvoteNewsItem (
149 | id: Int!
150 | ): NewsItem
151 |
152 | hideNewsItem (
153 | id: Int!
154 | ): NewsItem
155 |
156 | submitNewsItem (
157 | title: String!
158 | url: String
159 | text: String
160 | ): NewsItem
161 | }
162 | `);
163 |
164 | // Example query
165 | // query {
166 | // feed(type: top, first: 30, skip: 0) {
167 | // id
168 | // submitterId
169 | // author {
170 | // id
171 | // email
172 | // }
173 | // url
174 | // title
175 | // text
176 | // comments {
177 | // id
178 | // }
179 | // commentCount
180 | // upvotes
181 | // upvoteCount
182 | // }
183 | // }
184 |
--------------------------------------------------------------------------------
/server/server.ts:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from 'apollo-server-express';
2 | import { urlencoded } from 'body-parser';
3 | import cookieParser from 'cookie-parser';
4 | import express from 'express';
5 | import session from 'express-session';
6 | import { initializeApp } from 'firebase/app';
7 | import { getDatabase, ref } from 'firebase/database';
8 | import nextApp from 'next';
9 | import passport from 'passport';
10 | import { Strategy } from 'passport-local';
11 | import { parse } from 'url';
12 | import dotenv from 'dotenv';
13 |
14 | dotenv.config();
15 | /* eslint-disable import/first */
16 |
17 | import {
18 | APP_PORT,
19 | dev,
20 | GRAPHQL_PATH,
21 | HN_API_VERSION,
22 | HN_DB_URI,
23 | useGraphqlPlayground,
24 | } from '../src/config';
25 | import { UserModel } from '../src/data/models';
26 | import { HnCache } from './database/cache';
27 | import { seedCache, warmCache } from './database/cache-warmer';
28 | import { HnDatabase } from './database/database';
29 | import { IGraphQlSchemaContext, resolvers } from './graphql-resolvers';
30 | import { typeDefs } from './graphql-schema';
31 | import { CommentService } from './services/comment-service';
32 | import { FeedService } from './services/feed-service';
33 | import { NewsItemService } from './services/news-item-service';
34 | import { UserService } from './services/user-service';
35 | import { ServerResponse } from 'http';
36 |
37 | const ONE_MINUTE = 1000 * 60;
38 | const SEVEN_DAYS = 1000 * 60 * 60 * 24 * 7;
39 |
40 | // Seed the in-memory data using the HN api
41 | const delay = dev ? ONE_MINUTE : 0;
42 |
43 | const app = nextApp({ dev });
44 | const handle = app.getRequestHandler();
45 |
46 | app
47 | .prepare()
48 | .then(async () => {
49 | const firebaseApp = initializeApp({ databaseURL: HN_DB_URI });
50 | const firebaseDb = getDatabase(firebaseApp); //Firebase.database().ref(HN_API_VERSION);
51 | const firebaseRef = ref(firebaseDb, HN_API_VERSION);
52 |
53 | const cache = new HnCache();
54 | const db = new HnDatabase(firebaseRef, cache);
55 | seedCache(db, cache, delay);
56 |
57 | const commentService = new CommentService(db, cache);
58 | const feedService = new FeedService(db, cache);
59 | const newsItemService = new NewsItemService(db, cache);
60 | const userService = new UserService(db, cache);
61 |
62 | const expressServer = express();
63 |
64 | /* BEGIN PASSPORT.JS AUTHENTICATION */
65 |
66 | passport.use(
67 | new (Strategy as any)(
68 | {
69 | usernameField: 'id',
70 | },
71 | async (username, password, done) => {
72 | const user = await userService.getUser(username);
73 | if (!user) {
74 | return done(null, false, { message: 'Incorrect username.' });
75 | }
76 |
77 | if (!(await userService.validatePassword(username, password))) {
78 | return done(null, false, { message: 'Incorrect password.' });
79 | }
80 |
81 | return done(null, user);
82 | }
83 | )
84 | );
85 |
86 | /*
87 | In this example, only the user ID is serialized to the session,
88 | keeping the amount of data stored within the session small. When
89 | subsequent requests are received, this ID is used to find the user,
90 | which will be restored to req.user.
91 | */
92 | passport.serializeUser((user: unknown, cb) => {
93 | cb(null, (user as UserModel).id);
94 | });
95 | passport.deserializeUser((id: string, cb) => {
96 | (async (): Promise => {
97 | const user = await userService.getUser(id);
98 |
99 | cb(null, user || null);
100 | })();
101 | });
102 |
103 | expressServer.use(cookieParser('mysecret'));
104 | expressServer.use(
105 | session({
106 | cookie: { maxAge: SEVEN_DAYS }, // Requires https: secure: false
107 | resave: false,
108 | rolling: true,
109 | saveUninitialized: false,
110 | secret: 'mysecret',
111 | })
112 | );
113 | expressServer.use(passport.initialize());
114 | expressServer.use(urlencoded({ extended: false }) as express.Handler);
115 | expressServer.use(passport.session());
116 |
117 | expressServer.post(
118 | '/login',
119 | (req, res, next) => {
120 | // @ts-ignore returnTo is an undocumented feature of passportjs
121 | req.session!.returnTo = req.body.goto;
122 | next();
123 | },
124 | passport.authenticate('local', {
125 | failureRedirect: '/login?how=unsuccessful',
126 | successReturnToOrRedirect: '/',
127 | })
128 | );
129 | expressServer.post(
130 | '/register',
131 | async (req, res, next) => {
132 | if (!req.user) {
133 | try {
134 | await userService.registerUser({
135 | id: req.body.id,
136 | password: req.body.password,
137 | });
138 | // @ts-ignore returnTo is an undocumented feature of passportjs
139 | req.session!.returnTo = `/user?id=${req.body.id}`;
140 | } catch (err) {
141 | // @ts-ignore returnTo is an undocumented feature of passportjs
142 | req.session!.returnTo = `/login?how=${err.code}`;
143 | }
144 | } else {
145 | // @ts-ignore returnTo is an undocumented feature of passportjs
146 | req.session!.returnTo = '/login?how=user';
147 | }
148 | next();
149 | },
150 | passport.authenticate('local', {
151 | failureRedirect: '/login?how=unsuccessful',
152 | successReturnToOrRedirect: '/',
153 | })
154 | );
155 | expressServer.get('/logout', (req, res) => {
156 | req.logout();
157 | res.redirect('/');
158 | });
159 |
160 | /* END PASSPORT.JS AUTHENTICATION */
161 |
162 | /* BEGIN GRAPHQL */
163 |
164 | const apolloServer = new ApolloServer({
165 | context: ({ req }): IGraphQlSchemaContext => ({
166 | commentService,
167 | feedService,
168 | newsItemService,
169 | userService,
170 | userId: (req.user as UserModel)?.id,
171 | }),
172 | introspection: true,
173 | playground: useGraphqlPlayground,
174 | resolvers,
175 | typeDefs,
176 | } as any);
177 | await apolloServer.start();
178 | apolloServer.applyMiddleware({ app: expressServer, path: GRAPHQL_PATH });
179 |
180 | /* END GRAPHQL */
181 |
182 | /* BEGIN EXPRESS ROUTES */
183 |
184 | // This is how to render a masked route with NextJS
185 | // server.get('/p/:id', (req, res) => {
186 | // const actualPage = '/post';
187 | // const queryParams = { id: req.params.id };
188 | // app.render(req, res, actualPage, queryParams);
189 | // });
190 |
191 | expressServer.get('/news', (req, res) => {
192 | const actualPage = '/';
193 | void app.render(req, res as ServerResponse, actualPage);
194 | });
195 |
196 | expressServer.get('*', (req, res) => {
197 | // Be sure to pass `true` as the second argument to `url.parse`.
198 | // This tells it to parse the query portion of the URL.
199 | const parsedUrl = parse(req.url, true);
200 |
201 | void handle(req, res as ServerResponse, parsedUrl);
202 | });
203 |
204 | /* END EXPRESS ROUTES */
205 |
206 | warmCache(db, cache, feedService);
207 |
208 | expressServer.listen(APP_PORT, () => {
209 | console.log(`> App listening on port ${APP_PORT}`);
210 | console.log(`> GraphQL ready on ${GRAPHQL_PATH}`);
211 | console.log(`> GraphQL Playground is ${useGraphqlPlayground ? '' : 'not '}enabled`);
212 | console.log(`Dev: ${String(dev)}`);
213 | });
214 | })
215 | .catch((err) => {
216 | console.error((err as Error).stack);
217 | process.exit(1);
218 | });
219 |
--------------------------------------------------------------------------------
/server/services/comment-service.spec.ts:
--------------------------------------------------------------------------------
1 | import { CommentService } from './comment-service';
2 |
3 | describe('NewsItem Model', () => {
4 | it('gets a single comment', () => {
5 | const id = 100;
6 | const comment = CommentService.getComment(id);
7 |
8 | expect(comment).toBeDefined();
9 | });
10 |
11 | it('gets an array of comments', () => {
12 | const ids = [1, 3, 100];
13 | const comments = CommentService.getComments(ids);
14 |
15 | expect(comments).toBeDefined();
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/server/services/comment-service.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import type { CommentModel } from '../../src/data/models';
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): Promise {
20 | return (
21 | this.cache.getComment(id) ||
22 | this.db.fetchComment(id).catch((reason) => logger('Rejected comment:', reason))
23 | );
24 | }
25 |
26 | async getComments(ids: number[]): Promise | void> {
27 | return Promise.all(ids.map((commentId) => this.getComment(commentId)))
28 | .then((comments): CommentModel[] =>
29 | comments.filter((comment): comment is CommentModel => comment !== undefined)
30 | )
31 | .catch((reason) => logger('Rejected comments:', reason));
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/server/services/feed-service.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { FeedType, NewsItemModel } from '../../src/data/models';
4 | import { sampleData } from '../../src/data/sample-data';
5 | import { HnCache } from '../database/cache';
6 | import { HnDatabase } from '../database/database';
7 |
8 | const logger = debug('app:Feed');
9 | logger.log = console.log.bind(console);
10 |
11 | export class FeedService {
12 | db: HnDatabase;
13 | cache: HnCache;
14 |
15 | constructor(db: HnDatabase, cache: HnCache) {
16 | this.db = db;
17 | this.cache = cache;
18 | }
19 |
20 | public async getForType(
21 | type: FeedType,
22 | first: number,
23 | skip: number
24 | ): Promise> {
25 | logger('Get first', first, type, 'stories skip', skip);
26 |
27 | switch (type) {
28 | case FeedType.TOP:
29 | // In this app the HN data is reconstructed in-memory
30 | return Promise.all(
31 | this.cache.top
32 | .slice(skip, first + skip)
33 | .map((id) => this.cache.getNewsItem(id) || this.db.fetchNewsItem(id))
34 | );
35 |
36 | case FeedType.NEW:
37 | return Promise.all(
38 | this.cache.new
39 | .slice(skip, first + skip)
40 | .map((id) => this.cache.getNewsItem(id) || this.db.fetchNewsItem(id))
41 | );
42 |
43 | case FeedType.BEST:
44 | return Promise.all(
45 | this.cache.best
46 | .slice(skip, first + skip)
47 | .map((id) => this.cache.getNewsItem(id) || this.db.fetchNewsItem(id))
48 | );
49 |
50 | case FeedType.SHOW:
51 | return this.cache.showNewsItems.slice(skip, first + skip);
52 |
53 | case FeedType.ASK:
54 | return this.cache.askNewsItems.slice(skip, first + skip);
55 |
56 | case FeedType.JOB:
57 | return this.cache.jobNewsItems.slice(skip, first + skip);
58 |
59 | default:
60 | return sampleData.newsItems.slice(skip, skip + first);
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/server/services/news-item-service.spec.ts:
--------------------------------------------------------------------------------
1 | import { NewsItemService } from './news-item-service';
2 |
3 | describe('NewsItem Model', () => {
4 | it('gets a single News Item', () => {
5 | const id = 1224;
6 | const newsItem = NewsItemService.getNewsItem(id);
7 |
8 | expect(newsItem).toBeDefined();
9 | });
10 |
11 | it('gets multiple News Items', () => {
12 | const ids = [1224, 1225];
13 | const newsItems = NewsItemService.getNewsItems(ids);
14 |
15 | expect(newsItems).toBeDefined();
16 | });
17 |
18 | it('upvotes a News Item', () => {
19 | const id = 1224;
20 | const userId = 'testuser';
21 | const newsItem = NewsItemService.upvoteNewsItem(id, userId);
22 |
23 | expect(newsItem).toBeDefined();
24 | });
25 |
26 | it('submits a new News Item', () => {
27 | const submitterId = 'clinton';
28 | const title = 'wow.';
29 | const url = 'http://www.google.com';
30 | const text = undefined;
31 | const newsItem = NewsItemService.submitNewsItem({ submitterId, text, title, url });
32 |
33 | expect(newsItem).toBeDefined();
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/server/services/news-item-service.ts:
--------------------------------------------------------------------------------
1 | import { debug } from 'debug';
2 |
3 | import { NewsItemModel } from '../../src/data/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 NewsItemService {
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 getNewsItem(id: number): Promise {
22 | return this.cache.getNewsItem(id) || this.db.getNewsItem(id) || this.db.fetchNewsItem(id);
23 | }
24 |
25 | async getNewsItems(ids: number[]): Promise | void> {
26 | return Promise.all(ids.map((id) => this.getNewsItem(id)))
27 | .then((newsItems) => newsItems.filter((newsItem) => newsItem !== undefined))
28 | .catch((reason) => logger('Rejected News Items:', reason));
29 | }
30 |
31 | async upvoteNewsItem(id: number, userId: string): Promise {
32 | return this.db.upvoteNewsItem(id, userId);
33 | }
34 |
35 | async hideNewsItem(id: number, userId: string): Promise {
36 | return this.db.hideNewsItem(id, userId);
37 | }
38 |
39 | async submitNewsItem({ submitterId, title, text, url }): Promise {
40 | const newsItem = new NewsItemModel({
41 | id: (newPostIdCounter += 1),
42 | submitterId,
43 | text,
44 | title,
45 | upvoteCount: 1,
46 | upvotes: [submitterId],
47 | url,
48 | });
49 |
50 | return this.db.submitNewsItem(newsItem.id, newsItem);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/services/user-service.ts:
--------------------------------------------------------------------------------
1 | import { passwordIterations } from '../../src/config';
2 | import { NewsItemModel, UserModel } from '../../src/data/models';
3 | import { validateNewUser } from '../../src/data/validation/user';
4 | import { createHash, createSalt } from '../../src/helpers/hash-password';
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 |
--------------------------------------------------------------------------------
/src/@types/global.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
--------------------------------------------------------------------------------
/src/README.md:
--------------------------------------------------------------------------------
1 | ### Directory Structure
2 |
3 | Nothing in this folder should import from the `pages` or `server` folder since code in here is intended to run on the client and server.
4 |
5 | `src` - Source code
6 |
7 | - `__tests__`: Jest tests colocated in each folder.
8 | - `components`: React components with GraphQL fragments colocated in-file.
9 | - `data`: GraphQL Schema, HN Web APIs, cache, sample data.
10 | - - `models`: Data model objects . Used to create, fetch and set data eg. in GraphQL schema resolvers.
11 | - `helpers`: Helper functions and classes.
12 | - `layouts`: Every page uses a layout. It's a React component that can take children.
13 | - The current folder contains a `config.ts` file for app config.
14 |
--------------------------------------------------------------------------------
/src/__tests__/active.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 |
3 | import Page from '../../pages/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 '../../pages/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 '../../pages/best';
4 |
5 | describe('Best Posts Page', () => {
6 | it('has default export', () => {
7 | expect(Page).toBeDefined();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/__tests__/bestcomments.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 |
3 | import Page from '../../pages/bestcomments';
4 |
5 | describe('Best Comments 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 '../../pages/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 '../../pages/changepw';
4 |
5 | describe('Change Password Page', () => {
6 | it('has default export', () => {
7 | expect(Page).toBeDefined();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/__tests__/dmca.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 |
3 | import Page from '../../pages/dmca';
4 |
5 | describe('DMCA 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 '../../pages/formatdoc';
4 |
5 | describe('Format Doc 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 '../../pages/front';
4 |
5 | describe('Front 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 '../../pages/hidden';
4 |
5 | describe('Hidden Posts 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 '../../pages/index';
4 |
5 | describe('Home 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 '../../pages/item';
4 |
5 | describe('News Item 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 '../../pages/jobs';
4 |
5 | describe('Jobs 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 '../../pages/leaders';
4 |
5 | describe('Leaders 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 '../../pages/lists';
4 |
5 | describe('Lists 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 '../../pages/login';
4 |
5 | describe('Login 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 '../../pages/newcomments';
4 |
5 | describe('New Comments 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 '../../pages/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 '../../pages/newpoll';
4 |
5 | describe('New Poll 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 '../../pages/newsfaq';
4 |
5 | describe('FAQ 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 '../../pages/newsguidelines';
4 |
5 | describe('Guidelines 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 '../../pages/newswelcome';
4 |
5 | describe('Welcome 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 '../../pages/noobcomments';
4 |
5 | describe('Noob Comments 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 '../../pages/noobstories';
3 |
4 | describe('Noob Stories Page', () => {
5 | it('has default export', () => {
6 | expect(Page).toBeDefined();
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/src/__tests__/reply.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 |
3 | import Page from '../../pages/reply';
4 |
5 | describe('Reply 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 '../../pages/security';
4 |
5 | describe('Security 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 '../../pages/show';
4 |
5 | describe('Show 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 '../../pages/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 '../../pages/shownew';
4 |
5 | describe('Show New Page', () => {
6 | it('has default export', () => {
7 | expect(Page).toBeDefined();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/__tests__/submit.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 |
3 | import Page from '../../pages/submit';
4 |
5 | describe('Submit 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 '../../pages/submitted';
4 |
5 | describe('Submitted Posts 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 '../../pages/threads';
4 |
5 | describe('Threads 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 '../../pages/user';
4 |
5 | describe('User Page', () => {
6 | it('has default export', () => {
7 | expect(Page).toBeDefined();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/comment-box.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 |
7 |
10 |
11 |
15 |
20 |
25 |
30 |
35 |
36 |
37 |
41 |
42 |
43 |
44 |
45 |
46 | `;
47 |
--------------------------------------------------------------------------------
/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 |
88 |
89 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | `;
125 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/news-detail.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`NewsFeed component renders news items passed in as props 1`] = `
4 |
5 |
51 |
52 | `;
53 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/news-title.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`NewsTitle component renders news item properties passed in as props 1`] = `
4 |
5 |
65 |
66 | `;
67 |
--------------------------------------------------------------------------------
/src/components/comment-box.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 | import * as React from 'react';
3 | import { render } from '@testing-library/react';
4 |
5 | import { CommentBox } from './comment-box';
6 | import { sampleData } from '../data/sample-data';
7 |
8 | describe('Comment component', () => {
9 | it('renders at different indentation levels', () => {
10 | const wrapper = render( );
11 |
12 | expect(wrapper.baseElement).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/comment-box.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export function CommentBox(): JSX.Element {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/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 |
6 | import { sampleData } from '../data/sample-data';
7 | import { Comment } from './comment';
8 |
9 | const comment = sampleData.comments[0];
10 | // Snapshot will be out of date if we don't use consistent time ago for comment
11 | MockDate.set(1506022129802);
12 |
13 | describe('Comment component', () => {
14 | it('renders at different indentation levels', () => {
15 | const wrapper = render( );
16 | expect(wrapper.baseElement).toMatchSnapshot();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/components/comment.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 | import renderHTML from 'react-render-html';
4 |
5 | import { convertNumberToTimeAgo } from '../helpers/convert-number-to-time-ago';
6 |
7 | export interface ICommentProps {
8 | id: number;
9 | creationTime: number;
10 | indentationLevel: number;
11 | submitterId: string;
12 | text: string;
13 | }
14 |
15 | export const commentFragment = `
16 | fragment Comment on Comment {
17 | id
18 | creationTime
19 | comments {
20 | id
21 | creationTime
22 | submitterId
23 | text
24 | }
25 | submitterId
26 | text
27 | }
28 | `;
29 |
30 | export class Comment extends React.Component {
31 | render(): JSX.Element {
32 | const { id, creationTime, indentationLevel, submitterId, text } = this.props;
33 |
34 | const vote = (): void => {
35 | return undefined;
36 | };
37 |
38 | const toggle = (): void => {
39 | return undefined;
40 | };
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
55 |
56 |
57 |
66 |
67 |
68 |
87 |
88 |
89 |
90 | {renderHTML(text)}
91 |
92 |
93 |
94 |
95 | reply
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | );
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/comments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsItemModel } from '../data/models';
4 | import { Comment, commentFragment } from './comment';
5 |
6 | export interface ICommentsProps {
7 | newsItem: NewsItemModel;
8 | }
9 |
10 | export const commentsFragment = `
11 | fragment Comments on Comment {
12 | id
13 | comments {
14 | id
15 | comments {
16 | id
17 | comments {
18 | id
19 | comments {
20 | id
21 | ...Comment
22 | }
23 | ...Comment
24 | }
25 | ...Comment
26 | }
27 | ...Comment
28 | }
29 | ...Comment
30 | }
31 | ${commentFragment}
32 | `;
33 |
34 | export class Comments extends React.Component {
35 | renderComment = (comment, indent: number): JSX.Element => {
36 | return (
37 |
38 | );
39 | };
40 |
41 | render(): JSX.Element {
42 | const { newsItem } = this.props;
43 |
44 | const rows: JSX.Element[] = [];
45 |
46 | newsItem.comments.forEach((rootComment) => {
47 | rows.push(this.renderComment(rootComment, 0));
48 |
49 | rootComment.comments.forEach((commentOne) => {
50 | rows.push(this.renderComment(commentOne, 1));
51 |
52 | commentOne.comments.forEach((commentTwo) => {
53 | rows.push(this.renderComment(commentTwo, 2));
54 |
55 | commentTwo.comments.forEach((commentThree) => {
56 | rows.push(this.renderComment(commentThree, 3));
57 |
58 | commentThree.comments.forEach((commentFour) => {
59 | rows.push(this.renderComment(commentFour, 4));
60 |
61 | commentFour.comments.forEach((commentFive) => {
62 | rows.push(this.renderComment(commentFive, 5));
63 | });
64 | });
65 | });
66 | });
67 | });
68 | });
69 |
70 | return (
71 |
74 | );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | export function Footer(): JSX.Element {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/header-nav.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | interface IHeaderNavProps {
5 | userId?: string;
6 | currentUrl: string;
7 | isNavVisible: boolean;
8 | title: string;
9 | }
10 |
11 | export function HeaderNav(props: IHeaderNavProps): JSX.Element {
12 | const { userId, currentUrl, isNavVisible, title } = props;
13 |
14 | return isNavVisible ? (
15 |
16 |
17 |
18 | {title}
19 |
20 |
21 |
22 | {userId && (
23 |
24 | welcome
25 |
26 | )}
27 | {userId && ' | '}
28 |
29 | new
30 |
31 | {userId && ' | '}
32 | {userId && (
33 |
34 | threads
35 |
36 | )}
37 | {' | '}
38 |
39 | comments
40 |
41 | {' | '}
42 |
43 | show
44 |
45 | {' | '}
46 |
47 | ask
48 |
49 | {' | '}
50 |
51 | jobs
52 |
53 | {' | '}
54 |
55 | submit
56 |
57 | {currentUrl === '/best' && ' | '}
58 | {currentUrl === '/best' && (
59 |
60 | best
61 |
62 | )}
63 |
64 | ) : (
65 |
66 | {title}
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import * as React from 'react';
3 |
4 | import { HeaderNav } from './header-nav';
5 |
6 | export interface IHeaderProps {
7 | me: { id: string; karma: number } | undefined;
8 | currentUrl: string;
9 | isNavVisible: boolean;
10 | title: string;
11 | }
12 |
13 | export function Header(props: IHeaderProps): JSX.Element {
14 | const { currentUrl, isNavVisible, me, title } = props;
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {me ? (
43 |
44 |
45 | {me.id}
46 |
47 | {` (${me.karma}) | `}
48 |
51 | logout
52 |
53 |
54 | ) : (
55 |
56 |
57 | login
58 |
59 |
60 | )}
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export function LoadingSpinner(): JSX.Element {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/news-detail.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 | import { MockedProvider } from '@apollo/client/testing';
3 | import { render } from '@testing-library/react';
4 | import MockDate from 'mockdate';
5 | import * as React from 'react';
6 |
7 | import { sampleData } from '../data/sample-data';
8 | import { NewsDetail } from './news-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('NewsFeed 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/components/news-detail.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import Link from 'next/link';
3 | import Router from 'next/router';
4 | import * as React from 'react';
5 |
6 | import { HIDE_NEWS_ITEM_MUTATION } from '../data/mutations/hide-news-item-mutation';
7 | import { convertNumberToTimeAgo } from '../helpers/convert-number-to-time-ago';
8 |
9 | export interface INewsDetailProps {
10 | commentCount: number;
11 | creationTime: number;
12 | hidden?: boolean;
13 | id: number;
14 | isFavoriteVisible?: boolean;
15 | isJobListing?: boolean;
16 | isPostScrutinyVisible?: boolean;
17 | submitterId: string;
18 | upvoteCount: number;
19 | }
20 |
21 | export const newsDetailNewsItemFragment = `
22 | fragment NewsDetail on NewsItem {
23 | id
24 | commentCount
25 | creationTime
26 | hidden
27 | submitterId
28 | upvoteCount
29 | }
30 | `;
31 |
32 | const HIDE_BUTTON_STYLE = { cursor: 'pointer' };
33 |
34 | export function NewsDetail(props: INewsDetailProps): JSX.Element {
35 | const {
36 | commentCount,
37 | creationTime,
38 | hidden,
39 | id,
40 | isFavoriteVisible = true,
41 | isJobListing = false,
42 | isPostScrutinyVisible = false,
43 | submitterId,
44 | upvoteCount,
45 | } = props;
46 |
47 | const [hideNewsItem] = useMutation(HIDE_NEWS_ITEM_MUTATION, {
48 | onError() {
49 | Router.push('/login', `/hide?id=${id}&how=up&goto=news`);
50 | },
51 | variables: { id },
52 | });
53 |
54 | const unhideNewsItem = (): void => undefined;
55 |
56 | return isJobListing ? (
57 |
58 |
59 |
60 |
61 |
62 | {convertNumberToTimeAgo(creationTime)}
63 |
64 |
65 |
66 |
67 | ) : (
68 |
69 |
70 |
71 | {upvoteCount} points
72 | {' by '}
73 |
74 | {submitterId}
75 | {' '}
76 |
77 |
78 | {convertNumberToTimeAgo(creationTime)}
79 |
80 |
81 | {' | '}
82 | {hidden ? (
83 | unhideNewsItem()} style={HIDE_BUTTON_STYLE}>
84 | unhide
85 |
86 | ) : (
87 | => hideNewsItem()} style={HIDE_BUTTON_STYLE}>
88 | hide
89 |
90 | )}
91 | {isPostScrutinyVisible && (
92 |
93 | {' | '}
94 |
95 | past
96 |
97 | {' | '}
98 | web
99 |
100 | )}
101 | {' | '}
102 |
103 |
104 | {commentCount === 0
105 | ? 'discuss'
106 | : commentCount === 1
107 | ? '1 comment'
108 | : `${commentCount} comments`}
109 |
110 |
111 | {isFavoriteVisible && ' | favorite'}
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/news-feed.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 | import { MockedProvider } from '@apollo/client/testing';
3 | import { render } from '@testing-library/react';
4 | import MockDate from 'mockdate';
5 | import * as React from 'react';
6 |
7 | import { sampleData } from '../data/sample-data';
8 | import { NewsFeedView } 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/news-feed.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { DataValue } from 'react-apollo';
3 |
4 | import { NewsItemModel } from '../data/models';
5 | import { LoadingSpinner } from './loading-spinner';
6 | import { NewsDetail, newsDetailNewsItemFragment } from './news-detail';
7 | import { NewsTitle, newsTitleFragment } from './news-title';
8 |
9 | export interface INewsFeedProps {
10 | currentUrl: string;
11 | first: number;
12 | isJobListing?: boolean;
13 | isPostScrutinyVisible?: boolean;
14 | isRankVisible?: boolean;
15 | isUpvoteVisible?: boolean;
16 | newsItems: Array;
17 | notice?: JSX.Element;
18 | skip: number;
19 | }
20 |
21 | export const newsFeedNewsItemFragment = `
22 | fragment NewsFeed on NewsItem {
23 | id
24 | hidden
25 | ...NewsTitle
26 | ...NewsDetail
27 | }
28 | ${newsTitleFragment}
29 | ${newsDetailNewsItemFragment}
30 | `;
31 |
32 | export function NewsFeedView(props: INewsFeedProps): JSX.Element {
33 | const {
34 | isPostScrutinyVisible = false,
35 | first,
36 | newsItems,
37 | notice = null,
38 | skip,
39 | isJobListing = false,
40 | isRankVisible = true,
41 | isUpvoteVisible = true,
42 | currentUrl,
43 | } = props;
44 |
45 | const nextPage = Math.ceil((skip || 1) / first) + 1;
46 |
47 | return (
48 |
49 |
50 |
59 |
60 | {notice && notice}
61 | <>
62 | {newsItems
63 | .filter((newsItem): newsItem is NewsItemModel => !!newsItem && !newsItem.hidden)
64 | .flatMap((newsItem, index) => [
65 | ,
72 | ,
79 | ,
80 | ])}
81 |
82 |
83 |
84 |
85 |
91 | More
92 |
93 |
94 |
95 | >
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | export interface INewsFeedData {
104 | error;
105 | feed;
106 | loading;
107 | }
108 | export interface INewsFeedContainerProps {
109 | currentUrl: string;
110 | data: DataValue;
111 | first: number;
112 | isJobListing?: boolean;
113 | isRankVisible?: boolean;
114 | isUpvoteVisible?: boolean;
115 | notice?: JSX.Element;
116 | skip: number;
117 | }
118 |
119 | export const NewsFeed: React.FC = (props) => {
120 | const { data, currentUrl, first, skip, notice } = props;
121 |
122 | if (data?.error) {
123 | return (
124 |
125 | Error loading news items.
126 |
127 | );
128 | }
129 |
130 | if (data?.feed?.length) {
131 | return (
132 |
139 | );
140 | }
141 |
142 | return ;
143 | };
144 |
--------------------------------------------------------------------------------
/src/components/news-item-with-comments.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { NewsItemModel } from '../data/models';
4 | import { CommentBox } from './comment-box';
5 | import { Comments } from './comments';
6 | import { LoadingSpinner } from './loading-spinner';
7 | import { NewsDetail } from './news-detail';
8 | import { NewsTitle } from './news-title';
9 |
10 | export interface INewsItemWithCommentsProps {
11 | error: Error;
12 | loading: boolean;
13 | newsItem: NewsItemModel;
14 | }
15 |
16 | /** Acts as the component for a page of a news item with all it's comments */
17 | export function NewsItemWithComments(props: INewsItemWithCommentsProps): JSX.Element {
18 | const { loading, error, newsItem } = props;
19 |
20 | if (error) {
21 | return (
22 |
23 | Error loading news items.
24 |
25 | );
26 | }
27 |
28 | if (loading || !newsItem || !newsItem.comments) {
29 | return ;
30 | }
31 |
32 | return (
33 |
34 |
35 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/news-title.spec.tsx:
--------------------------------------------------------------------------------
1 | /** @jest-environment jsdom */
2 | import { MockedProvider } from '@apollo/client/testing';
3 | import * as React from 'react';
4 | import MockDate from 'mockdate';
5 | import { render } from '@testing-library/react';
6 |
7 | import { NewsTitle } from './news-title';
8 | import { sampleData } from '../data/sample-data';
9 |
10 | MockDate.set(1506022129802);
11 |
12 | describe('NewsTitle 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/components/news-title.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import Router from 'next/router';
3 | import * as React from 'react';
4 | import { parse } from 'url';
5 |
6 | import { UPVOTE_NEWS_ITEM_MUTATION } from '../data/mutations/upvote-news-item-mutation';
7 |
8 | export interface INewsTitleProps {
9 | id: number;
10 | isRankVisible?: boolean;
11 | isUpvoteVisible?: boolean;
12 | rank?: number;
13 | title: string;
14 | url?: string;
15 | upvoted?: boolean;
16 | }
17 |
18 | export const newsTitleFragment = `
19 | fragment NewsTitle on NewsItem {
20 | id
21 | title
22 | url
23 | upvoted
24 | }
25 | `;
26 |
27 | export function NewsTitle(props: INewsTitleProps): JSX.Element {
28 | const { id, isRankVisible = true, isUpvoteVisible = true, rank, title, upvoted, url } = props;
29 |
30 | const [upvoteNewsItem] = useMutation(UPVOTE_NEWS_ITEM_MUTATION, {
31 | onError: () => Router.push('/login', `/vote?id=${id}&how=up&goto=news`),
32 | variables: { id },
33 | });
34 |
35 | return (
36 |
37 |
38 | {isRankVisible && `${rank}.`}
39 |
40 |
41 |
52 |
53 |
54 |
55 | {title}
56 |
57 | {url && (
58 |
59 | {' '}
60 | (
61 |
62 | {parse(url).hostname}
63 |
64 | )
65 |
66 | )}
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/config.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 | export const GRAPHQL_PATH = '/graphql';
17 | export const GRAPHIQL_PATH = '/graphiql';
18 | export const GRAPHQL_URI = ORIGIN + GRAPHQL_PATH;
19 | export const useGraphqlPlayground = true;
20 |
21 | /*
22 | Cryptography
23 | https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2_password_salt_iterations_keylen_digest_callback
24 | */
25 | export const passwordIterations = 10000;
26 |
27 | /* UI CONFIG */
28 | export const POSTS_PER_PAGE = 30;
29 |
--------------------------------------------------------------------------------
/src/data/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: string[];
13 |
14 | public readonly text: string;
15 |
16 | constructor(fields) {
17 | if (!fields.id) {
18 | throw new Error(`Error instantiating Comment, id invalid: ${fields.id}`);
19 | } else if (!fields.parent) {
20 | throw new Error(`Error instantiating Comment, parent invalid: ${fields.parent}`);
21 | } else if (!fields.submitterId) {
22 | throw new Error(`Error instantiating Comment, submitterId invalid: ${fields.submitterId}`);
23 | } else if (!fields.text) {
24 | throw new Error(`Error instantiating Comment, text invalid: ${fields.text}`);
25 | }
26 |
27 | this.id = fields.id;
28 | this.creationTime = fields.creationTime || +new Date();
29 | this.comments = fields.comments || [];
30 | this.parent = fields.parent;
31 | this.submitterId = fields.submitterId;
32 | this.text = fields.text;
33 | this.upvotes = fields.upvotes || [];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/data/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/data/models/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | Models are pure data representations with validation
3 | */
4 |
5 | export * from './feed';
6 | export * from './news-item-model';
7 | export * from './user-model';
8 | export * from './comment-model';
9 |
--------------------------------------------------------------------------------
/src/data/models/news-item-model.ts:
--------------------------------------------------------------------------------
1 | import { isValidUrl } from '../../helpers/is-valid-url';
2 |
3 | let newPostIdCounter = 100;
4 |
5 | export class NewsItemModel {
6 | /** ID of submitter */
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;
36 |
37 | public readonly url?: string;
38 |
39 | public readonly hidden?: boolean; // TODO: exists?
40 |
41 | public readonly rank?: number;
42 |
43 | constructor(fields) {
44 | if (!fields.id) {
45 | throw new Error(`Error instantiating News Item, id is required: ${fields.id}`);
46 | } else if (!fields.submitterId) {
47 | throw new Error(`Error instantiating News Item, submitterId is required: ${fields.id}`);
48 | } else if (!fields.title) {
49 | throw new Error(`Error instantiating News Item, title is required: ${fields.id}`);
50 | } else if (fields.url && !isValidUrl(fields.url)) {
51 | throw new Error(`Error instantiating News Item ${fields.id}, invalid URL: ${fields.url}`);
52 | }
53 |
54 | this.id = fields.id || (newPostIdCounter += 1);
55 | this.commentCount = fields.commentCount || 0;
56 | this.comments = fields.comments || [];
57 | this.creationTime = fields.creationTime || +new Date();
58 | this.hides = fields.hides || [];
59 | this.hiddenCount = this.hides.length;
60 | this.submitterId = fields.submitterId;
61 | this.text = fields.text || null;
62 | this.title = fields.title;
63 | this.upvoteCount = fields.upvoteCount || 1;
64 | this.upvotes = fields.upvotes || [fields.submitterId];
65 | this.url = fields.url;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/data/models/user-model.ts:
--------------------------------------------------------------------------------
1 | import { validateUsername } from '../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 | validateUsername(props);
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/data/mutations/hide-news-item-mutation.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const HIDE_NEWS_ITEM_MUTATION = gql`
4 | mutation HideNewsItem($id: Int!) {
5 | hideNewsItem(id: $id) {
6 | id
7 | hidden
8 | }
9 | }
10 | `;
11 |
--------------------------------------------------------------------------------
/src/data/mutations/submit-news-item-mutation.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | import { newsFeedNewsItemFragment } from '../../components/news-feed';
4 |
5 | export interface ISubmitNewsItemGraphQL {
6 | submitNewsItem: { id }; // Return type of submitNewsItem mutation
7 | }
8 |
9 | export const SUBMIT_NEWS_ITEM_MUTATION = gql`
10 | mutation SubmitNewsItem($title: String!, $url: String) {
11 | submitNewsItem(title: $title, url: $url) {
12 | id
13 | ...NewsFeed
14 | }
15 | }
16 | ${newsFeedNewsItemFragment}
17 | `;
18 |
--------------------------------------------------------------------------------
/src/data/mutations/upvote-news-item-mutation.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const UPVOTE_NEWS_ITEM_MUTATION = gql`
4 | mutation UpvoteNewsItem($id: Int!) {
5 | upvoteNewsItem(id: $id) {
6 | id
7 | upvoteCount
8 | upvoted
9 | }
10 | }
11 | `;
12 |
--------------------------------------------------------------------------------
/src/data/queries/me-query.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export interface IMeQuery {
4 | me: {
5 | id: string;
6 | karma: number;
7 | };
8 | }
9 |
10 | export const ME_QUERY = gql`
11 | query User {
12 | me {
13 | id
14 | karma
15 | }
16 | }
17 | `;
18 |
--------------------------------------------------------------------------------
/src/data/validation/user.ts:
--------------------------------------------------------------------------------
1 | import { ValidationError, ValidationCode } from './validation-error';
2 |
3 | export function validateUsername({ id }): boolean {
4 | if (id.length < 3 || id.length > 32) {
5 | throw new ValidationError({
6 | code: ValidationCode.ID,
7 | message: 'User ID must be between 3 and 32 characters.',
8 | });
9 | }
10 |
11 | return true;
12 | }
13 |
14 | export function validateNewUser({ id, password }): boolean {
15 | if (id.length < 3 || id.length > 32) {
16 | throw new ValidationError({
17 | code: ValidationCode.ID,
18 | message: 'User ID must be between 3 and 32 characters.',
19 | });
20 | }
21 |
22 | if (password.length < 8 || password.length > 100) {
23 | throw new ValidationError({
24 | code: ValidationCode.PASSWORD,
25 | message: 'User password must be longer than 8 characters.',
26 | });
27 | }
28 |
29 | return true;
30 | }
31 |
--------------------------------------------------------------------------------
/src/data/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/helpers/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/helpers/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(`convertNumberToTimeAgo: number ${number} timeAgo ${timeAgo}, is date older than 1970 or in the future?`)
39 | return '';
40 | }
41 | };
42 |
--------------------------------------------------------------------------------
/src/helpers/hash-password.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/helpers/init-apollo.tsx:
--------------------------------------------------------------------------------
1 | import { InMemoryCache, NormalizedCacheObject } from '@apollo/client/cache';
2 | import { ApolloClient, createHttpLink } from '@apollo/client';
3 | import { debug } from 'debug';
4 | import fetch from 'isomorphic-unfetch';
5 |
6 | import { IS_SERVER, GRAPHQL_URI } from '../config';
7 |
8 | const logger = debug('app:initApollo');
9 | logger.log = console.log.bind(console);
10 |
11 | let apolloClient: ApolloClient | null = null;
12 |
13 | function create(initialState, { getToken }): ApolloClient {
14 | return new ApolloClient({
15 | ssrMode: !IS_SERVER, // Disables forceFetch on the server (so queries are only run once)
16 | link: createHttpLink({
17 | uri: GRAPHQL_URI,
18 | credentials: 'same-origin',
19 | headers: {
20 | // HTTP Header: Cookie: =
21 | Cookie: `connect.sid=${getToken()['connect.sid']}`,
22 | },
23 | fetch,
24 | }),
25 | cache: new InMemoryCache().restore(initialState || {}),
26 | connectToDevTools: !IS_SERVER,
27 | });
28 | }
29 |
30 | export function initApollo(initialState, options): ApolloClient {
31 | // Make sure to create a new client for every server-side request so that data
32 | // isn't shared between connections (which would be bad)
33 | if (IS_SERVER) {
34 | return create(initialState, options);
35 | }
36 |
37 | // Reuse client at module scope on the client-side
38 | if (!apolloClient) {
39 | apolloClient = create(initialState, options);
40 | }
41 |
42 | return apolloClient;
43 | }
44 |
--------------------------------------------------------------------------------
/src/helpers/is-valid-url.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns whether a string is a valid URL
3 | */
4 | export function isValidUrl(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/helpers/user-login-error-code.ts:
--------------------------------------------------------------------------------
1 | export enum UserLoginErrorCode {
2 | INCORRECT_PASSWORD = 'pw',
3 | INVALID_ID = 'invalid_id',
4 | LOGGED_IN = 'loggedin',
5 | LOGIN_UNSUCCESSFUL = 'unsuccessful',
6 | LOGIN_UPVOTE = 'up',
7 | USERNAME_TAKEN = 'id',
8 | }
9 |
10 | const userLoginErrorCodeMessages: Record = {
11 | [UserLoginErrorCode.INCORRECT_PASSWORD]: 'Incorrect password.',
12 | [UserLoginErrorCode.INVALID_ID]: 'User ID must be between 3 and 32 characters.',
13 | [UserLoginErrorCode.LOGGED_IN]: 'Logged in user must logout before logging in again.',
14 | [UserLoginErrorCode.LOGIN_UNSUCCESSFUL]: 'Login unsuccessful.',
15 | [UserLoginErrorCode.LOGIN_UPVOTE]: 'You have to be logged in to vote.',
16 | [UserLoginErrorCode.USERNAME_TAKEN]: 'Username is taken.',
17 | };
18 |
19 | export function getErrorMessageForLoginErrorCode(code: UserLoginErrorCode): string {
20 | return userLoginErrorCodeMessages[code];
21 | }
22 |
--------------------------------------------------------------------------------
/src/helpers/with-data.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import { ApolloClient, ApolloProvider, NormalizedCacheObject } from '@apollo/client';
3 | import { getDataFromTree } from '@apollo/client/react/ssr';
4 | import * as cookie from 'cookie';
5 | import { debug } from 'debug';
6 | import { withRouter } from 'next/router';
7 | import * as React from 'react';
8 |
9 | import { initApollo } from './init-apollo';
10 | import { IS_SERVER } from '../config';
11 |
12 | const logger = debug('app:withData');
13 | logger.log = console.log.bind(console);
14 |
15 | function parseCookies(
16 | ctx: any = {},
17 | options = {}
18 | ): {
19 | [key: string]: string;
20 | } {
21 | const userCookie = cookie.parse(
22 | ctx.req && ctx.req.headers.cookie ? ctx.req.headers.cookie : '', // document.cookie,
23 | options
24 | );
25 |
26 | logger('Parsing cookie: ', userCookie);
27 | return userCookie;
28 | }
29 |
30 | export interface IWithDataProps {
31 | serverState: IWithDataServerState;
32 | dataContext: IWithDataContext;
33 | }
34 |
35 | export interface IWithDataServerState {
36 | apollo: { data };
37 | }
38 |
39 | export interface IWithDataContext {
40 | query: Q;
41 | pathname: string;
42 | }
43 |
44 | export type TComposedComponent = React.ComponentType & {
45 | getInitialProps?: (context, apollo) => any;
46 | };
47 |
48 | export function withData(
49 | ComposedComponent: TComposedComponent
50 | ): React.ComponentType {
51 | return class WithData extends React.Component {
52 | // Note: Apollo should never be used on the server side beyond the initial
53 | // render within `getInitialProps()` (since the entire prop tree
54 | // will be initialized there), meaning the below will only ever be
55 | // executed on the client.
56 | private apollo: ApolloClient = initApollo(
57 | this.props.serverState.apollo.data,
58 | { getToken: () => parseCookies() }
59 | );
60 |
61 | static displayName = `WithData(${ComposedComponent.displayName})`;
62 |
63 | static async getInitialProps(context): Promise<{ serverState } | void> {
64 | let serverState: IWithDataServerState = { apollo: { data: {} } };
65 |
66 | // Setup a server-side one-time-use apollo client for initial props and
67 | // rendering (on server)
68 | logger('getInitialProps with context:', context);
69 | const apollo = initApollo({}, { getToken: () => parseCookies(context) });
70 |
71 | // Evaluate the composed component's getInitialProps()
72 | const childInitialProps = ComposedComponent.getInitialProps
73 | ? await ComposedComponent.getInitialProps(context, apollo)
74 | : {};
75 |
76 | // Run all GraphQL queries from component tree and extract the resulting data
77 | if (IS_SERVER) {
78 | if (context.res && context.res.finished) {
79 | // When redirecting, the response is finished. No point in continuing to render
80 | return undefined;
81 | }
82 |
83 | // Provide the router prop in case a page needs it to render
84 | const router: IWithDataContext = { query: context.query, pathname: context.pathname };
85 |
86 | // Run all GraphQL queries
87 | const app = (
88 |
89 |
90 |
91 | );
92 |
93 | await getDataFromTree(app, {
94 | router: { query: context.query, pathname: context.pathname, asPath: context.asPath },
95 | });
96 |
97 | serverState = {
98 | apollo: {
99 | // Make sure to only include Apollo's data state
100 | data: apollo.cache.extract(), // Extract query data from the Apollo's store
101 | },
102 | };
103 | }
104 |
105 | return {
106 | serverState,
107 | ...childInitialProps,
108 | };
109 | }
110 |
111 | render(): JSX.Element {
112 | return (
113 |
114 |
115 |
116 | );
117 | }
118 | };
119 | }
120 |
121 | const compose = (...functions) => (args) => functions.reduceRight((arg, fn) => fn(arg), args);
122 | export const withDataAndRouter = compose(withRouter, withData);
123 |
--------------------------------------------------------------------------------
/src/layouts/blank-layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import * as React from 'react';
3 |
4 | export function BlankLayout(props): JSX.Element {
5 | const { children } = props;
6 |
7 | return (
8 |
9 |
10 |
Hacker News Clone
11 |
12 |
13 |
14 |
15 | {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/layouts/main-layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import * as React from 'react';
3 | import { useQuery } from '@apollo/client';
4 |
5 | import { Footer } from '../components/footer';
6 | import { Header } from '../components/header';
7 | import { IMeQuery, ME_QUERY } from '../data/queries/me-query';
8 |
9 | interface IMainLayoutProps {
10 | children: React.ReactChild;
11 | currentUrl: string;
12 | isNavVisible?: boolean;
13 | isUserVisible?: boolean;
14 | isFooterVisible?: boolean;
15 | title?: string;
16 | }
17 |
18 | export function MainLayout(props: IMainLayoutProps): JSX.Element {
19 | const { data } = useQuery(ME_QUERY);
20 |
21 | const {
22 | children,
23 | currentUrl,
24 | isNavVisible = true,
25 | isFooterVisible = true,
26 | title = 'Hacker News',
27 | } = props;
28 |
29 | return (
30 |
31 |
32 |
Hacker News Clone
33 |
34 |
35 |
36 |
37 |
38 |
51 |
52 |
58 |
59 | {children}
60 | {isFooterVisible && }
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/layouts/notice-layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import * as React from 'react';
3 |
4 | export function NoticeLayout(props): JSX.Element {
5 | const { children } = props;
6 |
7 | return (
8 |
9 |
10 |
Hacker News Clone
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/tsconfig-server.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react",
5 | "module": "commonjs",
6 | "outDir": "dist",
7 | "target": "es2017",
8 | "isolatedModules": false,
9 | "noEmit": false
10 | },
11 | "include": ["**/*.ts", "server/**/*.tsx"],
12 | "exclude": ["node_modules", "**/__mocks__*", "**/__tests__*", "**/*.spec.*", "out", ".next"]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "checkJs": false,
5 | "allowSyntheticDefaultImports": true,
6 | "esModuleInterop": true,
7 | "jsx": "preserve",
8 | "lib": [
9 | "dom",
10 | "dom.iterable",
11 | "esnext"
12 | ],
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "noEmit": false,
16 | "noImplicitAny": false,
17 | "outDir": "dist",
18 | "removeComments": true,
19 | "sourceMap": true,
20 | "target": "ES5",
21 | "typeRoots": [
22 | "./node_modules/@types",
23 | "src/@types"
24 | ],
25 | "skipLibCheck": true,
26 | "strict": true,
27 | "forceConsistentCasingInFileNames": true,
28 | "resolveJsonModule": true,
29 | "isolatedModules": true,
30 | "incremental": true
31 | },
32 | "include": [
33 | "pages",
34 | "**/*.ts",
35 | "**/*.tsx"
36 | ],
37 | "exclude": [
38 | "node_modules",
39 | "**/__mocks__*",
40 | "**/__tests__*",
41 | "**/*.spec.*",
42 | "out",
43 | ".next"
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
98 | This might help in forensic investigation afterwards. Less crap to wade through. 99 |
100 | 101 |105 | 106 | 109 | reply 110 | 111 | 112 |
113 |