From b4e835be0291644614711f58c5bc646e23e67887 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:38:16 +0000 Subject: [PATCH 1/4] feat: migrate ItemDetails and Comment components from Angular to React + TypeScript - Convert ItemDetailsComponent to React functional component (ItemDetails.tsx) - Convert CommentComponent to React functional component (Comment.tsx) - Replace Angular DI with useParams, useNavigate, useSettings hooks - Replace comment pipe with formatCommentCount utility function - Convert SCSS to CSS Modules (camelCase class names) - Preserve all behavior: loading, error, scroll-to-top, back nav, polls, recursive comments - Original Angular files kept alongside for comparison Co-Authored-By: Charity Quinn --- src/app/item-details/ItemDetails.module.scss | 151 ++++++++++++++ src/app/item-details/ItemDetails.tsx | 195 ++++++++++++++++++ .../item-details/comment/Comment.module.scss | 83 ++++++++ src/app/item-details/comment/Comment.tsx | 49 +++++ src/app/shared/utils/formatCommentCount.ts | 7 + 5 files changed, 485 insertions(+) create mode 100644 src/app/item-details/ItemDetails.module.scss create mode 100644 src/app/item-details/ItemDetails.tsx create mode 100644 src/app/item-details/comment/Comment.module.scss create mode 100644 src/app/item-details/comment/Comment.tsx create mode 100644 src/app/shared/utils/formatCommentCount.ts diff --git a/src/app/item-details/ItemDetails.module.scss b/src/app/item-details/ItemDetails.module.scss new file mode 100644 index 000000000..22bb0a91b --- /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; +} + +p { + margin: 2px 0; +} + +.subject { + word-wrap: break-word; + margin-top: 20px; +} + +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..40be1a10f --- /dev/null +++ b/src/app/item-details/ItemDetails.tsx @@ -0,0 +1,195 @@ +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(); + + 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 }); + }); + } 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 +
+
+
+ ))} +
+ )} + + {/* 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..cf1d4b8a2 --- /dev/null +++ b/src/app/item-details/comment/Comment.module.scss @@ -0,0 +1,83 @@ +@import "../../shared/scss/media"; +@import "../../shared/scss/theme_variables"; + +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..34e986592 --- /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'; +} From 8f0b40fb51e4780b1461f3c764c4a81323feb4b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:41:28 +0000 Subject: [PATCH 2/4] fix: add error handling for poll fetch promises Adds .catch() to Promise.all(pollPromises) to handle network errors and AbortError on unmount, preventing unhandled promise rejections and perpetual loading spinners. Co-Authored-By: Charity Quinn --- src/app/item-details/ItemDetails.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/item-details/ItemDetails.tsx b/src/app/item-details/ItemDetails.tsx index 40be1a10f..0f53cd009 100644 --- a/src/app/item-details/ItemDetails.tsx +++ b/src/app/item-details/ItemDetails.tsx @@ -42,6 +42,10 @@ export function ItemDetails() { }); story.poll_votes_count = pollVotesCount; setItem({ ...story }); + }).catch((err) => { + if (err.name !== 'AbortError') { + setErrorMessage('Could not load item comments.'); + } }); } else { setItem(story); From 560ad213215493b5f321e132b5c6b5f50f5dff35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:48:12 +0000 Subject: [PATCH 3/4] fix: scope bare element selectors and guard poll division by zero - Nest bare p/a selectors inside .item in ItemDetails.module.scss - Wrap bare a selector in .commentWrapper in Comment.module.scss - Add commentWrapper class to Comment.tsx root div - Guard poll bar width calculation against division by zero Co-Authored-By: Charity Quinn --- src/app/item-details/ItemDetails.module.scss | 4 ++-- src/app/item-details/ItemDetails.tsx | 2 +- src/app/item-details/comment/Comment.module.scss | 2 +- src/app/item-details/comment/Comment.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/item-details/ItemDetails.module.scss b/src/app/item-details/ItemDetails.module.scss index 22bb0a91b..c7edcbe17 100644 --- a/src/app/item-details/ItemDetails.module.scss +++ b/src/app/item-details/ItemDetails.module.scss @@ -35,7 +35,7 @@ margin-bottom: 15px; } -p { +.item p { margin: 2px 0; } @@ -44,7 +44,7 @@ p { margin-top: 20px; } -a { +.item a { cursor: pointer; text-decoration: none; } diff --git a/src/app/item-details/ItemDetails.tsx b/src/app/item-details/ItemDetails.tsx index 0f53cd009..af139dfd7 100644 --- a/src/app/item-details/ItemDetails.tsx +++ b/src/app/item-details/ItemDetails.tsx @@ -167,7 +167,7 @@ export function ItemDetails() { className={styles.pollBar} style={{ width: `${ - (pollResult.points / item.poll_votes_count) * 100 + item.poll_votes_count > 0 ? (pollResult.points / item.poll_votes_count) * 100 : 0 }%`, }} /> diff --git a/src/app/item-details/comment/Comment.module.scss b/src/app/item-details/comment/Comment.module.scss index cf1d4b8a2..bfb91f01b 100644 --- a/src/app/item-details/comment/Comment.module.scss +++ b/src/app/item-details/comment/Comment.module.scss @@ -1,7 +1,7 @@ @import "../../shared/scss/media"; @import "../../shared/scss/theme_variables"; -a { +.commentWrapper a { font-weight: bold; text-decoration: none; &:hover { diff --git a/src/app/item-details/comment/Comment.tsx b/src/app/item-details/comment/Comment.tsx index 34e986592..a14e6892c 100644 --- a/src/app/item-details/comment/Comment.tsx +++ b/src/app/item-details/comment/Comment.tsx @@ -21,7 +21,7 @@ export function Comment({ comment }: CommentProps) { } return ( -
+
setCollapse(!collapse)}> [{collapse ? '+' : '-'}] From f66557de5cf22aa4e2d3742e4c44174b02e14fed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:54:00 +0000 Subject: [PATCH 4/4] fix: reset item and errorMessage state when id changes Co-Authored-By: Charity Quinn --- src/app/item-details/ItemDetails.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/item-details/ItemDetails.tsx b/src/app/item-details/ItemDetails.tsx index af139dfd7..075506ec0 100644 --- a/src/app/item-details/ItemDetails.tsx +++ b/src/app/item-details/ItemDetails.tsx @@ -17,6 +17,8 @@ export function ItemDetails() { useEffect(() => { const controller = new AbortController(); + setItem(null); + setErrorMessage(''); if (id) { fetch(`https://node-hnapi.herokuapp.com/item/${id}`, {