Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/components/Comment.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
@import "../scss/media";
@import "../scss/theme_variables";

/* Replaces Angular's `:host >>>`: styles links in the comment's own markup as
well as in the HTML injected from the API. */
.comment a {
font-weight: bold;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

.meta {
font-size: 13px;
color: #696969;
font-weight: bold;
letter-spacing: 0.5px;
margin-bottom: 8px;
a {
text-decoration: none;

&:hover {
text-decoration: underline;
}
}
.time {
padding-left: 5px;
}
}

@media #{$mobile-only} {
.meta {
font-size: 14px;
margin-bottom: 10px;
.time {
padding: 0;
float: right;
}
}
}

.meta-collapse {
margin-bottom: 20px;
}

.deleted-meta {
font-size: 12px;
font-weight: bold;
letter-spacing: 0.5px;
margin: 30px 0;
a {
text-decoration: none;
}
}

.collapse {
font-size: 13px;
letter-spacing: 2px;
cursor: pointer;
}

.comment-tree {
margin-left: 24px;
}

@media #{$tablet-only} {
.comment-tree {
margin-left: 8px;
}
}

.comment-text {
font-size: 15px;
margin-top: 0;
margin-bottom: 20px;
word-wrap: break-word;
line-height: 1.5em;
}

.subtree {
margin-left: 0;
padding: 0;
list-style-type: none;
}
76 changes: 76 additions & 0 deletions src/components/Comment.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it } from 'vitest';

import type { Comment as CommentModel } from '../models/comment';
import Comment from './Comment';

function makeComment(overrides: Partial<CommentModel> = {}): CommentModel {
return {
id: 1,
level: 0,
user: 'pg',
time: 0,
time_ago: '1 hour ago',
content: '<p>Top level</p>',
deleted: false,
comments: [],
...overrides,
};
}

function renderComment(comment: CommentModel) {
return render(
<MemoryRouter>
<Comment comment={comment} />
</MemoryRouter>
);
}

afterEach(cleanup);

describe('Comment', () => {
it('renders the comment html and author link', () => {
const { container } = renderComment(makeComment());

expect(container.querySelector('.comment-text')?.innerHTML).toBe('<p>Top level</p>');
expect(screen.getByRole('link', { name: 'pg' }).getAttribute('href')).toBe('/user/pg');
});

it('renders nested comments recursively', () => {
renderComment(
makeComment({
comments: [
makeComment({ id: 2, user: 'kid', content: 'Reply', comments: [makeComment({ id: 3, user: 'grandkid', content: 'Deep reply' })] }),
],
})
);

expect(screen.getByText('Reply')).toBeTruthy();
expect(screen.getByText('Deep reply')).toBeTruthy();
expect(screen.getByRole('link', { name: 'grandkid' })).toBeTruthy();
});

it('collapses and expands its subtree', () => {
const { container } = renderComment(makeComment({ comments: [makeComment({ id: 2, content: 'Reply' })] }));
const toggle = screen.getAllByText('[-]')[0];
const tree = container.querySelector<HTMLElement>('.comment-tree > div');

expect(tree?.hidden).toBe(false);

fireEvent.click(toggle);
expect(screen.getAllByText('[+]').length).toBe(1);
expect(container.querySelector('.meta')?.className).toContain('meta-collapse');
expect(container.querySelector<HTMLElement>('.comment-tree > div')?.hidden).toBe(true);

fireEvent.click(screen.getAllByText('[+]')[0]);
expect(container.querySelector<HTMLElement>('.comment-tree > div')?.hidden).toBe(false);
});

it('renders the deleted branch instead of the content', () => {
const { container } = renderComment(makeComment({ deleted: true }));

expect(screen.getByText('[deleted]')).toBeTruthy();
expect(container.querySelector('.comment-text')).toBeNull();
});
});
48 changes: 48 additions & 0 deletions src/components/Comment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';

import type { Comment as CommentModel } from '../models/comment';

import './Comment.scss';

export interface CommentProps {
comment: CommentModel;
}

export default function Comment({ comment }: CommentProps) {
const [collapse, setCollapse] = useState(false);

if (comment.deleted) {
return (
<div className="comment">
<div className="deleted-meta">
<span className="collapse">[deleted]</span> | Comment Deleted
</div>
</div>
);
}

return (
<div className="comment">
<div className={collapse ? 'meta meta-collapse' : 'meta'}>
<span className="collapse" onClick={() => setCollapse(!collapse)}>
[{collapse ? '+' : '-'}]
</span>{' '}
<Link to={`/user/${comment.user}`}>{comment.user}</Link>
<span className="time">{comment.time_ago}</span>
</div>
<div className="comment-tree">
<div hidden={collapse}>
<p className="comment-text" dangerouslySetInnerHTML={{ __html: comment.content }}></p>
<ul className="subtree">
{comment.comments?.map((subComment) => (
<li key={subComment.id}>
<Comment comment={subComment} />
</li>
))}
</ul>
</div>
</div>
</div>
);
}
115 changes: 115 additions & 0 deletions src/components/ErrorMessage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
@import "../scss/media";
@import "../scss/theme_variables";

.error-section {
height: 300px;
margin: 200px;

@media #{$mobile-only} {
height: 0;
display: block;
position: relative;
margin: 30vh 0;
}

p {
text-align: center;
padding: 0 25px;

&.strong {
margin-top: 25px;
font-weight: bold;
}
}

.skull {
width: $skull-size;
height: $skull-size;
position: relative;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;

.head {
width: 100%;
height: 75%;
border-radius: 15% / 20%;
position: absolute;
top: 0;
left: 0;
&:before, &:after {
content: "";
position: absolute;
border-radius: 50%;
width: 20%;
height: 30%;
bottom: 10%;
}
&:before {
left: 10%;
}
&:after {
right: 10%;
}
.crack {
width: 10%;
height: 10%;
position: absolute;
top: 0;
right: 25%;
transform: skew(-15deg);

&:before {
content: "";
position: absolute;
top: 100%;
left: $skull-size / 15;
border-right: $skull-size / 20 solid transparent;
border-left: $skull-size / 40 solid transparent;
}
}
}
.mouth {
width: 40%;
height: 25%;
position: absolute;
top: 75%;
left: 30%;
border-radius: 0 0 $skull-size / 10 $skull-size / 10;
&:before {
content: "";
position: absolute;
width: 15%;
height: 50%;
border-radius: 50% / 30%;
left: 42.5%;
top: -25%;
}
.teeth {
position: absolute;
bottom: 0;
left: 45%;
width: 10%;
height: 50%;
margin-bottom: -5%;
border-radius: 50% / 20%;

&:before, &:after {
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 50% / 20%;
}
&:before {
left: -250%;
}
&:after {
right: -250%;
}
}
}
}
}
25 changes: 25 additions & 0 deletions src/components/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import './ErrorMessage.scss';

export interface ErrorMessageProps {
message: string;
}

export default function ErrorMessage({ message }: ErrorMessageProps) {
return (
<div className="error-section">
<div className="skull">
<div className="head">
<div className="crack"></div>
</div>
<div className="mouth">
<div className="teeth"></div>
</div>
</div>
<p className="strong">{message}</p>
<p>
If you are offline viewing, you'll need to visit this page with a network connection first before it can
work offline.
</p>
</div>
);
}
Loading