Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the
✨ Finishing touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai help |
ChatThere are 3 ways to chat with CodeRabbit:
CodeRabbit commands
Other keywords and placeholders
CodeRabbit configuration file (
|
|
Tip For best results, initiate chat on the files or code changes.
📋 Available CommandsReview Commands
PR Management
Code Generation
Configuration & Help
Learning & Memory
💡 Usage Tips
🎯 Quick Examples
Feel free to interact naturally - I'll understand your intent and help accordingly! |
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/(service)/one-on-one/page.tsx (1)
640-651:⚠️ Potential issue | 🟠 MajorUse Next.js
<Link>for internal navigation.This internal route uses
<a>instead of<Link>, which triggers a full reload and violates the Next.jsno-html-link-for-pageslint rule.🔧 Suggested fix
- <a - href="/insights/weekly" - className="text-left px-200 py-150 rounded-100 font-designer-16m transition-all flex items-center gap-150 text-text-subtle hover:text-text-strong hover:bg-fill-neutral-subtle-hover" - > + <Link + href="/insights/weekly" + className="text-left px-200 py-150 rounded-100 font-designer-16m transition-all flex items-center gap-150 text-text-subtle hover:text-text-strong hover:bg-fill-neutral-subtle-hover" + > <MessageSquareText className="w-5 h-5" /> <span className="flex items-center gap-100"> 밸런스게임 <span className="animate-pulse text-[10px] bg-gradient-to-r from-[`#6366f1`] to-[`#8b5cf6`] text-white px-100 py-25 rounded-full font-bold"> NEW </span> </span> - </a> + </Link>// add near the top of the file import Link from 'next/link';
🤖 Fix all issues with AI agents
In `@src/app/`(service)/insights/weekly/[id]/page.tsx:
- Around line 8-16: Validate the dynamic id before rendering VotingDetailView:
read params via useParams(), ensure params.id is defined, convert it to a number
and check Number.isFinite/Number.isNaN (or Number.isInteger) to detect invalid
numeric values; if the id is missing or invalid, render a fallback UI (e.g., a
not-found message/component) or call router.back()/notFound() instead of passing
NaN into VotingDetailView; update the code around useParams, useRouter, votingId
and the VotingDetailView return to perform this guard.
In `@src/app/`(service)/one-on-one/page.tsx:
- Around line 389-410: LibraryCard and LibraryRow use clickable <div>s that are
not keyboard-accessible and the view handler opens a new tab without
noopener/noreferrer; update both components (LibraryCard and LibraryRow) to add
role="link", tabIndex={0}, a descriptive aria-label, and an onKeyDown that
triggers the same action on Enter and Space keys so keyboard users can focus and
activate the element, and change the view handler (handleView) to call
window.open(link, '_blank', 'noopener,noreferrer') and then, if a window object
is returned, set newWindow.opener = null for reverse-tabnabbing protection.
In `@src/components/discussion/discussion-detail-modal.tsx`:
- Around line 78-95: Replace the non-focusable trigger div passed to
UserProfileModal's trigger prop with a semantic, keyboard-focusable button:
change the inner element used in trigger (currently the <div className="flex
items-center ..."> with UserAvatar and nickname) to a <button type="button">
keeping the same className and styles, add an accessible name (aria-label e.g.
`${discussion.author.nickname} profile` or ensure visible text remains), and
ensure click propagation is still stopped by keeping the outer onClick={(e) =>
e.stopPropagation()} wrapper; this makes UserProfileModal's trigger
keyboard-operable without changing visual styling.
In `@src/components/home/tabs/archive-tab.tsx`:
- Around line 363-365: The handleView function opens external links with
window.open(link, '_blank') which leaves window.opener available; update
handleView to open the link in a way that prevents the opened page from
accessing window.opener and removes referrer info — either create an anchor
element with target="_blank" and rel="noopener noreferrer" and click it, or
capture the returned window from window.open and set newWindow.opener = null
(and include 'noreferrer' if using feature string) before returning; locate and
modify the handleView function to implement one of these secure patterns.
- Around line 115-128: The module-level Array.from mock data in archive-tab.tsx
uses Math.random() (for isLiked/isBookmarked) which causes SSR/client hydration
mismatches; remove Math.random from the module scope and either (A) make the
mock values deterministic (e.g., derive isLiked/isBookmarked from the index like
i % 2 === 0) in the Array.from expression, or (B) keep deterministic module data
and perform client-only randomization inside the ArchiveTab component using
useState + useEffect to set isLiked/isBookmarked on mount (so randomization runs
only on the client). Target the Array.from({...}) generation in archive-tab.tsx
and the ArchiveTab component state initialization when applying the fix.
- Around line 394-398: The sort uses new Date(...) on non-ISO strings which can
be invalid for formats like "2025.01.10"; update the sorting to parse dates
explicitly instead of relying on Date parsing: inside the sortedLibrary creation
(which uses filteredLibrary and librarySort) replace the new Date(b.date)/new
Date(a.date) calls with a deterministic parser (e.g., split the "YYYY.MM.DD"
string into year, month, day and construct a Date(year, monthIndex, day) or
convert dots to hyphens to form an ISO string) so the date comparison always
produces correct timestamps.
In `@src/components/home/tabs/community-tab.tsx`:
- Around line 69-89: The code uses Date.now() to assign newVoting.id and option
ids which can collide when created in the same millisecond, and directly calls
JSON.parse(customVotings) which will throw on malformed data; update the
creation in the newVoting block to use a stronger unique id generator (e.g.,
crypto.randomUUID() or a uuid helper) for newVoting.id and each option.id (or
append a per-option counter) and replace direct JSON.parse usage with a safe
parse (wrap JSON.parse(customVotings) in try/catch or use a safeParse helper
that returns [] on error) wherever customVotings is read so malformed
localStorage won’t crash the app; refer to the newVoting constant, its id and
options fields, and the localStorage parsing site to locate changes.
In `@src/components/home/tabs/hall-of-fame-tab.tsx`:
- Around line 58-95: generateMockRankers uses Math.random() (studyTime, streak,
major, change, changeValue) which causes SSR/client hydration mismatches;
replace all Math.random() calls in generateMockRankers with deterministic values
derived from the index (i) so the output is identical on server and client —
e.g., compute studyTime from i ((i % 31) + 10), streak from i ((i % 50) + 1),
pick major as JOBS[i % JOBS.length], derive change using a deterministic pattern
based on i (e.g., i % 10 thresholds for 'up'/'down'/'same'), and set changeValue
to i % 5; keep lastActive logic as-is since it already depends on i.
In `@src/components/study-history/study-calendar.tsx`:
- Around line 47-62: The component currently renders all dayItems via
dayItems.map(...) while also showing an overflow indicator when dayItems.length
> 2; change the rendering to only display the first two items (use
dayItems.slice(0, 2) or compute a displayedItems array) in the map (the JSX that
uses key={item.id}, title, and role/subject rendering) and keep the existing
overflow indicator using dayItems.length - displayedItems.length so "+N개 더"
correctly reflects hidden items.
- Around line 8-9: StudyCalendar currently hardcodes currentDate to new
Date(2025, 0, 1); change initialization so currentDate defaults to either the
most recent date present in the items prop (map items by their date/timestamp
and pick the max) or, if items is empty/undefined, the current date (new
Date()); set the initial state using a lazy initializer (useState(() => ...))
and also update currentDate when items change (useEffect that computes the
latest item date and calls setCurrentDate) to ensure the calendar starts on a
relevant date. Reference: StudyCalendar, currentDate, setCurrentDate, items,
StudyHistoryItem.
In `@src/components/study-history/study-history-row.tsx`:
- Around line 51-60: The external-link anchor rendering when item.link is
present lacks an accessible name; update the anchor that wraps ExternalLink (the
<a> element in StudyHistoryRow / where item.link is used) to include an
accessible label — either add an aria-label (e.g. aria-label={`Open
${item.title} in new tab`} or similar) or include a visually-hidden text node (a
<span className="sr-only">Open {item.title} in new tab</span>) inside the anchor
so screen readers announce the purpose of the link while preserving the
icon-only visual.
In `@src/widgets/home/home-dashboard.tsx`:
- Around line 19-43: getSincerityPresetByLevelName can return undefined, so
update the code that assigns temperPreset (used in the JSX: temperPreset.bgClass
and temperPreset.textClass) to ensure a safe default object is used when
undefined; e.g., after calling
getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) coalesce to a
fallback preset with safe bgClass and textClass values (or destructure with
defaults) so the JSX rendering of the profile badge always has valid class
strings.
- Line 176: The FeedbackLink usage is passing unsupported props
(variant="compact", className="w-full") which the component doesn't accept; fix
by either removing those props from the JSX in the home dashboard (replace
<FeedbackLink variant="compact" className="w-full" /> with <FeedbackLink />) or
update the FeedbackLink component signature (function FeedbackLink) in
feedback-link.tsx to accept and apply props (e.g., props: { variant?: string;
className?: string } and forward className/variant to the rendered element);
choose one approach and ensure types are updated so the props are consumed or
not passed.
- Around line 16-21: The code currently casts the cookie value returned by
getServerCookie('memberId') into a Number (memberId) without validating it, so
if memberIdStr is missing or not numeric you end up calling
getUserProfileInServer(NaN); add a guard: check memberIdStr exists and that
Number.isInteger(Number(memberIdStr)) (or parseInt and validate) and if invalid
perform the proper redirect/early return before calling getUserProfileInServer
and getSincerityPresetByLevelName; update logic around memberIdStr, memberId,
getUserProfileInServer, and getSincerityPresetByLevelName to only run when
memberId is a valid integer.
🧹 Nitpick comments (14)
src/components/voting/voting-detail-modal.tsx (1)
67-86: Use a button for the profile trigger (keyboard a11y).Line 72 uses a clickable
<div>; it won’t be focusable by keyboard. Use a<button>as the trigger and keepstopPropagation.♿ Suggested fix
- <div className="mb-200" onClick={(e) => e.stopPropagation()}> + <div className="mb-200"> <UserProfileModal memberId={voting.author.id} trigger={ - <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"> + <button + type="button" + onClick={(e) => e.stopPropagation()} + className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default" + aria-label={`${voting.author.nickname} 프로필 보기`} + > <div> <UserAvatar size={32} image={voting.author.avatar} className="relative z-10" /> </div> <span className="font-designer-13b text-text-default"> {voting.author.nickname} </span> - </div> + </button> } /> </div>src/components/discussion/comment-list.tsx (1)
67-71: Use a button trigger for the avatar modal (keyboard a11y).Line 70 uses
UserAvatardirectly as the trigger; if it renders a<div>, keyboard users can’t open the modal. Wrap it in a button with an aria-label.♿ Suggested fix
- <div onClick={(e) => e.stopPropagation()}> - <UserProfileModal - memberId={comment.author.id} - trigger={<UserAvatar size={28} image={comment.author.avatar} />} - /> - </div> + <div> + <UserProfileModal + memberId={comment.author.id} + trigger={ + <button + type="button" + onClick={(e) => e.stopPropagation()} + aria-label={`${comment.author.nickname} 프로필 보기`} + className="rounded-full" + > + <UserAvatar size={28} image={comment.author.avatar} /> + </button> + } + /> + </div>src/components/home/tab-navigation.tsx (1)
66-78: Addtype="button"to tab buttons.
Prevents accidental form submission if this nav is ever rendered inside a<form>.♻️ Suggested tweak
<button key={tab.id} + type="button" onClick={() => handleTabChange(tab.id)}src/app/(service)/home/page.tsx (1)
22-32: Remove the duplicate Suspense boundary from the page wrapper.
HomeContentalready wraps its content in aSuspenseboundary with an identical fallback (Banner/StudyCard). Nesting two Suspense boundaries with the same fallback is redundant—React will always show the outermost fallback, making the inner one unreachable. Per React 19 and Next.js 15 guidance, avoid stacking identical fallbacks. The innerSuspenseboundary withinHomeContentInner(for tab-specific streaming) is still valuable and should be kept.♻️ Optional cleanup
- <Suspense - fallback={ - <> - <Banner /> - <StudyCard /> - </> - } - > - <HomeContent /> - </Suspense> + <HomeContent />src/types/study-history.ts (1)
1-11: Remove the'use client'directive from this type-only module.Per Next.js best practices, type-only files should remain neutral without directives. This allows the types to be safely imported from both server and client components. Adding
'use client'to a type-only module unnecessarily marks it as part of the client bundle.♻️ Suggested change
-'use client'; - export interface StudyHistoryItem {src/components/home/tabs/study-history-tab.tsx (1)
138-145: Add blank line before return statement to satisfy ESLint rule.Static analysis flagged a missing blank line before the
returnstatement.🔧 Proposed fix
useEffect(() => { setIsLoading(true); const timer = setTimeout(() => { setHistoryItems(MOCK_HISTORY_DATA); setIsLoading(false); }, 300); + return () => clearTimeout(timer); }, []);src/components/study-history/study-calendar.tsx (1)
16-23: Add blank line before return statement to satisfy ESLint rule.Static analysis flagged a missing blank line.
🔧 Proposed fix
const itemsByDate = items.reduce((acc, item) => { const match = item.date.match(/(\d{4})\.(\d{2})\.(\d{2})/); if (match) { const key = `${match[1]}.${match[2]}.${match[3]}`; acc[key] = acc[key] ? [...acc[key], item] : [item]; } + return acc; }, {} as Record<string, StudyHistoryItem[]>);src/components/voting/voting-detail-view.tsx (2)
108-116: Add blank line before return statement to satisfy ESLint rule.Static analysis flagged missing blank lines in the setVoting callback.
🔧 Proposed fix
setVoting((prev) => { if (!prev) return prev; + return { ...prev, comments: [...prev.comments, newComment], commentCount: prev.commentCount + 1, }; });
119-128: Add blank line before return statement to satisfy ESLint rule.Similar formatting issue in the delete handler.
🔧 Proposed fix
const handleDeleteComment = (commentId: number) => { setVoting((prev) => { if (!prev) return prev; + return { ...prev, comments: prev.comments.filter((c) => c.id !== commentId), commentCount: prev.commentCount - 1, }; }); };src/components/home/tabs/archive-tab.tsx (1)
419-427: Admin toggle should be removed or properly secured for production.The admin toggle is implemented as a hidden UI element that becomes visible on hover. While the comment indicates it's a "dev feature," this pattern could inadvertently expose admin functionality to users in production. Consider using environment variables or feature flags to conditionally render this in development only.
♻️ Suggested approach using environment check
+const isDev = process.env.NODE_ENV === 'development'; + {/* Admin Toggle (Hidden/Dev feature) */} +{isDev && ( <button onClick={() => setIsAdmin(!isAdmin)} className={cn( "px-200 py-100 rounded-100 text-xs font-mono transition-colors", isAdmin ? "bg-red-100 text-red-600" : "bg-transparent text-transparent hover:text-gray-300" )} > {isAdmin ? 'Admin Mode ON' : 'Admin'} </button> +)}src/components/home/tabs/hall-of-fame-tab.tsx (2)
287-305: Large mock dataset (2000 items) regenerated on every tab change may impact performance.The effect generates 2000 ranker objects (100 × 20) on every
rankingTypechange. While this is mock data, it creates unnecessary memory churn. Consider reducing the dataset size or memoizing the generation.♻️ Consider reducing mock data size
const timer = setTimeout(() => { - const data = Array.from({ length: 100 }, (_, i) => { + const data = Array.from({ length: 10 }, (_, i) => { return generateMockRankers(rankingType).map((r) => ({ ...r, rank: r.rank + i * 20, })); })This would generate 200 items which is still sufficient for demonstrating pagination.
316-325: Edge case: Empty ranking table when all results are in top 3.When
filteredRankers.lengthis exactly 3,currentRankerswill be empty (since.slice(3)returns[]), resulting in an empty table body. The top 3 section will display correctly, but the table header will still render with no rows beneath it.This may be intentional, but consider hiding the table entirely when there are no additional rankers beyond the top 3.
♻️ Hide table when no additional rankers
+{currentRankers.length > 0 && ( {/* Ranking Table */} <div className="bg-background-default rounded-200 border border-border-subtle overflow-hidden shadow-1"> ... </div> +)}src/components/home/tabs/community-tab.tsx (2)
113-135: IntersectionObserver re-created on every page increment.The effect's dependency on
pagecauses the observer to be disconnected and reconnected every time a new page is loaded. While functionally correct, this is inefficient. Consider removingpagefrom dependencies and using a ref to track it.♻️ Use ref for page tracking
+const pageRef = useRef(page); +pageRef.current = page; useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !isLoadingMore && !isLoading) { - const nextPage = page + 1; + const nextPage = pageRef.current + 1; setPage(nextPage); loadVotings(nextPage, false); } }, { threshold: 0.1 }, ); const currentTarget = observerTarget.current; if (currentTarget) { observer.observe(currentTarget); } return () => { if (currentTarget) { observer.unobserve(currentTarget); } }; -}, [hasMore, isLoadingMore, isLoading, page, loadVotings]); +}, [hasMore, isLoadingMore, isLoading, loadVotings]);
147-157: Consider preserving scroll position when returning from detail view.When navigating back from
VotingDetailView, the list will re-render from the beginning. For better UX, consider preserving the scroll position or using a more sophisticated navigation pattern.
| const params = useParams(); | ||
| const router = useRouter(); | ||
| const votingId = Number(params.id); | ||
|
|
||
| const [voting, setVoting] = useState<Voting | null>(null); | ||
| const [selectedOption, setSelectedOption] = useState<number | undefined>(); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
| const [showDescription, setShowDescription] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| // 데이터 로드 | ||
| useEffect(() => { | ||
| const loadVoting = async () => { | ||
| try { | ||
| setIsLoading(true); | ||
| const data = await mockFetchVotingDetail(votingId); | ||
| if (data) { | ||
| setVoting(data); | ||
| setSelectedOption(data.myVote); | ||
| } else { | ||
| setError('투표를 찾을 수 없습니다.'); | ||
| } | ||
| } catch (err) { | ||
| setError('데이터를 불러오는데 실패했습니다.'); | ||
| console.error(err); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| loadVoting(); | ||
| }, [votingId]); | ||
|
|
||
| // 투표 핸들러 | ||
| const handleVote = async () => { | ||
| if (!selectedOption || !voting?.isActive) return; | ||
|
|
||
| setIsSubmitting(true); | ||
| try { | ||
| // Mock API 호출 (실제로는 서버에 요청) | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
|
|
||
| // 로컬 상태 업데이트 | ||
| setVoting((prev) => { | ||
| if (!prev) return prev; | ||
|
|
||
| const oldVote = prev.myVote; | ||
| const updatedOptions = prev.options.map((opt) => { | ||
| let newVoteCount = opt.voteCount; | ||
|
|
||
| if (oldVote === opt.id) { | ||
| newVoteCount--; | ||
| } | ||
| if (opt.id === selectedOption) { | ||
| newVoteCount++; | ||
| } | ||
|
|
||
| return { ...opt, voteCount: newVoteCount }; | ||
| }); | ||
|
|
||
| const newTotalVotes = oldVote ? prev.totalVotes : prev.totalVotes + 1; | ||
| const optionsWithPercentage = updatedOptions.map((opt) => ({ | ||
| ...opt, | ||
| percentage: (opt.voteCount / newTotalVotes) * 100, | ||
| })); | ||
|
|
||
| return { | ||
| ...prev, | ||
| myVote: selectedOption, | ||
| options: optionsWithPercentage, | ||
| totalVotes: newTotalVotes, | ||
| }; | ||
| }); | ||
| } finally { | ||
| setIsSubmitting(false); | ||
| } | ||
| }; | ||
|
|
||
| // 댓글 추가 핸들러 | ||
| const handleAddComment = async (data: CommentFormData) => { | ||
| if (!voting) return; | ||
|
|
||
| const newComment: VotingComment = { | ||
| id: Date.now(), | ||
| author: { id: 999, nickname: '나' }, | ||
| content: data.content, | ||
| createdAt: new Date().toISOString(), | ||
| isAuthor: true, | ||
| votedOption: voting.options.find((opt) => opt.id === voting.myVote)?.label, | ||
| }; | ||
|
|
||
| setVoting((prev) => { | ||
| if (!prev) return prev; | ||
| return { | ||
| ...prev, | ||
| comments: [...prev.comments, newComment], | ||
| commentCount: prev.commentCount + 1, | ||
| }; | ||
| }); | ||
| }; | ||
|
|
||
| // 댓글 삭제 핸들러 | ||
| const handleDeleteComment = (commentId: number) => { | ||
| setVoting((prev) => { | ||
| if (!prev) return prev; | ||
| return { | ||
| ...prev, | ||
| comments: prev.comments.filter((c) => c.id !== commentId), | ||
| commentCount: prev.commentCount - 1, | ||
| }; | ||
| }); | ||
| }; | ||
|
|
||
| // 로딩 상태 | ||
| if (isLoading) { | ||
| return ( | ||
| <div className="flex min-h-screen items-center justify-center bg-background-alternative"> | ||
| <div className="flex flex-col items-center gap-400"> | ||
| <Loader2 className="h-8 w-8 animate-spin text-text-brand" /> | ||
| <p className="font-designer-16m text-text-subtle">투표를 불러오는 중...</p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // 에러 상태 | ||
| if (error || !voting) { | ||
| return ( | ||
| <div className="flex min-h-screen items-center justify-center bg-background-alternative"> | ||
| <div className="flex flex-col items-center gap-400"> | ||
| <p className="font-designer-16m text-text-subtle">{error || '투표를 찾을 수 없습니다.'}</p> | ||
| <button | ||
| onClick={() => router.push('/insights/weekly')} | ||
| className="rounded-100 bg-fill-brand-default-default px-400 py-200 font-designer-14b text-text-inverse transition-colors hover:bg-fill-brand-default-hover" | ||
| > | ||
| 목록으로 돌아가기 | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const hasVoted = voting.myVote !== undefined; | ||
|
|
||
| return ( | ||
| <div className="min-h-screen bg-background-alternative"> | ||
| <div className="mx-auto w-full max-w-screen-lg px-400 py-600"> | ||
| {/* 뒤로가기 버튼 */} | ||
| <button | ||
| onClick={() => router.back()} | ||
| className="mb-400 flex items-center gap-100 font-designer-14m text-text-subtle transition-colors hover:text-text-strong" | ||
| > | ||
| <ArrowLeft className="h-4 w-4" /> | ||
| 돌아가기 | ||
| </button> | ||
|
|
||
| {/* 헤더 */} | ||
| <div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | ||
| {/* 라운드 & 상태 */} | ||
| <div className="mb-300 flex items-center gap-200"> | ||
| <div className="flex items-center gap-100 rounded-100 bg-fill-brand-default-default px-250 py-100"> | ||
| <TrendingUp className="h-4 w-4 text-text-inverse" /> | ||
| <span className="font-designer-13b text-text-inverse">{voting.round} 라운드</span> | ||
| </div> | ||
| <VoteTimer endsAt={voting.endsAt} isActive={voting.isActive} /> | ||
| </div> | ||
|
|
||
| {/* 제목 */} | ||
| <h1 className="mb-200 font-bold-h3 text-text-strong">{voting.title}</h1> | ||
|
|
||
| {/* 설명 토글 */} | ||
| {voting.description && ( | ||
| <> | ||
| <button | ||
| onClick={() => setShowDescription(!showDescription)} | ||
| className="mb-200 flex items-center gap-100 rounded-100 border border-border-subtle px-200 py-100 font-designer-12r text-text-subtle transition-colors hover:border-border-brand hover:text-text-brand" | ||
| > | ||
| <Info className="h-3.5 w-3.5" /> | ||
| 주제 설명 {showDescription ? '숨기기' : '보기'} | ||
| </button> | ||
|
|
||
| {showDescription && ( | ||
| <p className="mb-200 rounded-100 border border-border-subtle bg-background-alternative p-300 font-designer-14r text-text-default"> | ||
| {voting.description} | ||
| </p> | ||
| )} | ||
| </> | ||
| )} | ||
|
|
||
| {/* 태그 */} | ||
| {voting.tags.length > 0 && ( | ||
| <div className="flex flex-wrap gap-100"> | ||
| {voting.tags.map((tag) => ( | ||
| <span | ||
| key={tag} | ||
| className="rounded-100 bg-fill-neutral-subtle-default px-150 py-50 font-designer-12r text-text-subtle" | ||
| > | ||
| #{tag} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* 투표 섹션 */} | ||
| <div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | ||
| {!hasVoted && voting.isActive ? ( | ||
| <> | ||
| {/* 헤더 */} | ||
| <div className="mb-400 flex items-center justify-between"> | ||
| <h2 className="font-designer-18b text-text-strong">투표해주세요</h2> | ||
|
|
||
| {/* 현재 투표 참여 인원 */} | ||
| <div className="flex items-center gap-200 rounded-100 border border-border-subtle bg-background-alternative px-300 py-150"> | ||
| <div className="flex h-8 w-8 items-center justify-center rounded-full bg-fill-brand-default-default"> | ||
| <TrendingUp className="h-4 w-4 text-text-inverse" /> | ||
| </div> | ||
| <div className="flex flex-col"> | ||
| <span className="font-designer-11r text-text-subtle">현재 참여</span> | ||
| <span className="font-designer-14b text-text-strong"> | ||
| {voting.totalVotes.toLocaleString()}명 | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 선택지 */} | ||
| <div className="mb-400 flex flex-col gap-300"> | ||
| {voting.options.map((option, index) => { | ||
| const isSelected = selectedOption === option.id; | ||
| const colors = [ | ||
| { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500' }, | ||
| { border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500' }, | ||
| { border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500' }, | ||
| { border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500' }, | ||
| { border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500' }, | ||
| ]; | ||
| const color = colors[index % colors.length]; | ||
|
|
||
| return ( | ||
| <button | ||
| key={option.id} | ||
| onClick={() => setSelectedOption(option.id)} | ||
| disabled={isSubmitting} | ||
| className={cn( | ||
| 'group relative rounded-200 border-2 p-300 text-left transition-all duration-200', | ||
| isSelected | ||
| ? cn('shadow-lg', color.border, color.bg) | ||
| : 'border-border-subtle bg-background-default hover:border-border-brand hover:shadow-1', | ||
| isSubmitting && 'cursor-not-allowed opacity-50', | ||
| )} | ||
| > | ||
| <div className="flex items-center justify-between"> | ||
| {/* 선택지 텍스트 */} | ||
| <div className="flex items-center gap-200"> | ||
| {/* 번호 배지 */} | ||
| <div | ||
| className={cn( | ||
| 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-bold transition-all', | ||
| isSelected | ||
| ? cn('text-white', color.border.replace('border-', 'bg-')) | ||
| : 'bg-fill-neutral-subtle-default text-text-subtle group-hover:bg-fill-neutral-strong-default group-hover:text-text-inverse', | ||
| )} | ||
| > | ||
| {index + 1} | ||
| </div> | ||
|
|
||
| <span | ||
| className={cn( | ||
| 'font-designer-15b transition-colors', | ||
| isSelected ? color.text : 'text-text-default', | ||
| )} | ||
| > | ||
| {option.label} | ||
| </span> | ||
| </div> | ||
|
|
||
| {/* 선택 체크 표시 */} | ||
| {isSelected && ( | ||
| <div className={cn('flex h-7 w-7 items-center justify-center rounded-full', color.border.replace('border-', 'bg-'))}> | ||
| <Check className="h-4 w-4 text-white" /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
|
|
||
| {/* 투표하기 버튼 */} | ||
| <button | ||
| onClick={handleVote} | ||
| disabled={!selectedOption || isSubmitting} | ||
| className={cn( | ||
| 'w-full rounded-100 py-300 font-designer-15b text-text-inverse shadow-lg transition-all duration-200', | ||
| 'bg-gradient-to-r from-fill-brand-default-default to-fill-brand-default-hover', | ||
| 'disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none', | ||
| 'hover:scale-[1.02] hover:shadow-xl', | ||
| )} | ||
| > | ||
| {isSubmitting ? ( | ||
| <div className="flex items-center justify-center gap-200"> | ||
| <Loader2 className="h-5 w-5 animate-spin" /> | ||
| 투표 중... | ||
| </div> | ||
| ) : ( | ||
| '투표하기' | ||
| )} | ||
| </button> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <h2 className="mb-400 font-designer-18b text-text-strong">투표 결과</h2> | ||
| <VoteResultsChart | ||
| options={voting.options} | ||
| myVote={voting.myVote} | ||
| totalVotes={voting.totalVotes} | ||
| /> | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* 일별 통계 (투표 후에만 표시) */} | ||
| {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( | ||
| <div className="mb-500"> | ||
| <DailyStatsChart | ||
| dailyStats={voting.dailyStats} | ||
| options={voting.options} | ||
| myVote={voting.myVote} | ||
| /> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* 댓글 섹션 */} | ||
| <div className="rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | ||
| <div className="mb-400 flex items-center gap-100 font-designer-16b text-text-strong"> | ||
| <MessageCircle className="h-5 w-5" /> | ||
| <span>댓글 {voting.commentCount}</span> | ||
| </div> | ||
|
|
||
| {/* 댓글 목록 (항상 표시) */} | ||
| <div className="mb-400"> | ||
| <CommentList | ||
| comments={voting.comments} | ||
| onDelete={handleDeleteComment} | ||
| votingOptions={voting.options} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* 댓글 작성 폼 */} | ||
| {voting.isActive && ( | ||
| <> | ||
| {!hasVoted ? ( | ||
| /* 투표 안 했으면 댓글 작성 불가 안내 */ | ||
| <div className="rounded-200 border border-border-subtle bg-background-alternative p-400 text-center"> | ||
| <p className="font-designer-14m text-text-subtle"> | ||
| 투표 후 댓글을 작성할 수 있습니다 | ||
| </p> | ||
| </div> | ||
| ) : ( | ||
| /* 투표 했으면 댓글 작성 폼 */ | ||
| <div className="rounded-200 border border-border-subtle bg-background-alternative p-300"> | ||
| <CommentForm onSubmit={handleAddComment} /> | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <VotingDetailView | ||
| votingId={votingId} | ||
| onBack={() => router.back()} | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Next.js 15 useParams return type dynamic segments [id] documentation
💡 Result:
In Next.js 15 (App Router), useParams() returns an object whose keys are the active dynamic segment names (e.g. [id] → id) and whose values are:
stringfor a single dynamic segment like[id]string[]for a catch-all segment like[...slug]
If there are no dynamic params, it returns{}. [1]
TypeScript typing (recommended)
You can provide a generic to get a precise return type:
'use client'
import { useParams } from 'next/navigation'
export default function Page() {
const { id } = useParams<{ id: string }>() // route: /product/[id]
// id: string
}Catch-all example:
const { slug } = useParams<{ slug: string[] }>() // route: /docs/[...slug]
// slug: string[][1]
Note: In the Pages Router, useParams() can be null on the initial render before the router is ready. [2]
Sources: Next.js docs for useParams (App Router) [1] and (Pages Router) [2].
🏁 Script executed:
# Check if there's any relevant type information in the repository
fd "page.tsx|useParams" src/app --type f | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 1042
🏁 Script executed:
# Look at the actual file to understand the context better
cat -n src/app/\(service\)/insights/weekly/\[id\]/page.tsx | head -30Repository: code-zero-to-one/study-platform-client
Length of output: 632
Validate the numeric ID before rendering.
For the dynamic route [id], useParams() returns { id: string }. While params.id is always a string (not an array), Number(params.id) can still produce NaN if the param is non-numeric or empty. Consider validating and rendering a fallback on invalid IDs.
✅ Suggested guard
const votingId = Number(params.id);
+ if (!Number.isFinite(votingId)) {
+ return <div className="p-400 text-text-subtle">잘못된 접근입니다.</div>;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const params = useParams(); | |
| const router = useRouter(); | |
| const votingId = Number(params.id); | |
| const [voting, setVoting] = useState<Voting | null>(null); | |
| const [selectedOption, setSelectedOption] = useState<number | undefined>(); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [showDescription, setShowDescription] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| // 데이터 로드 | |
| useEffect(() => { | |
| const loadVoting = async () => { | |
| try { | |
| setIsLoading(true); | |
| const data = await mockFetchVotingDetail(votingId); | |
| if (data) { | |
| setVoting(data); | |
| setSelectedOption(data.myVote); | |
| } else { | |
| setError('투표를 찾을 수 없습니다.'); | |
| } | |
| } catch (err) { | |
| setError('데이터를 불러오는데 실패했습니다.'); | |
| console.error(err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| loadVoting(); | |
| }, [votingId]); | |
| // 투표 핸들러 | |
| const handleVote = async () => { | |
| if (!selectedOption || !voting?.isActive) return; | |
| setIsSubmitting(true); | |
| try { | |
| // Mock API 호출 (실제로는 서버에 요청) | |
| await new Promise((resolve) => setTimeout(resolve, 500)); | |
| // 로컬 상태 업데이트 | |
| setVoting((prev) => { | |
| if (!prev) return prev; | |
| const oldVote = prev.myVote; | |
| const updatedOptions = prev.options.map((opt) => { | |
| let newVoteCount = opt.voteCount; | |
| if (oldVote === opt.id) { | |
| newVoteCount--; | |
| } | |
| if (opt.id === selectedOption) { | |
| newVoteCount++; | |
| } | |
| return { ...opt, voteCount: newVoteCount }; | |
| }); | |
| const newTotalVotes = oldVote ? prev.totalVotes : prev.totalVotes + 1; | |
| const optionsWithPercentage = updatedOptions.map((opt) => ({ | |
| ...opt, | |
| percentage: (opt.voteCount / newTotalVotes) * 100, | |
| })); | |
| return { | |
| ...prev, | |
| myVote: selectedOption, | |
| options: optionsWithPercentage, | |
| totalVotes: newTotalVotes, | |
| }; | |
| }); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| // 댓글 추가 핸들러 | |
| const handleAddComment = async (data: CommentFormData) => { | |
| if (!voting) return; | |
| const newComment: VotingComment = { | |
| id: Date.now(), | |
| author: { id: 999, nickname: '나' }, | |
| content: data.content, | |
| createdAt: new Date().toISOString(), | |
| isAuthor: true, | |
| votedOption: voting.options.find((opt) => opt.id === voting.myVote)?.label, | |
| }; | |
| setVoting((prev) => { | |
| if (!prev) return prev; | |
| return { | |
| ...prev, | |
| comments: [...prev.comments, newComment], | |
| commentCount: prev.commentCount + 1, | |
| }; | |
| }); | |
| }; | |
| // 댓글 삭제 핸들러 | |
| const handleDeleteComment = (commentId: number) => { | |
| setVoting((prev) => { | |
| if (!prev) return prev; | |
| return { | |
| ...prev, | |
| comments: prev.comments.filter((c) => c.id !== commentId), | |
| commentCount: prev.commentCount - 1, | |
| }; | |
| }); | |
| }; | |
| // 로딩 상태 | |
| if (isLoading) { | |
| return ( | |
| <div className="flex min-h-screen items-center justify-center bg-background-alternative"> | |
| <div className="flex flex-col items-center gap-400"> | |
| <Loader2 className="h-8 w-8 animate-spin text-text-brand" /> | |
| <p className="font-designer-16m text-text-subtle">투표를 불러오는 중...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 에러 상태 | |
| if (error || !voting) { | |
| return ( | |
| <div className="flex min-h-screen items-center justify-center bg-background-alternative"> | |
| <div className="flex flex-col items-center gap-400"> | |
| <p className="font-designer-16m text-text-subtle">{error || '투표를 찾을 수 없습니다.'}</p> | |
| <button | |
| onClick={() => router.push('/insights/weekly')} | |
| className="rounded-100 bg-fill-brand-default-default px-400 py-200 font-designer-14b text-text-inverse transition-colors hover:bg-fill-brand-default-hover" | |
| > | |
| 목록으로 돌아가기 | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const hasVoted = voting.myVote !== undefined; | |
| return ( | |
| <div className="min-h-screen bg-background-alternative"> | |
| <div className="mx-auto w-full max-w-screen-lg px-400 py-600"> | |
| {/* 뒤로가기 버튼 */} | |
| <button | |
| onClick={() => router.back()} | |
| className="mb-400 flex items-center gap-100 font-designer-14m text-text-subtle transition-colors hover:text-text-strong" | |
| > | |
| <ArrowLeft className="h-4 w-4" /> | |
| 돌아가기 | |
| </button> | |
| {/* 헤더 */} | |
| <div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | |
| {/* 라운드 & 상태 */} | |
| <div className="mb-300 flex items-center gap-200"> | |
| <div className="flex items-center gap-100 rounded-100 bg-fill-brand-default-default px-250 py-100"> | |
| <TrendingUp className="h-4 w-4 text-text-inverse" /> | |
| <span className="font-designer-13b text-text-inverse">{voting.round} 라운드</span> | |
| </div> | |
| <VoteTimer endsAt={voting.endsAt} isActive={voting.isActive} /> | |
| </div> | |
| {/* 제목 */} | |
| <h1 className="mb-200 font-bold-h3 text-text-strong">{voting.title}</h1> | |
| {/* 설명 토글 */} | |
| {voting.description && ( | |
| <> | |
| <button | |
| onClick={() => setShowDescription(!showDescription)} | |
| className="mb-200 flex items-center gap-100 rounded-100 border border-border-subtle px-200 py-100 font-designer-12r text-text-subtle transition-colors hover:border-border-brand hover:text-text-brand" | |
| > | |
| <Info className="h-3.5 w-3.5" /> | |
| 주제 설명 {showDescription ? '숨기기' : '보기'} | |
| </button> | |
| {showDescription && ( | |
| <p className="mb-200 rounded-100 border border-border-subtle bg-background-alternative p-300 font-designer-14r text-text-default"> | |
| {voting.description} | |
| </p> | |
| )} | |
| </> | |
| )} | |
| {/* 태그 */} | |
| {voting.tags.length > 0 && ( | |
| <div className="flex flex-wrap gap-100"> | |
| {voting.tags.map((tag) => ( | |
| <span | |
| key={tag} | |
| className="rounded-100 bg-fill-neutral-subtle-default px-150 py-50 font-designer-12r text-text-subtle" | |
| > | |
| #{tag} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* 투표 섹션 */} | |
| <div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | |
| {!hasVoted && voting.isActive ? ( | |
| <> | |
| {/* 헤더 */} | |
| <div className="mb-400 flex items-center justify-between"> | |
| <h2 className="font-designer-18b text-text-strong">투표해주세요</h2> | |
| {/* 현재 투표 참여 인원 */} | |
| <div className="flex items-center gap-200 rounded-100 border border-border-subtle bg-background-alternative px-300 py-150"> | |
| <div className="flex h-8 w-8 items-center justify-center rounded-full bg-fill-brand-default-default"> | |
| <TrendingUp className="h-4 w-4 text-text-inverse" /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <span className="font-designer-11r text-text-subtle">현재 참여</span> | |
| <span className="font-designer-14b text-text-strong"> | |
| {voting.totalVotes.toLocaleString()}명 | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* 선택지 */} | |
| <div className="mb-400 flex flex-col gap-300"> | |
| {voting.options.map((option, index) => { | |
| const isSelected = selectedOption === option.id; | |
| const colors = [ | |
| { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500' }, | |
| { border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500' }, | |
| { border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500' }, | |
| { border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500' }, | |
| { border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500' }, | |
| ]; | |
| const color = colors[index % colors.length]; | |
| return ( | |
| <button | |
| key={option.id} | |
| onClick={() => setSelectedOption(option.id)} | |
| disabled={isSubmitting} | |
| className={cn( | |
| 'group relative rounded-200 border-2 p-300 text-left transition-all duration-200', | |
| isSelected | |
| ? cn('shadow-lg', color.border, color.bg) | |
| : 'border-border-subtle bg-background-default hover:border-border-brand hover:shadow-1', | |
| isSubmitting && 'cursor-not-allowed opacity-50', | |
| )} | |
| > | |
| <div className="flex items-center justify-between"> | |
| {/* 선택지 텍스트 */} | |
| <div className="flex items-center gap-200"> | |
| {/* 번호 배지 */} | |
| <div | |
| className={cn( | |
| 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-bold transition-all', | |
| isSelected | |
| ? cn('text-white', color.border.replace('border-', 'bg-')) | |
| : 'bg-fill-neutral-subtle-default text-text-subtle group-hover:bg-fill-neutral-strong-default group-hover:text-text-inverse', | |
| )} | |
| > | |
| {index + 1} | |
| </div> | |
| <span | |
| className={cn( | |
| 'font-designer-15b transition-colors', | |
| isSelected ? color.text : 'text-text-default', | |
| )} | |
| > | |
| {option.label} | |
| </span> | |
| </div> | |
| {/* 선택 체크 표시 */} | |
| {isSelected && ( | |
| <div className={cn('flex h-7 w-7 items-center justify-center rounded-full', color.border.replace('border-', 'bg-'))}> | |
| <Check className="h-4 w-4 text-white" /> | |
| </div> | |
| )} | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* 투표하기 버튼 */} | |
| <button | |
| onClick={handleVote} | |
| disabled={!selectedOption || isSubmitting} | |
| className={cn( | |
| 'w-full rounded-100 py-300 font-designer-15b text-text-inverse shadow-lg transition-all duration-200', | |
| 'bg-gradient-to-r from-fill-brand-default-default to-fill-brand-default-hover', | |
| 'disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none', | |
| 'hover:scale-[1.02] hover:shadow-xl', | |
| )} | |
| > | |
| {isSubmitting ? ( | |
| <div className="flex items-center justify-center gap-200"> | |
| <Loader2 className="h-5 w-5 animate-spin" /> | |
| 투표 중... | |
| </div> | |
| ) : ( | |
| '투표하기' | |
| )} | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <h2 className="mb-400 font-designer-18b text-text-strong">투표 결과</h2> | |
| <VoteResultsChart | |
| options={voting.options} | |
| myVote={voting.myVote} | |
| totalVotes={voting.totalVotes} | |
| /> | |
| </> | |
| )} | |
| </div> | |
| {/* 일별 통계 (투표 후에만 표시) */} | |
| {hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && ( | |
| <div className="mb-500"> | |
| <DailyStatsChart | |
| dailyStats={voting.dailyStats} | |
| options={voting.options} | |
| myVote={voting.myVote} | |
| /> | |
| </div> | |
| )} | |
| {/* 댓글 섹션 */} | |
| <div className="rounded-200 border border-border-subtle bg-background-default p-500 shadow-1"> | |
| <div className="mb-400 flex items-center gap-100 font-designer-16b text-text-strong"> | |
| <MessageCircle className="h-5 w-5" /> | |
| <span>댓글 {voting.commentCount}</span> | |
| </div> | |
| {/* 댓글 목록 (항상 표시) */} | |
| <div className="mb-400"> | |
| <CommentList | |
| comments={voting.comments} | |
| onDelete={handleDeleteComment} | |
| votingOptions={voting.options} | |
| /> | |
| </div> | |
| {/* 댓글 작성 폼 */} | |
| {voting.isActive && ( | |
| <> | |
| {!hasVoted ? ( | |
| /* 투표 안 했으면 댓글 작성 불가 안내 */ | |
| <div className="rounded-200 border border-border-subtle bg-background-alternative p-400 text-center"> | |
| <p className="font-designer-14m text-text-subtle"> | |
| 투표 후 댓글을 작성할 수 있습니다 | |
| </p> | |
| </div> | |
| ) : ( | |
| /* 투표 했으면 댓글 작성 폼 */ | |
| <div className="rounded-200 border border-border-subtle bg-background-alternative p-300"> | |
| <CommentForm onSubmit={handleAddComment} /> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <VotingDetailView | |
| votingId={votingId} | |
| onBack={() => router.back()} | |
| /> | |
| const params = useParams(); | |
| const router = useRouter(); | |
| const votingId = Number(params.id); | |
| if (!Number.isFinite(votingId)) { | |
| return <div className="p-400 text-text-subtle">잘못된 접근입니다.</div>; | |
| } | |
| return ( | |
| <VotingDetailView | |
| votingId={votingId} | |
| onBack={() => router.back()} | |
| /> |
🤖 Prompt for AI Agents
In `@src/app/`(service)/insights/weekly/[id]/page.tsx around lines 8 - 16,
Validate the dynamic id before rendering VotingDetailView: read params via
useParams(), ensure params.id is defined, convert it to a number and check
Number.isFinite/Number.isNaN (or Number.isInteger) to detect invalid numeric
values; if the id is missing or invalid, render a fallback UI (e.g., a not-found
message/component) or call router.back()/notFound() instead of passing NaN into
VotingDetailView; update the code around useParams, useRouter, votingId and the
VotingDetailView return to perform this guard.
| const LibraryCard = ({ | ||
| item, | ||
| onLike, | ||
| onView, | ||
| onBookmark, | ||
| onSimilar, | ||
| onView | ||
| }: { | ||
| item: LibraryItem; | ||
| onLike: (e: React.MouseEvent, id: number) => void; | ||
| onView: (link: string) => void; | ||
| onBookmark: (e: React.MouseEvent, id: number) => void; | ||
| onSimilar: (e: React.MouseEvent, tag: string) => void; | ||
| }) => { | ||
| return ( | ||
| <div className="flex h-full flex-col gap-250 rounded-200 border border-border-subtle bg-background-default p-400 shadow-1 transition-all hover:-translate-y-50 hover:shadow-2"> | ||
| <div className="flex items-start justify-between gap-200"> | ||
| <div className="flex flex-wrap items-center gap-100"> | ||
| <span className="rounded-100 border border-border-subtle px-200 py-50 font-designer-12m text-text-subtle"> | ||
| {CURATION_LABELS.level[item.level]} | ||
| </span> | ||
| {item.isRecommended && ( | ||
| <span className="flex items-center gap-50 rounded-100 bg-fill-brand-subtle-default px-200 py-50 font-designer-12b text-text-brand"> | ||
| <Sparkles className="h-3.5 w-3.5" /> | ||
| 추천 | ||
| </span> | ||
| )} | ||
| </div> | ||
| <button | ||
| onClick={(e) => onBookmark(e, item.id)} | ||
| className={cn( | ||
| 'flex items-center gap-50 rounded-100 px-150 py-50 font-designer-12m transition-colors', | ||
| item.isBookmarked | ||
| ? 'bg-fill-neutral-strong-default text-text-inverse' | ||
| : 'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover', | ||
| )} | ||
| > | ||
| <Bookmark className="h-3.5 w-3.5" /> | ||
| {item.isBookmarked ? '저장됨' : '저장'} | ||
| </button> | ||
| <div | ||
| onClick={() => onView(item.link)} | ||
| className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default" | ||
| > | ||
| <div className="flex items-center justify-between"> | ||
| <span className="font-designer-13m text-text-disabled">{item.date}</span> | ||
| <ExternalLink className="w-4 h-4 text-text-subtlest opacity-0 group-hover:opacity-100 transition-opacity" /> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-150"> | ||
| <h3 className="font-bold-h5 text-text-strong line-clamp-2"> | ||
| {item.title} | ||
| </h3> | ||
| <p className="font-designer-13r text-text-subtle line-clamp-2"> | ||
| {item.description} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap items-center gap-100"> | ||
| {item.tags.map((tag) => ( | ||
| <button | ||
| key={tag} | ||
| onClick={(e) => onSimilar(e, tag)} | ||
| className="rounded-100 bg-fill-neutral-subtle-default px-200 py-50 font-designer-12r text-text-subtle hover:bg-fill-neutral-subtle-hover" | ||
| > | ||
| #{tag} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| <h3 className="font-bold-h5 text-text-strong group-hover:text-text-information line-clamp-2 h-[60px] transition-colors leading-tight"> | ||
| {item.title} | ||
| </h3> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if the file exists and get its size
stat src/app/\(service\)/one-on-one/page.tsx 2>/dev/null || ls -la src/app/*/one-on-one/page.tsx 2>/dev/null || find src -name "page.tsx" -path "*one-on-one*" 2>/dev/nullRepository: code-zero-to-one/study-platform-client
Length of output: 169
🏁 Script executed:
# List files in src/app to understand the directory structure
find src/app -type f -name "page.tsx" | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 1042
🏁 Script executed:
# Read the specific line ranges mentioned in the review
sed -n '389,410p' src/app/\(service\)/one-on-one/page.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 978
🏁 Script executed:
# Also read the second location mentioned (441-468)
sed -n '441,468p' src/app/\(service\)/one-on-one/page.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 1153
🏁 Script executed:
# Search for the onView function implementation to understand what it does
rg -A 10 "const onView" src/app/\(service\)/one-on-one/page.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 64
🏁 Script executed:
# Search for where onView is defined or used
rg "onView\s*=" src/app/\(service\)/one-on-one/page.tsx | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 177
🏁 Script executed:
# Look for the component that uses LibraryCard and LibraryRow to see onView definition
rg -B 5 "LibraryCard|LibraryRow" src/app/\(service\)/one-on-one/page.tsx | head -50Repository: code-zero-to-one/study-platform-client
Length of output: 877
🏁 Script executed:
# Search for handleView function definition
rg -B 3 -A 10 "const handleView|function handleView" src/app/\(service\)/one-on-one/page.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 446
🏁 Script executed:
# Also check if there's any import related to opening links/windows
rg "window.open|target.*blank" src/app/\(service\)/one-on-one/page.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 114
Make the clickable card/row keyboard-accessible and add security hardening for new-tab opens.
Both the LibraryCard and LibraryRow components use clickable <div> elements that lack keyboard accessibility features. Keyboard users cannot focus or activate these elements. Additionally, the handleView function opens links with window.open(link, '_blank') without noopener or noreferrer protection, exposing the site to reverse-tabnabbing attacks.
Add role="link", tabIndex={0}, aria-label, and an onKeyDown handler for Enter/Space keys to both components:
♿️ Suggested fixes
- <div
- onClick={() => onView(item.link)}
- className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default"
- >
+ <div
+ role="link"
+ tabIndex={0}
+ aria-label={`Open ${item.title}`}
+ onClick={() => onView(item.link)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onView(item.link);
+ }
+ }}
+ className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default"
+ >Apply the same changes to LibraryRow (lines 441–468).
Update the handler to include security hardening:
const handleView = (link: string) => {
const newWindow = window.open(link, '_blank', 'noopener,noreferrer');
if (newWindow) newWindow.opener = null;
};🤖 Prompt for AI Agents
In `@src/app/`(service)/one-on-one/page.tsx around lines 389 - 410, LibraryCard
and LibraryRow use clickable <div>s that are not keyboard-accessible and the
view handler opens a new tab without noopener/noreferrer; update both components
(LibraryCard and LibraryRow) to add role="link", tabIndex={0}, a descriptive
aria-label, and an onKeyDown that triggers the same action on Enter and Space
keys so keyboard users can focus and activate the element, and change the view
handler (handleView) to call window.open(link, '_blank', 'noopener,noreferrer')
and then, if a window object is returned, set newWindow.opener = null for
reverse-tabnabbing protection.
| <div onClick={(e) => e.stopPropagation()}> | ||
| <UserProfileModal | ||
| memberId={discussion.author.id} | ||
| trigger={ | ||
| <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"> | ||
| <div> | ||
| <UserAvatar | ||
| size={32} | ||
| image={discussion.author.avatar} | ||
| className="relative z-10" | ||
| /> | ||
| </div> | ||
| <span className="font-designer-13b text-text-default"> | ||
| {discussion.author.nickname} | ||
| </span> | ||
| </div> | ||
| } | ||
| /> |
There was a problem hiding this comment.
Make the profile trigger keyboard-accessible.
The modal trigger is a plain <div>, which isn’t focusable or operable by keyboard users. Use a <button> (or add role/tabIndex/keydown handlers) to avoid an accessibility blocker.
♿ Suggested fix (button trigger)
- <div onClick={(e) => e.stopPropagation()}>
- <UserProfileModal
- memberId={discussion.author.id}
- trigger={
- <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default">
- <div>
- <UserAvatar
- size={32}
- image={discussion.author.avatar}
- className="relative z-10"
- />
- </div>
- <span className="font-designer-13b text-text-default">
- {discussion.author.nickname}
- </span>
- </div>
- }
- />
- </div>
+ <UserProfileModal
+ memberId={discussion.author.id}
+ trigger={
+ <button
+ type="button"
+ onClick={(e) => e.stopPropagation()}
+ className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"
+ >
+ <div>
+ <UserAvatar
+ size={32}
+ image={discussion.author.avatar}
+ className="relative z-10"
+ />
+ </div>
+ <span className="font-designer-13b text-text-default">
+ {discussion.author.nickname}
+ </span>
+ </button>
+ }
+ />📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div onClick={(e) => e.stopPropagation()}> | |
| <UserProfileModal | |
| memberId={discussion.author.id} | |
| trigger={ | |
| <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"> | |
| <div> | |
| <UserAvatar | |
| size={32} | |
| image={discussion.author.avatar} | |
| className="relative z-10" | |
| /> | |
| </div> | |
| <span className="font-designer-13b text-text-default"> | |
| {discussion.author.nickname} | |
| </span> | |
| </div> | |
| } | |
| /> | |
| <UserProfileModal | |
| memberId={discussion.author.id} | |
| trigger={ | |
| <button | |
| type="button" | |
| onClick={(e) => e.stopPropagation()} | |
| className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default" | |
| > | |
| <div> | |
| <UserAvatar | |
| size={32} | |
| image={discussion.author.avatar} | |
| className="relative z-10" | |
| /> | |
| </div> | |
| <span className="font-designer-13b text-text-default"> | |
| {discussion.author.nickname} | |
| </span> | |
| </button> | |
| } | |
| /> |
🤖 Prompt for AI Agents
In `@src/components/discussion/discussion-detail-modal.tsx` around lines 78 - 95,
Replace the non-focusable trigger div passed to UserProfileModal's trigger prop
with a semantic, keyboard-focusable button: change the inner element used in
trigger (currently the <div className="flex items-center ..."> with UserAvatar
and nickname) to a <button type="button"> keeping the same className and styles,
add an accessible name (aria-label e.g. `${discussion.author.nickname} profile`
or ensure visible text remains), and ensure click propagation is still stopped
by keeping the outer onClick={(e) => e.stopPropagation()} wrapper; this makes
UserProfileModal's trigger keyboard-operable without changing visual styling.
| ...Array.from({ length: 10 }, (_, i): LibraryItem => ({ | ||
| id: 10 + i, | ||
| title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`, | ||
| description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.', | ||
| author: 'Admin', | ||
| date: `2024.12.${20 - i}`, | ||
| views: 100 + i * 10, | ||
| likes: 10 + i, | ||
| link: 'https://google.com', | ||
| isLiked: Math.random() > 0.5, | ||
| level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED', | ||
| tags: ['면접', '자료구조', 'CS'], | ||
| isBookmarked: Math.random() > 0.7, | ||
| })), |
There was a problem hiding this comment.
Math.random() in module-level mock data causes SSR hydration mismatch.
Since this is a 'use client' component, the mock data with Math.random() is evaluated at module load time. When SSR renders on the server and then hydrates on the client, the random values will differ, causing React hydration warnings.
Consider using deterministic values for mock data or moving the randomization inside a useEffect to ensure it only runs on the client.
🛠️ Suggested fix using deterministic mock data
...Array.from({ length: 10 }, (_, i): LibraryItem => ({
id: 10 + i,
title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.',
author: 'Admin',
date: `2024.12.${20 - i}`,
views: 100 + i * 10,
likes: 10 + i,
link: 'https://google.com',
- isLiked: Math.random() > 0.5,
+ isLiked: i % 2 === 0,
level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED',
tags: ['면접', '자료구조', 'CS'],
- isBookmarked: Math.random() > 0.7,
+ isBookmarked: i % 4 === 0,
})),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ...Array.from({ length: 10 }, (_, i): LibraryItem => ({ | |
| id: 10 + i, | |
| title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`, | |
| description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.', | |
| author: 'Admin', | |
| date: `2024.12.${20 - i}`, | |
| views: 100 + i * 10, | |
| likes: 10 + i, | |
| link: 'https://google.com', | |
| isLiked: Math.random() > 0.5, | |
| level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED', | |
| tags: ['면접', '자료구조', 'CS'], | |
| isBookmarked: Math.random() > 0.7, | |
| })), | |
| ...Array.from({ length: 10 }, (_, i): LibraryItem => ({ | |
| id: 10 + i, | |
| title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`, | |
| description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.', | |
| author: 'Admin', | |
| date: `2024.12.${20 - i}`, | |
| views: 100 + i * 10, | |
| likes: 10 + i, | |
| link: 'https://google.com', | |
| isLiked: i % 2 === 0, | |
| level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED', | |
| tags: ['면접', '자료구조', 'CS'], | |
| isBookmarked: i % 4 === 0, | |
| })), |
🤖 Prompt for AI Agents
In `@src/components/home/tabs/archive-tab.tsx` around lines 115 - 128, The
module-level Array.from mock data in archive-tab.tsx uses Math.random() (for
isLiked/isBookmarked) which causes SSR/client hydration mismatches; remove
Math.random from the module scope and either (A) make the mock values
deterministic (e.g., derive isLiked/isBookmarked from the index like i % 2 ===
0) in the Array.from expression, or (B) keep deterministic module data and
perform client-only randomization inside the ArchiveTab component using useState
+ useEffect to set isLiked/isBookmarked on mount (so randomization runs only on
the client). Target the Array.from({...}) generation in archive-tab.tsx and the
ArchiveTab component state initialization when applying the fix.
| const handleView = (link: string) => { | ||
| window.open(link, '_blank'); | ||
| }; |
There was a problem hiding this comment.
Add security attributes to window.open call.
Opening external links without noopener,noreferrer can expose the application to security risks (e.g., the opened page can access window.opener).
🔒 Proposed fix
const handleView = (link: string) => {
- window.open(link, '_blank');
+ window.open(link, '_blank', 'noopener,noreferrer');
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleView = (link: string) => { | |
| window.open(link, '_blank'); | |
| }; | |
| const handleView = (link: string) => { | |
| window.open(link, '_blank', 'noopener,noreferrer'); | |
| }; |
🤖 Prompt for AI Agents
In `@src/components/home/tabs/archive-tab.tsx` around lines 363 - 365, The
handleView function opens external links with window.open(link, '_blank') which
leaves window.opener available; update handleView to open the link in a way that
prevents the opened page from accessing window.opener and removes referrer info
— either create an anchor element with target="_blank" and rel="noopener
noreferrer" and click it, or capture the returned window from window.open and
set newWindow.opener = null (and include 'noreferrer' if using feature string)
before returning; locate and modify the handleView function to implement one of
these secure patterns.
| {dayItems.map((item) => ( | ||
| <div | ||
| key={item.id} | ||
| className={cn( | ||
| 'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium', | ||
| item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success', | ||
| item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning', | ||
| )} | ||
| title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`} | ||
| > | ||
| {item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}... | ||
| </div> | ||
| ))} | ||
| {dayItems.length > 2 && ( | ||
| <div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div> | ||
| )} |
There was a problem hiding this comment.
Inconsistent item display logic - shows all items but also "+N more" indicator.
Currently, all dayItems are rendered (line 47), but a "+N개 더" message still appears when there are more than 2 items (line 60). This results in showing all items and indicating there are more items, which is confusing.
Either limit the displayed items to 2 and show the overflow count, or remove the overflow indicator.
🔧 Proposed fix - limit to first 2 items
- {dayItems.map((item) => (
+ {dayItems.slice(0, 2).map((item) => (
<div
key={item.id}
className={cn(📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {dayItems.map((item) => ( | |
| <div | |
| key={item.id} | |
| className={cn( | |
| 'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium', | |
| item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success', | |
| item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning', | |
| )} | |
| title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`} | |
| > | |
| {item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}... | |
| </div> | |
| ))} | |
| {dayItems.length > 2 && ( | |
| <div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div> | |
| )} | |
| {dayItems.slice(0, 2).map((item) => ( | |
| <div | |
| key={item.id} | |
| className={cn( | |
| 'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium', | |
| item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success', | |
| item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning', | |
| )} | |
| title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`} | |
| > | |
| {item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}... | |
| </div> | |
| ))} | |
| {dayItems.length > 2 && ( | |
| <div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div> | |
| )} |
🤖 Prompt for AI Agents
In `@src/components/study-history/study-calendar.tsx` around lines 47 - 62, The
component currently renders all dayItems via dayItems.map(...) while also
showing an overflow indicator when dayItems.length > 2; change the rendering to
only display the first two items (use dayItems.slice(0, 2) or compute a
displayedItems array) in the map (the JSX that uses key={item.id}, title, and
role/subject rendering) and keep the existing overflow indicator using
dayItems.length - displayedItems.length so "+N개 더" correctly reflects hidden
items.
| <div className="col-span-1 flex justify-center"> | ||
| {item.link ? ( | ||
| <a | ||
| href={item.link} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default" | ||
| > | ||
| <ExternalLink className="w-4 h-4" /> | ||
| </a> |
There was a problem hiding this comment.
Add an accessible label to the external-link icon.
The icon-only link lacks an accessible name; screen readers will announce it as a generic “link.”
♿ Suggested fix
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
+ aria-label="외부 링크 열기"
className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default"
>
<ExternalLink className="w-4 h-4" />
</a>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="col-span-1 flex justify-center"> | |
| {item.link ? ( | |
| <a | |
| href={item.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default" | |
| > | |
| <ExternalLink className="w-4 h-4" /> | |
| </a> | |
| <div className="col-span-1 flex justify-center"> | |
| {item.link ? ( | |
| <a | |
| href={item.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| aria-label="외부 링크 열기" | |
| className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default" | |
| > | |
| <ExternalLink className="w-4 h-4" /> | |
| </a> |
🤖 Prompt for AI Agents
In `@src/components/study-history/study-history-row.tsx` around lines 51 - 60, The
external-link anchor rendering when item.link is present lacks an accessible
name; update the anchor that wraps ExternalLink (the <a> element in
StudyHistoryRow / where item.link is used) to include an accessible label —
either add an aria-label (e.g. aria-label={`Open ${item.title} in new tab`} or
similar) or include a visually-hidden text node (a <span
className="sr-only">Open {item.title} in new tab</span>) inside the anchor so
screen readers announce the purpose of the link while preserving the icon-only
visual.
| const memberIdStr = await getServerCookie('memberId'); | ||
| const memberId = Number(memberIdStr); | ||
|
|
||
| const userProfile = await getUserProfileInServer(memberId); | ||
| const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); | ||
|
|
There was a problem hiding this comment.
Guard against invalid memberId from cookies.
Line 17 converts the cookie to a number without validation; NaN will call /members/NaN/profile and likely throw. Add a guard/redirect before fetching.
🔧 Suggested fix
import { getServerCookie } from '@/utils/server-cookie';
+import { redirect } from 'next/navigation';
export default async function HomeDashboard() {
const memberIdStr = await getServerCookie('memberId');
const memberId = Number(memberIdStr);
+ if (!Number.isFinite(memberId)) {
+ redirect('/login');
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const memberIdStr = await getServerCookie('memberId'); | |
| const memberId = Number(memberIdStr); | |
| const userProfile = await getUserProfileInServer(memberId); | |
| const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); | |
| const memberIdStr = await getServerCookie('memberId'); | |
| const memberId = Number(memberIdStr); | |
| if (!Number.isFinite(memberId)) { | |
| redirect('/login'); | |
| } | |
| const userProfile = await getUserProfileInServer(memberId); | |
| const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); |
🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` around lines 16 - 21, The code currently
casts the cookie value returned by getServerCookie('memberId') into a Number
(memberId) without validating it, so if memberIdStr is missing or not numeric
you end up calling getUserProfileInServer(NaN); add a guard: check memberIdStr
exists and that Number.isInteger(Number(memberIdStr)) (or parseInt and validate)
and if invalid perform the proper redirect/early return before calling
getUserProfileInServer and getSincerityPresetByLevelName; update logic around
memberIdStr, memberId, getUserProfileInServer, and getSincerityPresetByLevelName
to only run when memberId is a valid integer.
| const userProfile = await getUserProfileInServer(memberId); | ||
| const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); | ||
|
|
||
| // 데이터 가공 | ||
| const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정'; | ||
| const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능'; | ||
| const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정'; | ||
| const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3); | ||
|
|
||
| return ( | ||
| <div className="w-full space-y-300"> | ||
| {/* 상단 프로필 바 */} | ||
| <div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm"> | ||
| <div className="flex items-center gap-300"> | ||
| <div className="relative"> | ||
| <UserAvatar | ||
| size={64} | ||
| image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''} | ||
| /> | ||
| <div | ||
| className={cn( | ||
| 'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm', | ||
| temperPreset.bgClass, | ||
| temperPreset.textClass, | ||
| )} |
There was a problem hiding this comment.
Add a fallback when sincerity preset is missing.
getSincerityPresetByLevelName() can return undefined for unexpected or new levels, causing temperPreset.bgClass / textClass to throw. Add a safe default.
🛡️ Suggested fix
- const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);
+ const temperPreset =
+ getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) ?? {
+ bgClass: 'bg-fill-neutral-subtle-default',
+ textClass: 'text-text-subtle',
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const userProfile = await getUserProfileInServer(memberId); | |
| const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName); | |
| // 데이터 가공 | |
| const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정'; | |
| const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능'; | |
| const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정'; | |
| const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3); | |
| return ( | |
| <div className="w-full space-y-300"> | |
| {/* 상단 프로필 바 */} | |
| <div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm"> | |
| <div className="flex items-center gap-300"> | |
| <div className="relative"> | |
| <UserAvatar | |
| size={64} | |
| image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''} | |
| /> | |
| <div | |
| className={cn( | |
| 'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm', | |
| temperPreset.bgClass, | |
| temperPreset.textClass, | |
| )} | |
| const userProfile = await getUserProfileInServer(memberId); | |
| const temperPreset = | |
| getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) ?? { | |
| bgClass: 'bg-fill-neutral-subtle-default', | |
| textClass: 'text-text-subtle', | |
| }; | |
| // 데이터 가공 | |
| const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정'; | |
| const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능'; | |
| const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정'; | |
| const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3); | |
| return ( | |
| <div className="w-full space-y-300"> | |
| {/* 상단 프로필 바 */} | |
| <div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm"> | |
| <div className="flex items-center gap-300"> | |
| <div className="relative"> | |
| <UserAvatar | |
| size={64} | |
| image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''} | |
| /> | |
| <div | |
| className={cn( | |
| 'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm', | |
| temperPreset.bgClass, | |
| temperPreset.textClass, | |
| )} |
🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` around lines 19 - 43,
getSincerityPresetByLevelName can return undefined, so update the code that
assigns temperPreset (used in the JSX: temperPreset.bgClass and
temperPreset.textClass) to ensure a safe default object is used when undefined;
e.g., after calling
getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) coalesce to a
fallback preset with safe bgClass and textClass values (or destructure with
defaults) so the JSX rendering of the profile badge always has valid class
strings.
| <h3 className="font-designer-15b text-text-default">의견 보내기</h3> | ||
| </div> | ||
|
|
||
| <FeedbackLink variant="compact" className="w-full" /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/widgets/home/feedback-link.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 1310
Remove unsupported props from FeedbackLink component.
The FeedbackLink component at src/widgets/home/feedback-link.tsx does not accept any props—it's defined as function FeedbackLink() with no parameters. Passing variant="compact" and className="w-full" will fail type-checking and the props will be ignored at runtime. Either remove these props or update the FeedbackLink component signature to accept and use them.
🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` at line 176, The FeedbackLink usage is
passing unsupported props (variant="compact", className="w-full") which the
component doesn't accept; fix by either removing those props from the JSX in the
home dashboard (replace <FeedbackLink variant="compact" className="w-full" />
with <FeedbackLink />) or update the FeedbackLink component signature (function
FeedbackLink) in feedback-link.tsx to accept and apply props (e.g., props: {
variant?: string; className?: string } and forward className/variant to the
rendered element); choose one approach and ensure types are updated so the props
are consumed or not passed.
🌱 연관된 이슈
☘️ 작업 내용
code rabbit test
🍀 참고사항
스크린샷 (선택)
Summary by CodeRabbit
New Features
Improvements