[Feat/post-105] ✨ feat: 추천 포스트 기능 추가 & supabse 연동 및 인기순 정렬 추가#106
[Feat/post-105] ✨ feat: 추천 포스트 기능 추가 & supabse 연동 및 인기순 정렬 추가#106swallowedB merged 6 commits intodevfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthrough추천 포스트 기능을 추가하고 좋아요 관리를 Supabase 추상화로 분리했으며, posts 쿼리와 여러 컴포넌트를 async/await로 변환했습니다. 다수 컴포넌트에서 스타일·간격·타입 선택성이 조정되었습니다. Changes
Sequence DiagramsequenceDiagram
participant Browser
participant Page
participant RecommendedPosts
participant QueryPosts
participant Supabase
participant SortPosts
Browser->>Page: 게시물 상세 페이지 요청
Page->>RecommendedPosts: <RecommendedPosts post={post}>
RecommendedPosts->>QueryPosts: queryPosts({ series, sort: "popular", perPage: limit+1 })
QueryPosts->>Supabase: 인기 정렬용 좋아요 수 요청 (getLikeCountsForPosts)
Supabase-->>QueryPosts: likeCounts 맵 반환
QueryPosts->>SortPosts: sortPosts(posts, "popular", likeCounts)
SortPosts-->>QueryPosts: 정렬된 포스트 반환
QueryPosts-->>RecommendedPosts: QueryPostsResult
RecommendedPosts->>RecommendedPosts: 원본 포스트 필터링, 상위 N개 선택
RecommendedPosts-->>Page: RecommendedPostCard 렌더링 정보 반환
Page-->>Browser: 페이지 렌더링 완료
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📜 Recent review detailsConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro 📒 Files selected for processing (1)
✏️ Tip: You can disable this entire section by setting Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx (1)
20-30:appliedSort로직이 불필요해 보입니다.
PostSort타입이"latest" | "popular"만 허용한다면, Line 20의 삼항 연산자는sort값을 그대로 전달하는 것과 동일합니다. 단순화할 수 있습니다.♻️ 제안된 수정
- const appliedSort: PostSort = sort === "popular" ? "popular" : "latest"; - - const result = await queryPosts({ + const result = await queryPosts({ category, series, - sort: appliedSort, + sort, page, perPage: pageSize, includeDrafts: false, visiblePages: 5, });단,
PostSort에 다른 값이 추가될 가능성이 있다면 현재 로직을 유지하는 것도 방어적으로 괜찮습니다.src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx (1)
24-26:key로post.title대신 고유한 값을 사용하는 것이 안전합니다.동일한 제목을 가진 포스트가 있을 경우 React의 key 중복 경고가 발생할 수 있습니다.
post.href는 slug 기반이므로 고유성이 보장됩니다.🔧 제안된 수정
- <RecommendedPostCard key={post.title} post={post} /> + <RecommendedPostCard key={post.href} post={post} />
🤖 Fix all issues with AI agents
In `@src/app/`(layout)/(shell)/_components/layout/SiteHeader.tsx:
- Around line 87-108: The two child motion.button elements inside the section
already covered by the parent section's md:hidden class have redundant
className="md:hidden" attributes; remove the md:hidden (and any trailing space)
from the motion.button elements that render the menu/search buttons (the buttons
using toggleMobileMenu, handleSearchClick, and rendering {isMobileMenuOpen ? <X
/> : <Menu />} and <Search />) so the visibility is controlled only by the
parent and the markup is cleaner.
- Around line 88-97: Update the mobile menu button in the SiteHeader component
so its accessibility state reflects whether the menu is open: make the
aria-label dynamic (use "모바일 메뉴 닫기" when isMobileMenuOpen is true, otherwise
"모바일 메뉴 열기") and add an aria-expanded attribute bound to isMobileMenuOpen;
modify the motion.button that calls toggleMobileMenu (and renders X/Menu based
on isMobileMenuOpen) to include these dynamic attributes so screen readers
receive correct state.
In `@src/app/`(layout)/(shell)/_components/posts/PostCard.tsx:
- Line 12: The PostCard component's excerpt prop is now optional but the JSX
always renders an empty paragraph when excerpt is undefined; update the PostCard
render to conditionally render the excerpt block (or a placeholder) only when
props.excerpt exists to avoid empty <p> elements — locate the PostCard
function/Component and the JSX that renders the <p> for excerpt (also the
similar rendering around lines 119-128) and wrap it with a conditional (e.g.,
render the paragraph only if excerpt is truthy, or render a small placeholder
string/UI when you want a visible fallback).
In
`@src/app/`(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx:
- Around line 16-17: The anchor tag using post.href in the RecommendedPostCard
component is causing full page reloads; replace the raw <a href={post.href}>
with Next.js's Link component: import Link from 'next/link', wrap the card
content with <Link href={post.href}> (move any className/children into the Link
or an inner <a> as appropriate), and remove the direct plain <a> usage so
navigation uses client-side routing. Ensure the change is made inside the
RecommendedPostCard component and preserves existing attributes/styling.
In `@src/app/`(layout)/posts/[slug]/page.tsx:
- Around line 93-99: The RecommendedPosts component is self-closed
inconsistently; standardize the JSX self-closing tag spacing for the prop post
by making both usages of <RecommendedPosts post={post} .../> follow the same
style (either "post={post} />" or "post={post}/>" )—update the instance inside
the mt-10 xl:hidden div to match the other occurrence so both <RecommendedPosts
post={post} /> forms are consistent.
In `@src/components/common/CommandPalette.tsx`:
- Around line 64-71: Dialog.Content and the nested Command component both apply
the same max-h-[70vh], causing duplicated styling; remove the redundant
max-h-[70vh] from one place (preferably the inner Command component) so the max
height is controlled in a single location, locate the class array on
Dialog.Content and the Command component that also sets max-h-[70vh], keep the
max-h on the higher-level Dialog.Content (or document the intentional
duplication if required) and update any tests/styles referencing the removed
class.
In `@src/hooks/usePostLike.ts`:
- Around line 34-36: The repeated defensive pattern using viewerIdRef.current ??
getOrCreateViewerId() inside the run function (and again later) should be
consolidated: either extract a small helper like resolveViewerId() that returns
viewerIdRef.current if set or calls getOrCreateViewerId(), or if
getOrCreateViewerId() already caches/returns a stable id, remove viewerIdRef
entirely and call getOrCreateViewerId() directly from run (and other call
sites). Update usages in the run function and the other location to use the new
helper or the single getOrCreateViewerId() call to avoid duplication and clarify
initialization logic.
In `@src/lib/posts/source.ts`:
- Line 1: The import using a brittle relative path ("../../../.velite") in
src/lib/posts/source.ts should be replaced with a path alias to avoid breakage
if files move: add a paths entry in tsconfig.json (e.g. map "#velite/*" or
"@velite/*" to the repository root) and update the import of posts from
"../../../.velite" to the chosen alias (referencing the existing import of
posts). Also verify whether a previous "#site" alias was removed—if so, confirm
that change or reintroduce an appropriate alias to preserve prior behavior.
In `@src/lib/supabase/postLikes.ts`:
- Around line 16-43: fetchPostLikeState currently fetches all post_likes rows
for a post and then filters client-side to determine if viewerId liked it;
change it to use two targeted Supabase queries: one query on post_likes with
.eq("post_id", postId).select("*", { count: "exact" }) to get the total count,
and a second query with .eq("post_id", postId).eq("viewer_id",
viewerId).select("viewer_id", { count: "exact" }) (or a simple
.select("viewer_id").limit(1)) to determine liked existence; update the logic in
fetchPostLikeState to use these two results (total from the first, liked =
second count > 0) and preserve error handling.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (20)
src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsxsrc/app/(layout)/(shell)/_components/layout/SiteHeader.tsxsrc/app/(layout)/(shell)/_components/posts/PostCard.tsxsrc/app/(layout)/(shell)/page.tsxsrc/app/(layout)/posts/[slug]/_components/PostToc.tsxsrc/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsxsrc/app/(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsxsrc/app/(layout)/posts/[slug]/page.tsxsrc/components/common/CommandPalette.tsxsrc/components/common/fallback/BrowseFallback.tsxsrc/components/mdx/Quote.tsxsrc/hooks/usePostLike.tssrc/lib/posts/index.tssrc/lib/posts/query.tssrc/lib/posts/recommend.tssrc/lib/posts/source.tssrc/lib/posts/utils.tssrc/lib/posts/viewModels.tssrc/lib/supabase/postLikes.tsvelite.config.ts
🧰 Additional context used
🧬 Code graph analysis (12)
src/lib/posts/recommend.ts (3)
src/lib/posts/source.ts (1)
VelitePost(4-4)src/lib/posts/query.ts (1)
queryPosts(36-92)src/lib/posts/queries.ts (4)
getAllPosts(9-17)post(14-14)getPostsByTag(41-49)getPostBySlug(19-29)
src/components/common/CommandPalette.tsx (2)
src/app/(layout)/(shell)/_components/dock/DockMenu.tsx (1)
DockMenu(20-161)src/app/(layout)/(shell)/_components/home/HeroLeft.tsx (1)
HeroLeft(11-69)
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx (1)
src/app/(layout)/(shell)/_components/home/_hooks/useSearchTransition.ts (3)
setIsMobileMenuOpen(35-36)useSearchTransition(7-52)setIsMobileMenuOpen(38-38)
src/lib/posts/query.ts (3)
src/lib/posts/source.ts (1)
VelitePost(4-4)src/lib/supabase/postLikes.ts (1)
getLikeCountsForPosts(84-106)src/lib/posts/utils.ts (1)
sortPosts(6-34)
src/lib/supabase/postLikes.ts (1)
src/lib/supabase/client.ts (1)
supabase(3-6)
src/hooks/usePostLike.ts (2)
src/lib/supabase/viewerId.ts (1)
getOrCreateViewerId(3-19)src/lib/supabase/postLikes.ts (5)
fetchPostLikeState(16-43)removePostLike(73-81)addPostLike(63-70)isDuplicateKeyError(11-13)fetchPostLikeCount(46-60)
src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx (3)
src/lib/posts/source.ts (1)
VelitePost(4-4)src/lib/posts/recommend.ts (1)
getRecommendedPostsForPost(4-22)src/app/(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx (1)
RecommendedPost(4-9)
src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx (1)
src/lib/posts/query.ts (1)
queryPosts(36-92)
src/app/(layout)/(shell)/page.tsx (1)
src/lib/posts/query.ts (1)
queryPosts(36-92)
src/lib/posts/viewModels.ts (1)
src/lib/posts/source.ts (1)
VelitePost(4-4)
src/app/(layout)/posts/[slug]/page.tsx (2)
src/lib/posts/source.ts (1)
velitePosts(3-3)src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx (1)
RecommendedPosts(6-30)
src/lib/posts/utils.ts (2)
src/lib/posts/source.ts (1)
VelitePost(4-4)src/lib/supabase/postLikes.ts (1)
LikeCountMap(4-4)
🔇 Additional comments (20)
src/app/(layout)/posts/[slug]/_components/PostToc.tsx (1)
30-32: LGTM!활성화된 TOC 항목에
font-semibold를 추가하여 시각적 구분을 강화한 것은 좋은 개선입니다. 기존의 색상 변화(text-gray-900,dark:text-blue-300)와 왼쪽 강조 라인에 더해 폰트 굵기까지 적용되어 사용자가 현재 위치를 더 명확하게 인지할 수 있겠네요.src/components/common/fallback/BrowseFallback.tsx (1)
13-13: LGTM!
mt-10→mt-30으로 상단 마진을 늘린 변경이며, PR 목표에서 언급된 전반적인 레이아웃/스타일 조정과 일관성이 있습니다. Tailwind v4에서mt-30은 유효한 동적 유틸리티 값입니다 (margin-top: 7.5rem).src/components/common/CommandPalette.tsx (2)
84-86: 모바일 UX 개선이 적절합니다.모바일 사용자에게는 물리적 ESC 키가 없으므로
hidden sm:inline-block으로 데스크톱에서만 표시하는 것이 합리적입니다.
89-89: 뷰포트 기반 높이 제한이 적절합니다.
max-h-80(고정 320px)에서max-h-[60vh]로 변경하여 다양한 화면 크기에 유연하게 대응합니다. 부모 컨테이너(70vh)보다 작은 60vh를 사용하여 검색 입력 영역의 공간도 확보됩니다.src/components/mdx/Quote.tsx (1)
69-71: 텍스트 오버랩 문제 수정이 올바릅니다 ✅
leading-0(line-height: 0)이 텍스트 줄 겹침의 원인이었고,leading-snug(line-height: 1.375)로 교체한 것은 적절한 해결책입니다.wrap-break-word추가는 긴 단어가 포함된 인용문에서 레이아웃이 깨지는 것을 방지하므로 좋은 개선입니다.프로젝트가 Tailwind CSS v4를 사용 중이므로
wrap-break-word(overflow-wrap: break-word)는 유효한 유틸리티 클래스입니다.velite.config.ts (1)
45-51: LGTM!
thumbnail필드를 optional로 변경하고 빈 문자열을undefined로 정규화하는 로직이 깔끔합니다.RecommendedPostCard에서"/post-fallback.png"fallback과 잘 연동됩니다.src/app/(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx (1)
28-34: 이미지 처리 LGTM!fallback 이미지 처리와
alt텍스트에post.title사용이 접근성 측면에서 좋습니다. 고정 크기(64x64)와object-cover조합도 적절합니다.src/lib/supabase/postLikes.ts (2)
20-22:viewerId가 null일 때 count도 0 반환하는 로직 확인 필요현재
viewerId가 null이면count: 0을 반환합니다. 로그아웃 상태의 사용자도 좋아요 개수는 볼 수 있어야 하지 않을까요?viewerId가 없을 때는liked: false만 설정하고, count는 정상적으로 조회하는 것이 의도에 맞을 수 있습니다.
84-106: LGTM! 배치 조회 구현
post_like_counts뷰를 활용한 배치 조회가 깔끔합니다. 빈 배열 처리와 에러 핸들링도 적절합니다.src/lib/posts/utils.ts (1)
6-30: LGTM! 인기순 정렬 구현
likeCountsoptional 파라미터 추가와 정렬 로직이 깔끔합니다. 특히:
likeCounts?.[slug] ?? 0으로 안전하게 접근- 좋아요 수가 같을 때 날짜 기준 tiebreaker 적용
- 기존
latest정렬 로직 유지src/lib/posts/query.ts (1)
36-70: LGTM! async 변환과 인기순 정렬 로직이 깔끔합니다.
sort === "popular"조건에서만getLikeCountsForPosts를 호출하여 불필요한 API 호출을 피하는 점이 좋습니다.getLikeCountsForPosts가 에러 시 빈 객체를 반환하므로 graceful degradation도 잘 처리됩니다.src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx (1)
13-15: async Server Component로의 변환이 적절합니다.Next.js App Router에서 Server Component를 async로 선언하고
await queryPosts()를 사용하는 패턴이 올바르게 적용되었습니다.Also applies to: 22-30
src/lib/posts/recommend.ts (1)
4-22: 추천 포스트 로직이 잘 구현되어 있습니다.
perPage: limit + 1로 현재 포스트를 필터링해도 최대limit개를 반환할 수 있도록 한 점이 좋습니다. 시리즈가 없는 포스트에 대한 early return도 적절합니다.src/lib/posts/index.ts (1)
9-9: LGTM!기존 패턴에 맞게 recommend 모듈을 re-export하고 있습니다.
src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx (1)
6-15: async 데이터 페칭과 매핑 로직이 깔끔합니다.
VelitePost를RecommendedPost타입으로 변환하는 로직이 명확하고, 추천 포스트가 없을 때null을 반환하는 처리도 적절합니다.src/app/(layout)/posts/[slug]/page.tsx (1)
11-11: LGTM! velitePosts 데이터 소스로의 전환이 잘 되었습니다.
@/lib/posts에서velitePosts를 가져오는 방식과find()를 사용한 조회 로직이 일관성 있게 적용되었습니다.Also applies to: 21-21, 59-59
src/app/(layout)/(shell)/page.tsx (1)
22-22: LGTM! async/await 패턴으로의 전환이 올바르게 적용되었습니다.
queryPosts가 비동기 함수로 변경됨에 따라await키워드가 적절히 추가되었습니다. 인기순 정렬을 위한 좋아요 카운트 조회 등 비동기 작업을 지원하기 위한 변경으로 보입니다.src/hooks/usePostLike.ts (2)
5-11: LGTM! Supabase 로직을 별도 모듈로 추상화한 것이 좋습니다.
postLikes모듈로 Supabase 관련 로직을 분리하여 관심사 분리가 잘 되었습니다. 테스트 용이성과 재사용성이 향상됩니다.
46-77: LGTM! toggleLike 로직이 잘 구현되었습니다.
- 입력 값 검증 (line 48)
- 좋아요 취소/추가 분기 처리
- 중복 키 에러 핸들링 (line 65-68)
- 낙관적 업데이트로 UX 개선
모두 적절하게 구현되었습니다.
src/lib/posts/viewModels.ts (1)
10-10: LGTM! 타입과 기본값 변경이 적절합니다.
excerpt를 optional로 변경하여PostCard.tsx의 props와 일관성 유지- 썸네일 fallback 경로가
/post-fallback.png로 통일되어 코드베이스 전반에서 일관성 있게 사용됩니다.Also applies to: 16-16
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
| <section className="flex grow justify-between items-center py-1 md:hidden"> | ||
| <motion.button | ||
| type="button" | ||
| className="md:hidden" | ||
| onClick={toggleMobileMenu} | ||
| aria-label="모바일 메뉴 열기" | ||
| whileTap={{ scale: 0.9, opacity: 0.8 }} | ||
| transition={{ duration: 0.08 }} | ||
| > | ||
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | ||
| </motion.button> | ||
| <motion.button | ||
| type="button" | ||
| className="md:hidden " | ||
| onClick={handleSearchClick} | ||
| aria-label="검색 열기" | ||
| whileTap={{ scale: 0.9, opacity: 0.8 }} | ||
| transition={{ duration: 0.08 }} | ||
| > | ||
| <Search size={22} /> | ||
| </motion.button> | ||
| </section> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
중복된 md:hidden 클래스 제거 권장
부모 <section>에 이미 md:hidden이 적용되어 있으므로, 내부 버튼들(lines 90, 100)의 md:hidden은 불필요합니다. 제거해도 동작은 동일하며 코드가 더 간결해집니다.
♻️ 제안 수정
<section className="flex grow justify-between items-center py-1 md:hidden">
<motion.button
type="button"
- className="md:hidden"
onClick={toggleMobileMenu}
aria-label="모바일 메뉴 열기"
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>
<motion.button
type="button"
- className="md:hidden "
onClick={handleSearchClick}
aria-label="검색 열기"
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
<Search size={22} />
</motion.button>
</section>📝 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.
| <section className="flex grow justify-between items-center py-1 md:hidden"> | |
| <motion.button | |
| type="button" | |
| className="md:hidden" | |
| onClick={toggleMobileMenu} | |
| aria-label="모바일 메뉴 열기" | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | |
| </motion.button> | |
| <motion.button | |
| type="button" | |
| className="md:hidden " | |
| onClick={handleSearchClick} | |
| aria-label="검색 열기" | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| <Search size={22} /> | |
| </motion.button> | |
| </section> | |
| <section className="flex grow justify-between items-center py-1 md:hidden"> | |
| <motion.button | |
| type="button" | |
| onClick={toggleMobileMenu} | |
| aria-label="모바일 메뉴 열기" | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | |
| </motion.button> | |
| <motion.button | |
| type="button" | |
| onClick={handleSearchClick} | |
| aria-label="검색 열기" | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| <Search size={22} /> | |
| </motion.button> | |
| </section> |
🤖 Prompt for AI Agents
In `@src/app/`(layout)/(shell)/_components/layout/SiteHeader.tsx around lines 87 -
108, The two child motion.button elements inside the section already covered by
the parent section's md:hidden class have redundant className="md:hidden"
attributes; remove the md:hidden (and any trailing space) from the motion.button
elements that render the menu/search buttons (the buttons using
toggleMobileMenu, handleSearchClick, and rendering {isMobileMenuOpen ? <X /> :
<Menu />} and <Search />) so the visibility is controlled only by the parent and
the markup is cleaner.
| <motion.button | ||
| type="button" | ||
| className="md:hidden" | ||
| onClick={toggleMobileMenu} | ||
| aria-label="모바일 메뉴 열기" | ||
| whileTap={{ scale: 0.9, opacity: 0.8 }} | ||
| transition={{ duration: 0.08 }} | ||
| > | ||
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | ||
| </motion.button> |
There was a problem hiding this comment.
접근성 개선: 메뉴 상태에 따라 aria-label 동적 변경 권장
현재 aria-label이 "모바일 메뉴 열기"로 고정되어 있습니다. 메뉴가 열린 상태에서는 "모바일 메뉴 닫기"로 변경되어야 스크린 리더 사용자에게 정확한 정보를 전달할 수 있습니다.
♿ 제안 수정
<motion.button
type="button"
- className="md:hidden"
onClick={toggleMobileMenu}
- aria-label="모바일 메뉴 열기"
+ aria-label={isMobileMenuOpen ? "모바일 메뉴 닫기" : "모바일 메뉴 열기"}
+ aria-expanded={isMobileMenuOpen}
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>aria-expanded 속성을 추가하면 메뉴의 열림/닫힘 상태를 보조 기술에 더 명확하게 전달할 수 있습니다.
📝 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.
| <motion.button | |
| type="button" | |
| className="md:hidden" | |
| onClick={toggleMobileMenu} | |
| aria-label="모바일 메뉴 열기" | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | |
| </motion.button> | |
| <motion.button | |
| type="button" | |
| className="md:hidden" | |
| onClick={toggleMobileMenu} | |
| aria-label={isMobileMenuOpen ? "모바일 메뉴 닫기" : "모바일 메뉴 열기"} | |
| aria-expanded={isMobileMenuOpen} | |
| whileTap={{ scale: 0.9, opacity: 0.8 }} | |
| transition={{ duration: 0.08 }} | |
| > | |
| {isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />} | |
| </motion.button> |
🤖 Prompt for AI Agents
In `@src/app/`(layout)/(shell)/_components/layout/SiteHeader.tsx around lines 88 -
97, Update the mobile menu button in the SiteHeader component so its
accessibility state reflects whether the menu is open: make the aria-label
dynamic (use "모바일 메뉴 닫기" when isMobileMenuOpen is true, otherwise "모바일 메뉴 열기")
and add an aria-expanded attribute bound to isMobileMenuOpen; modify the
motion.button that calls toggleMobileMenu (and renders X/Menu based on
isMobileMenuOpen) to include these dynamic attributes so screen readers receive
correct state.
| date: string; | ||
| title: string; | ||
| excerpt: string; | ||
| excerpt?: string; |
There was a problem hiding this comment.
excerpt가 optional로 변경되었지만 렌더링 시 처리가 없습니다.
excerpt가 undefined인 경우에도 빈 <p> 태그가 렌더링됩니다. 의도된 동작이라면 괜찮지만, 빈 공간이 UI에 표시될 수 있습니다.
excerpt가 없을 때 해당 영역을 숨기거나 플레이스홀더를 표시하는 것이 UX상 더 나을 수 있습니다.
🔧 제안된 수정 (excerpt가 없을 때 숨기기)
- <p
- className={clsx(
- "text-gray-800 dark:text-gray-200/80",
- isSmall
- ? "mt-1.5 line-clamp-2 text-[10px] leading-tight"
- : "mt-2 line-clamp-2 text-xs"
- )}
- >
- {excerpt}
- </p>
+ {excerpt && (
+ <p
+ className={clsx(
+ "text-gray-800 dark:text-gray-200/80",
+ isSmall
+ ? "mt-1.5 line-clamp-2 text-[10px] leading-tight"
+ : "mt-2 line-clamp-2 text-xs"
+ )}
+ >
+ {excerpt}
+ </p>
+ )}Also applies to: 119-128
🤖 Prompt for AI Agents
In `@src/app/`(layout)/(shell)/_components/posts/PostCard.tsx at line 12, The
PostCard component's excerpt prop is now optional but the JSX always renders an
empty paragraph when excerpt is undefined; update the PostCard render to
conditionally render the excerpt block (or a placeholder) only when
props.excerpt exists to avoid empty <p> elements — locate the PostCard
function/Component and the JSX that renders the <p> for excerpt (also the
similar rendering around lines 119-128) and wrap it with a conditional (e.g.,
render the paragraph only if excerpt is truthy, or render a small placeholder
string/UI when you want a visible fallback).
| <a | ||
| href={post.href} |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Next.js <Link> 컴포넌트 사용 권장
현재 <a> 태그를 직접 사용하고 있어 클릭 시 전체 페이지 새로고침이 발생합니다. Next.js의 <Link> 컴포넌트를 사용하면 클라이언트 사이드 네비게이션으로 더 빠른 페이지 전환이 가능합니다.
♻️ 제안된 수정
import clsx from "clsx";
import Image from "next/image";
+import Link from "next/link";
// ...
- <a
- href={post.href}
+ <Link
+ href={post.href}
className={clsx(
// ...
)}
>
{/* content */}
- </a>
+ </Link>📝 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.
| <a | |
| href={post.href} | |
| import clsx from "clsx"; | |
| import Image from "next/image"; | |
| import Link from "next/link"; | |
| // ... other code ... | |
| <Link | |
| href={post.href} | |
| className={clsx( | |
| // className values | |
| )} | |
| > | |
| {/* content */} | |
| </Link> |
🤖 Prompt for AI Agents
In
`@src/app/`(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx
around lines 16 - 17, The anchor tag using post.href in the RecommendedPostCard
component is causing full page reloads; replace the raw <a href={post.href}>
with Next.js's Link component: import Link from 'next/link', wrap the card
content with <Link href={post.href}> (move any className/children into the Link
or an inner <a> as appropriate), and remove the direct plain <a> usage so
navigation uses client-side routing. Ensure the change is made inside the
RecommendedPostCard component and preserves existing attributes/styling.
| <RecommendedPosts post={post} /> | ||
| </div> | ||
| </aside> | ||
| </div> | ||
|
|
||
| <div className="mt-10 xl:hidden"> | ||
| <RecommendedPosts /> | ||
| <RecommendedPosts post={post}/> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
코드 포맷팅 일관성 확인 필요
Line 93과 Line 99에서 RecommendedPosts 컴포넌트 사용 시 닫는 태그 앞 공백이 일관되지 않습니다:
- Line 93:
post={post} />(공백 있음) - Line 99:
post={post}/>(공백 없음)
사소한 부분이지만, 코드 일관성을 위해 통일하면 좋겠습니다.
🔧 제안된 수정
<div className="mt-10 xl:hidden">
- <RecommendedPosts post={post}/>
+ <RecommendedPosts post={post} />
</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.
| <RecommendedPosts post={post} /> | |
| </div> | |
| </aside> | |
| </div> | |
| <div className="mt-10 xl:hidden"> | |
| <RecommendedPosts /> | |
| <RecommendedPosts post={post}/> | |
| <RecommendedPosts post={post} /> | |
| </div> | |
| </aside> | |
| </div> | |
| <div className="mt-10 xl:hidden"> | |
| <RecommendedPosts post={post} /> |
🤖 Prompt for AI Agents
In `@src/app/`(layout)/posts/[slug]/page.tsx around lines 93 - 99, The
RecommendedPosts component is self-closed inconsistently; standardize the JSX
self-closing tag spacing for the prop post by making both usages of
<RecommendedPosts post={post} .../> follow the same style (either "post={post}
/>" or "post={post}/>" )—update the instance inside the mt-10 xl:hidden div to
match the other occurrence so both <RecommendedPosts post={post} /> forms are
consistent.
| <Dialog.Content | ||
| className={[ | ||
| "fixed inset-x-3 top-[18%] z-70 w-auto rounded-2xl border border-white/10", | ||
| "bg-foreground/80 text-background shadow-2xl dark:bg-foreground", | ||
| "max-h-[70vh]", | ||
| "sm:left-1/2 sm:top-50 sm:w-full sm:max-w-xl sm:-translate-x-1/2 sm:inset-x-auto", | ||
| ].join(" ")} | ||
| > |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
반응형 레이아웃 개선이 잘 되었습니다.
모바일에서는 inset-x-3으로 좌우 여백을 주고, sm 이상에서는 sm:inset-x-auto와 sm:left-1/2 sm:-translate-x-1/2로 중앙 정렬하는 패턴이 적절합니다.
다만 한 가지 확인 사항: Line 68의 max-h-[70vh]가 Line 74의 Command 컴포넌트에서도 동일하게 적용되어 있어 중복됩니다. 의도적인 방어 코드라면 괜찮지만, 불필요하다면 한 곳에서만 관리하는 것이 유지보수에 유리할 수 있습니다.
🤖 Prompt for AI Agents
In `@src/components/common/CommandPalette.tsx` around lines 64 - 71,
Dialog.Content and the nested Command component both apply the same
max-h-[70vh], causing duplicated styling; remove the redundant max-h-[70vh] from
one place (preferably the inner Command component) so the max height is
controlled in a single location, locate the class array on Dialog.Content and
the Command component that also sets max-h-[70vh], keep the max-h on the
higher-level Dialog.Content (or document the intentional duplication if
required) and update any tests/styles referencing the removed class.
| const run = async () => { | ||
| const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); | ||
| if (!viewerId) { | ||
| setLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| const { | ||
| data, | ||
| count: total, | ||
| error, | ||
| } = await supabase | ||
| .from("post_likes") | ||
| .select("viewer_id", { count: "exact" }) | ||
| .eq("post_id", postId); | ||
| const { count, liked } = await fetchPostLikeState(postId, viewerId); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
viewerId 초기화 타이밍 관련 방어적 코딩 확인
viewerIdRef.current ?? getOrCreateViewerId() 패턴은 첫 번째 useEffect (line 27-29)가 실행되기 전에 이 코드가 실행될 수 있는 상황을 대비한 방어적 코딩으로 보입니다.
다만, 두 곳(line 35, line 47)에서 동일한 패턴이 반복되고 있어, 헬퍼 함수로 추출하거나 getOrCreateViewerId()가 이미 캐싱을 지원한다면 viewerIdRef를 제거하는 것도 고려해볼 수 있습니다. 현재 구현도 정상 동작하므로 선택적 개선 사항입니다.
🤖 Prompt for AI Agents
In `@src/hooks/usePostLike.ts` around lines 34 - 36, The repeated defensive
pattern using viewerIdRef.current ?? getOrCreateViewerId() inside the run
function (and again later) should be consolidated: either extract a small helper
like resolveViewerId() that returns viewerIdRef.current if set or calls
getOrCreateViewerId(), or if getOrCreateViewerId() already caches/returns a
stable id, remove viewerIdRef entirely and call getOrCreateViewerId() directly
from run (and other call sites). Update usages in the run function and the other
location to use the new helper or the single getOrCreateViewerId() call to avoid
duplication and clarify initialization logic.
| @@ -1,4 +1,4 @@ | |||
| import { posts } from "#site"; | |||
| import { posts } from "../../../.velite"; | |||
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
상대 경로 사용에 대한 참고사항
"../../../.velite" 상대 경로가 현재는 동작하지만, 파일 위치가 변경되면 깨질 수 있습니다. tsconfig.json에 path alias(예: #velite 또는 @velite)를 설정하면 유지보수가 더 쉬워질 수 있습니다. 다만 이전에 #site alias가 있었다면 해당 설정이 제거된 이유를 확인해 보시길 권장드립니다.
🤖 Prompt for AI Agents
In `@src/lib/posts/source.ts` at line 1, The import using a brittle relative path
("../../../.velite") in src/lib/posts/source.ts should be replaced with a path
alias to avoid breakage if files move: add a paths entry in tsconfig.json (e.g.
map "#velite/*" or "@velite/*" to the repository root) and update the import of
posts from "../../../.velite" to the chosen alias (referencing the existing
import of posts). Also verify whether a previous "#site" alias was removed—if
so, confirm that change or reintroduce an appropriate alias to preserve prior
behavior.
| export async function fetchPostLikeState( | ||
| postId: string, | ||
| viewerId: string | null, | ||
| ): Promise<LikeState> { | ||
| if (!postId || !viewerId) { | ||
| return { count: 0, liked: false }; | ||
| } | ||
|
|
||
| const { | ||
| data, | ||
| count: total, | ||
| error, | ||
| } = await supabase | ||
| .from("post_likes") | ||
| .select("viewer_id", { count: "exact" }) | ||
| .eq("post_id", postId); | ||
|
|
||
| if (error) { | ||
| console.error("좋아요 조회 실패:", error); | ||
| return { count: 0, liked: false }; | ||
| } | ||
|
|
||
| const rows = data ?? []; | ||
| const count = total ?? rows.length; | ||
| const liked = rows.some((row) => row.viewer_id === viewerId); | ||
|
|
||
| return { count, liked }; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
성능 개선 가능: 모든 행을 가져온 후 클라이언트에서 필터링
현재 구현은 해당 포스트의 모든 좋아요 행을 가져온 뒤 클라이언트에서 viewer_id를 찾습니다. 좋아요가 많은 포스트의 경우 비효율적일 수 있습니다.
♻️ 두 개의 쿼리로 분리하는 방안
export async function fetchPostLikeState(
postId: string,
viewerId: string | null,
): Promise<LikeState> {
- if (!postId || !viewerId) {
+ if (!postId) {
return { count: 0, liked: false };
}
- const {
- data,
- count: total,
- error,
- } = await supabase
+ // 카운트 조회
+ const { count: total, error: countError } = await supabase
.from("post_likes")
- .select("viewer_id", { count: "exact" })
+ .select("*", { count: "exact", head: true })
.eq("post_id", postId);
- if (error) {
- console.error("좋아요 조회 실패:", error);
+ if (countError) {
+ console.error("좋아요 조회 실패:", countError);
return { count: 0, liked: false };
}
- const rows = data ?? [];
- const count = total ?? rows.length;
- const liked = rows.some((row) => row.viewer_id === viewerId);
+ const count = total ?? 0;
+
+ // 로그인한 사용자의 좋아요 여부만 확인
+ let liked = false;
+ if (viewerId) {
+ const { data, error: likedError } = await supabase
+ .from("post_likes")
+ .select("viewer_id")
+ .eq("post_id", postId)
+ .eq("viewer_id", viewerId)
+ .maybeSingle();
+
+ if (!likedError && data) {
+ liked = true;
+ }
+ }
return { count, liked };
}📝 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.
| export async function fetchPostLikeState( | |
| postId: string, | |
| viewerId: string | null, | |
| ): Promise<LikeState> { | |
| if (!postId || !viewerId) { | |
| return { count: 0, liked: false }; | |
| } | |
| const { | |
| data, | |
| count: total, | |
| error, | |
| } = await supabase | |
| .from("post_likes") | |
| .select("viewer_id", { count: "exact" }) | |
| .eq("post_id", postId); | |
| if (error) { | |
| console.error("좋아요 조회 실패:", error); | |
| return { count: 0, liked: false }; | |
| } | |
| const rows = data ?? []; | |
| const count = total ?? rows.length; | |
| const liked = rows.some((row) => row.viewer_id === viewerId); | |
| return { count, liked }; | |
| } | |
| export async function fetchPostLikeState( | |
| postId: string, | |
| viewerId: string | null, | |
| ): Promise<LikeState> { | |
| if (!postId) { | |
| return { count: 0, liked: false }; | |
| } | |
| // 카운트 조회 | |
| const { count: total, error: countError } = await supabase | |
| .from("post_likes") | |
| .select("*", { count: "exact", head: true }) | |
| .eq("post_id", postId); | |
| if (countError) { | |
| console.error("좋아요 조회 실패:", countError); | |
| return { count: 0, liked: false }; | |
| } | |
| const count = total ?? 0; | |
| // 로그인한 사용자의 좋아요 여부만 확인 | |
| let liked = false; | |
| if (viewerId) { | |
| const { data, error: likedError } = await supabase | |
| .from("post_likes") | |
| .select("viewer_id") | |
| .eq("post_id", postId) | |
| .eq("viewer_id", viewerId) | |
| .maybeSingle(); | |
| if (!likedError && data) { | |
| liked = true; | |
| } | |
| } | |
| return { count, liked }; | |
| } |
🤖 Prompt for AI Agents
In `@src/lib/supabase/postLikes.ts` around lines 16 - 43, fetchPostLikeState
currently fetches all post_likes rows for a post and then filters client-side to
determine if viewerId liked it; change it to use two targeted Supabase queries:
one query on post_likes with .eq("post_id", postId).select("*", { count: "exact"
}) to get the total count, and a second query with .eq("post_id",
postId).eq("viewer_id", viewerId).select("viewer_id", { count: "exact" }) (or a
simple .select("viewer_id").limit(1)) to determine liked existence; update the
logic in fetchPostLikeState to use these two results (total from the first,
liked = second count > 0) and preserve error handling.
요약
변경 내용
leading-0제거, line-height 정상화 + word-break 처리스크린샷/동영상 (선택)
테스트
pnpm build)관련 이슈
close #105
Summary by CodeRabbit
릴리스 노트
새로운 기능
스타일
버그 픽스 / 개선
✏️ Tip: You can customize this high-level summary in your review settings.