diff --git a/src/app/item-details/ItemDetails.module.scss b/src/app/item-details/ItemDetails.module.scss new file mode 100644 index 000000000..c7edcbe17 --- /dev/null +++ b/src/app/item-details/ItemDetails.module.scss @@ -0,0 +1,151 @@ +@import "../shared/scss/media"; +@import "../shared/scss/theme_variables"; + +.mainContent { + position: relative; + width: 100%; + min-height: 100vh; + -webkit-transition: opacity .2s ease; + transition: opacity .2s ease; + box-sizing: border-box; + padding: 8px 0; + z-index: 0; +} + +.item { + box-sizing: border-box; + padding: 10px 40px 0 40px; + z-index: 0; +} + +@media #{$tablet-only} { + .item { + padding: 10px 20px 0 40px; + } +} + +@media #{$mobile-only} { + .item { + box-sizing: border-box; + padding: 110px 15px 0 15px; + } +} + +.headMargin { + margin-bottom: 15px; +} + +.item p { + margin: 2px 0; +} + +.subject { + word-wrap: break-word; + margin-top: 20px; +} + +.item a { + cursor: pointer; + text-decoration: none; +} + +@media #{$mobile-only} { + .laptop { + display: none; + } +} + +@media #{$laptop-only} { + .mobile { + display: none; + } +} + +.title { + font-size: 16px; + font-family: Verdana, Geneva, sans-serif; +} + +.titleBlock { + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0 75px; +} + +@media #{$mobile-only} { + .title { + font-size: 15px; + } + .backButton { + 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); + } +} + +.subtext { + font-size: 12px; + font-weight: bold; + letter-spacing: 0.5px; +} + +.domain { + letter-spacing: 0.5px; +} + +.subtext a { + &:hover { + text-decoration: underline; + } +} + +.itemDetails { + padding: 10px; +} + +.itemHeader { + padding-bottom: 10px; +} + +@media #{$mobile-only} { + .itemHeader { + padding: 10px 0 10px 0; + position: fixed; + width: 100%; + left: 0; + top: 62px; + } +} + +.pollResults { + margin-bottom: 1em; +} + +.pollContent { + * { + padding-bottom: 0; + margin-bottom: -1em; + margin-top: 1em; + } + .pollBar { + height: 10px; + margin-bottom: 1em; + } +} + +.commentList { + list-style-type: none; + padding: 10px 0; +} + +.commentList li { + display: list-item; +} diff --git a/src/app/item-details/ItemDetails.tsx b/src/app/item-details/ItemDetails.tsx new file mode 100644 index 000000000..075506ec0 --- /dev/null +++ b/src/app/item-details/ItemDetails.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useSettings } from '../shared/hooks/useSettings'; +import { Story } from '../shared/models/story'; +import { Comment as CommentComponent } from './comment/Comment'; +import { Loader } from '../shared/components/Loader'; +import { ErrorMessage } from '../shared/components/ErrorMessage'; +import { formatCommentCount } from '../shared/utils/formatCommentCount'; +import styles from './ItemDetails.module.scss'; + +export function ItemDetails() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { settings } = useSettings(); + const [item, setItem] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + setItem(null); + setErrorMessage(''); + + if (id) { + fetch(`https://node-hnapi.herokuapp.com/item/${id}`, { + signal: controller.signal, + }) + .then((res) => res.json()) + .then((story: Story) => { + if (story.type === 'poll' && story.poll) { + const numberOfPollOptions = story.poll.length; + let pollVotesCount = 0; + const pollPromises = Array.from( + { length: numberOfPollOptions }, + (_, i) => + fetch( + `https://node-hnapi.herokuapp.com/item/${story.id + i + 1}`, + { signal: controller.signal } + ).then((res) => res.json()) + ); + Promise.all(pollPromises).then((pollResults) => { + pollResults.forEach((result, i) => { + story.poll[i] = result; + pollVotesCount += result.points; + }); + story.poll_votes_count = pollVotesCount; + setItem({ ...story }); + }).catch((err) => { + if (err.name !== 'AbortError') { + setErrorMessage('Could not load item comments.'); + } + }); + } else { + setItem(story); + } + }) + .catch((err) => { + if (err.name !== 'AbortError') { + setErrorMessage('Could not load item comments.'); + } + }); + } + + window.scrollTo(0, 0); + + return () => { + controller.abort(); + }; + }, [id]); + + const hasUrl = item?.url?.startsWith('http') ?? false; + const goBack = () => navigate(-1); + + const linkTarget = settings.openLinkInNewTab ? '_blank' : undefined; + const linkRel = settings.openLinkInNewTab ? 'noopener' : undefined; + + return ( +
+ {!item && !errorMessage && } + {!item && errorMessage !== '' && } + + {item && ( +
+ {/* Mobile header */} +
+

+ + {hasUrl ? ( + + {item.title} + + ) : ( + + {item.title} + + )} +

+
+ + {/* Laptop header */} +
0 || item.type === 'job' + ? ` ${styles.itemHeader}` + : '' + }${item.content ? ` ${styles.headMargin}` : ''}`} + > + {hasUrl ? ( +

+ + {item.title} + + {item.domain && ( + ({item.domain}) + )} +

+ ) : ( +

+ + {item.title} + +

+ )} +
+ {item.type !== 'job' && ( + + {item.points} points by{' '} + {item.user} + + )} + + {item.time_ago} + {item.type !== 'job' && ( + + {' '} + |{' '} + + {formatCommentCount(item.comments_count)} + + + )} + +
+
+ + {/* Poll results */} + {item.type === 'poll' && item.poll && ( +
+ {item.poll.map((pollResult, index) => ( +
+
+
+ {pollResult.points} points +
+
0 ? (pollResult.points / item.poll_votes_count) * 100 : 0 + }%`, + }} + /> +
+ ))} +
+ )} + + {/* Content */} + {item.content && ( +

+ )} + + {/* Comments */} +

    + {item.comments?.map((comment) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/item-details/comment/Comment.module.scss b/src/app/item-details/comment/Comment.module.scss new file mode 100644 index 000000000..bfb91f01b --- /dev/null +++ b/src/app/item-details/comment/Comment.module.scss @@ -0,0 +1,83 @@ +@import "../../shared/scss/media"; +@import "../../shared/scss/theme_variables"; + +.commentWrapper 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; + } + } +} + +.metaCollapse { + margin-bottom: 20px; +} + +.deletedMeta { + 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; +} + +.commentTree { + margin-left: 24px; +} + +@media #{$tablet-only} { + .commentTree { + margin-left: 8px; + } +} + +.commentText { + 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; +} diff --git a/src/app/item-details/comment/Comment.tsx b/src/app/item-details/comment/Comment.tsx new file mode 100644 index 000000000..a14e6892c --- /dev/null +++ b/src/app/item-details/comment/Comment.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Comment as CommentModel } from '../../shared/models/comment'; +import styles from './Comment.module.scss'; + +interface CommentProps { + comment: CommentModel; +} + +export function Comment({ comment }: CommentProps) { + const [collapse, setCollapse] = useState(false); + + if (comment.deleted) { + return ( +
+
+ [deleted] | Comment Deleted +
+
+ ); + } + + return ( +
+
+ setCollapse(!collapse)}> + [{collapse ? '+' : '-'}] + {' '} + {comment.user} + {comment.time_ago} +
+
+ +
+
+ ); +} diff --git a/src/app/shared/utils/formatCommentCount.ts b/src/app/shared/utils/formatCommentCount.ts new file mode 100644 index 000000000..373b621c8 --- /dev/null +++ b/src/app/shared/utils/formatCommentCount.ts @@ -0,0 +1,7 @@ +export function formatCommentCount(count: number): string { + if (count > 0) { + const label = count === 1 ? 'comment' : 'comments'; + return `${count} ${label}`; + } + return 'discuss'; +}