diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 00000000..2b421f0d --- /dev/null +++ b/src/App.scss @@ -0,0 +1,24 @@ +@import "./scss/media"; +@import "./scss/theme_variables"; + +.body-cover { + width: 100%; + z-index: 0; + position: fixed; + height: 100%; +} + +.wrapper { + position: relative; + width: 85%; + min-height: 80px; + margin: 0 auto; + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + font-size: 15px; + height: 100%; + line-height: 1.3; + + @media #{$mobile-only} { + width: 100%; + } +} diff --git a/src/components/Footer.scss b/src/components/Footer.scss new file mode 100644 index 00000000..ec664bb1 --- /dev/null +++ b/src/components/Footer.scss @@ -0,0 +1,23 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +#footer { + position: relative; + padding: 10px; + height: 60px; + letter-spacing: 0.7px; + text-align: center; + + a { + font-weight: bold; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media #{$mobile-only} { + display: none; + } +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 00000000..dcd1c0f6 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,14 @@ +import './Footer.scss'; + +export default function Footer() { + return ( +
+ ); +} diff --git a/src/components/Header.scss b/src/components/Header.scss new file mode 100644 index 00000000..b97bc434 --- /dev/null +++ b/src/components/Header.scss @@ -0,0 +1,149 @@ +@import "../scss/media"; +@import "../scss/theme_variables"; + +#header { + color: #fff; + padding: 6px 0; + line-height: 18px; + vertical-align: middle; + position: relative; + z-index: 1; + width: 100%; + + @media #{$mobile-only} { + height: 50px; + position: fixed; + top: 0; + } + + a { + display: inline; + } +} + +.home-link { + width: 50px; + height: 66px; +} + +.logo-inner { + width: 32px; + position: absolute; + left: 17px; + top: 18px; + z-index: -1; + height: 32px; + border-radius: 50%; + + @media #{$mobile-only} { + left: 16px; + top: 12px; + } +} + +.logo { + width: 50px; + padding: 3px 8px 0; + + @media #{$mobile-only} { + width: 45px; + padding: 0 0 0 10px; + } +} + +h1 { + font-weight: normal; + display: inline-block; + vertical-align:middle; + margin: 0; + font-size: 16px; + + a { + color: #fff; + text-decoration: none; + } +} + +.name { + margin-right: 30px; + margin-bottom: 2px; + + @media #{$mobile-only} { + display: none; + } +} + +.header-text { + position: absolute; + width: inherit; + height: 20px; + left: 10px; + top: 27px; + z-index: -1; + + @media #{$mobile-only} { + top: 22px; + } +} + +.left { + position: absolute; + left: 60px; + font-size: 16px; + + @media #{$mobile-only} { + width: 100%; + left: 0; + } +} + +.header-nav { + display: inline-block; + margin-left: 20px; + + @media #{$mobile-only} { + margin-left: 60px; + } + + a { + color: hsla(0,0%,100%,.9); + text-decoration: none; + margin: 0 5px; + letter-spacing: 1.8px; + + &:hover { + color: #fff; + } + } + + .active { + color: #fff; + } +} + +.info { + position: absolute; + top: 0; + right: 20px; + height: 100%; + + @media #{$mobile-only} { + right: 10px; + } + + img { + opacity: 0.8; + width: 25px; + margin-top: 21.5px; + display: block; + + &:hover { + opacity: 1; + cursor: pointer; + } + + @media #{$mobile-only} { + margin-top: 15px; + } + } +} diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx new file mode 100644 index 00000000..6cedce5d --- /dev/null +++ b/src/components/Header.test.tsx @@ -0,0 +1,52 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SettingsProvider } from '../context/SettingsContext'; +import Header from './Header'; + +function renderHeader(path = '/news/1') { + return render( ++ These are jobs at startups that were funded by Y Combinator. You can also get a job at a YC startup + through Triplebyte. +
+ )} +Body copy
', + text: '', + comments: [], + comments_count: 1, + poll: [], + poll_votes_count: 0, + deleted: false, + dead: false, + ...overrides, + }; +} + +function makeComment(overrides: PartialBody copy
')); + expect(screen.getByText('Nested content')).toBeTruthy(); + expect(screen.getAllByText('1 comment').length).toBe(1); + }); + + it('renders poll results sized by their share of the votes', async () => { + stubFetch([ + makeItem({ type: 'poll', poll: [{ points: 0, content: 'A' }, { points: 0, content: 'B' }] }), + { points: 30, content: 'Option A' }, + { points: 10, content: 'Option B' }, + ]); + const { container } = renderItemDetails(); + + await waitFor(() => expect(screen.getByText('Option A')).toBeTruthy()); + expect(screen.getByText('30 points')).toBeTruthy(); + expect(container.querySelectorAll+ navigate(-1)}> + {external ? ( + + {item.title} + + ) : ( + + {item.title} + + )} +
++ + {item.title} + + {item.domain && ({item.domain})} +
+ ) : ( ++ + {item.title} + +
+ )} +` blocks inside the
+ about text injected from the API. */
+.profile pre {
+ white-space: pre-wrap;
+}
+
+.profile {
+ padding: 30px;
+}
+
+@media #{$mobile-only} {
+ .profile {
+ padding: 110px 15px 0 15px;
+ }
+ .title-block {
+ font-size: 15px;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin: 0 75px;
+ }
+ .back-button {
+ position: absolute;
+ top: 52%;
+ width: 0.6rem;
+ height: 0.6rem;
+ background: transparent;
+ box-shadow: 0 0 0 lightgray;
+ transition: all 200ms ease;
+ left: 4%;
+ transform: translate3d(0, -50%, 0) rotate(-135deg);
+ }
+ .item-header {
+ padding-bottom: 10px;
+ background-color: #fff;
+ padding: 10px 0 10px 0;
+ position: fixed;
+ width: 100%;
+ left: 0;
+ top: 62px;
+ height: 20px;
+ }
+}
+
+@media #{$laptop-only} {
+ .mobile {
+ display: none;
+ }
+}
+
+.main-details {
+ .name {
+ font-weight: bold;
+ font-size: 32px;
+ letter-spacing: 2px;
+ }
+ .age {
+ font-weight: bold;
+ color: #696969;
+ padding-bottom: 0;
+ }
+ .right {
+ float: right;
+ font-weight: bold;
+ font-size: 32px;
+ letter-spacing: 2px;
+ }
+}
+
+@media #{$mobile-only} {
+ .main-details {
+ margin-top: 20px;
+ .name {
+ font-size: 18px;
+ }
+ }
+}
+
+@media #{$mobile-only} {
+ .main-details .right {
+ font-size: 18px;
+ }
+}
+
+.other-details {
+ word-wrap: break-word;
+}
diff --git a/src/pages/User.test.tsx b/src/pages/User.test.tsx
new file mode 100644
index 00000000..d1a62455
--- /dev/null
+++ b/src/pages/User.test.tsx
@@ -0,0 +1,70 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { User as UserModel } from '../models/user';
+import User from './User';
+
+function stubFetch(user: UserModel | null) {
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
+ if (!user) {
+ return { ok: false, status: 404 } as Response;
+ }
+ return { ok: true, status: 200, json: async () => user } as Response;
+ });
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ return fetchMock;
+}
+
+function renderUser() {
+ return render(
+
+
+ } />
+
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('User', () => {
+ it('renders the profile of the routed user', async () => {
+ const fetchMock = stubFetch({
+ id: 'pg',
+ crated_time: 0,
+ created: 'January 1, 2007',
+ karma: 155000,
+ avg: 0,
+ about: 'Bio
',
+ });
+ const { container } = renderUser();
+
+ expect(fetchMock.mock.calls[0][0]).toBe('https://node-hnapi.herokuapp.com/user/pg');
+
+ await waitFor(() => expect(screen.getByText('155000 ★')).toBeTruthy());
+ expect(screen.getByText('Created January 1, 2007')).toBeTruthy();
+ expect(container.querySelector('.other-details')?.innerHTML).toContain('Bio
');
+ });
+
+ it('omits the about block when the user has no bio', async () => {
+ stubFetch({ id: 'pg', crated_time: 0, created: 'January 1, 2007', karma: 1, avg: 0, about: '' });
+ const { container } = renderUser();
+
+ await waitFor(() => expect(screen.getByText('1 ★')).toBeTruthy());
+ expect(container.querySelector('.other-details')).toBeNull();
+ });
+
+ it('names the user in the error message', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ stubFetch(null);
+ renderUser();
+
+ await waitFor(() => expect(screen.getByText('Could not load user pg.')).toBeTruthy());
+ });
+});
diff --git a/src/pages/User.tsx b/src/pages/User.tsx
new file mode 100644
index 00000000..f64e2f15
--- /dev/null
+++ b/src/pages/User.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { fetchUser } from '../api/hackerNews';
+import ErrorMessage from '../components/ErrorMessage';
+import Loader from '../components/Loader';
+import type { User as UserModel } from '../models/user';
+
+import './User.scss';
+
+export default function User() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const [user, setUser] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ setUser(null);
+ setErrorMessage('');
+
+ fetchUser(id ?? '', controller.signal)
+ .then(setUser)
+ .catch((error: unknown) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ console.error(error);
+ setErrorMessage(`Could not load user ${id}.`);
+ });
+
+ return () => controller.abort();
+ }, [id]);
+
+ if (!user) {
+ return errorMessage ? : ;
+ }
+
+ return (
+
+
+
+ navigate(-1)}>
+ Profile: {user.id}
+
+
+
+ {user.id}
+ {user.karma} ★
+ Created {user.created}
+
+ {user.about && (
+
+
+
+ )}
+
+ );
+}