Skip to content

Comments

[Feat/post-105] ✨ feat: 추천 포스트 기능 추가 & supabse 연동 및 인기순 정렬 추가#106

Merged
swallowedB merged 6 commits intodevfrom
feat/post-105
Jan 15, 2026
Merged

[Feat/post-105] ✨ feat: 추천 포스트 기능 추가 & supabse 연동 및 인기순 정렬 추가#106
swallowedB merged 6 commits intodevfrom
feat/post-105

Conversation

@swallowedB
Copy link
Owner

@swallowedB swallowedB commented Jan 15, 2026

요약

  • 변경 목적(왜?): 추천 기능 및 Supabase 기반 인기 정렬 도입, 모바일 대응 및 스타일/컴포넌트 오류 수정
  • 주요 변경(무엇을?): 추천 기능 추가, 인기순 정렬 추가, 모바일 반응형 개선, Quote 컴포넌트 텍스트 겹침 수정, 스타일 정리

변경 내용

  • feat: 추천 포스트 기능 추가
    • 포스트 상세 기준 시리즈 내 Top3 추천 노출
  • feat: supabase 연동 및 인기순 정렬 추가
    • post_likes / post_like_counts 기반 정렬 적용
  • refactor: 모바일 반응형 고려
    • Header / Command Palette 대응
  • fix: 인용 컴포넌트 텍스트 겹침 문제 해결
    • leading-0 제거, line-height 정상화 + word-break 처리
  • style: 전반적 스타일링 정리

스크린샷/동영상 (선택)

테스트

  • 로컬 기능 정상 동작 확인
  • Supabase 좋아요 연동 확인
  • 인기 정렬 동작 확인
  • 모바일 반응형 검증
  • 타입/빌드 정상 (pnpm build)

관련 이슈

close #105

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 시리즈 기반 동적 추천 게시물 추가
    • 좋아요 집계 기반 인기도 정렬 및 관련 추천 로직 도입
    • 게시물 좋아요 관리 기능(조회/증감) 추가
  • 스타일

    • 모바일 헤더 레이아웃 및 액션 버튼 정리
    • 카드/썸네일 레이아웃·간격·텍스트 색상 조정
    • 인용문 줄간·단어 줄바꿈 개선
    • 여러 섹션의 상단 여백 증가
  • 버그 픽스 / 개선

    • 게시물 요약(발췌)을 선택적 필드로 변경
    • 커맨드 팔레트 및 토글 동작의 반응형 개선

✏️ Tip: You can customize this high-level summary in your review settings.

@swallowedB swallowedB linked an issue Jan 15, 2026 that may be closed by this pull request
3 tasks
@vercel
Copy link

vercel bot commented Jan 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
b0o0a Ready Ready Preview, Comment Jan 15, 2026 4:39pm

@coderabbitai
Copy link

coderabbitai bot commented Jan 15, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

추천 포스트 기능을 추가하고 좋아요 관리를 Supabase 추상화로 분리했으며, posts 쿼리와 여러 컴포넌트를 async/await로 변환했습니다. 다수 컴포넌트에서 스타일·간격·타입 선택성이 조정되었습니다.

Changes

Cohort / File(s) 요약
추천 포스트 및 카드
src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx, src/app/(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx, src/lib/posts/recommend.ts, src/lib/posts/index.ts
정적 추천 목록을 제거하고 getRecommendedPostsForPost()로 시리즈 내 인기 포스트(최대 3개)를 비동기 조회해 렌더링. 카드의 이미지/레이아웃/alt 및 스타일 조정 포함.
좋아요 추상화 및 연동
src/lib/supabase/postLikes.ts, src/hooks/usePostLike.ts, src/lib/posts/utils.ts, src/lib/posts/query.ts
Supabase 좋아요 로직을 postLikes 모듈로 추상화(추출/삽입/삭제/집계). usePostLike 리팩터링으로 해당 모듈 사용. 인기 정렬 시 좋아요 수를 가져와 정렬에 활용하도록 변경(비동기).
비동기 변환 및 호출 위치 변경
src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx, src/app/(layout)/(shell)/page.tsx, src/lib/posts/query.ts
queryPosts를 async로 변경하고 호출부에서 await 적용. 일부 컴포넌트가 async 함수로 선언됨(데이터 대기).
타입/필드 선택성 변경
src/app/(layout)/(shell)/_components/posts/PostCard.tsx, src/lib/posts/viewModels.ts, velite.config.ts, src/lib/posts/source.ts
PostCard 및 view model에서 excerpt를 선택적 필드로 변경. 썸네일 fallback 경로 변경 및 Velite config에서 thumbnail을 optional로 조정. posts 소스 경로 수정.
스타일·레이아웃 조정
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx, src/components/common/CommandPalette.tsx, src/components/common/fallback/BrowseFallback.tsx, src/app/(layout)/posts/[slug]/_components/PostToc.tsx, src/components/mdx/Quote.tsx
모바일 액션 버튼 그룹화, CommandPalette 반응형 클래스 구성 변경, 여백/타이포그래피 및 TOC active 스타일 업데이트 등 UI 클래스·배치 조정.
페이지 참조 업데이트
src/app/(layout)/posts/[slug]/page.tsx
로컬 posts 소스 대신 velitePosts로 통일하고 RecommendedPostspost prop 전달.

Sequence Diagram

sequenceDiagram
    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: 페이지 렌더링 완료
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

infra

Poem

추천 목록이 비동기로 피어나고,
좋아요 숫자에 길이 달라지네 💫
코드 흐름은 깔끔히 await로 이어져,
카드에 그림과 텍스트가 반짝이네 ✨
작은 스타일도 손봐져, 배포 준비 완료 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive 대부분의 변경이 추천 기능과 관련되어 있으나, 일부 스타일 조정(SeriesPostList mt-10→mt-20, BrowseFallback mt-10→mt-30)은 범위 확인이 필요합니다. 스타일 변경(여백 조정)이 이슈 #105 범위에 포함되는지 명시하거나, 별도 PR로 분리하는 것을 고려해주세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 PR의 핵심 변경사항(추천 포스트 기능, Supabase 연동, 인기순 정렬)을 명확히 반영하고 있습니다.
Description check ✅ Passed PR 설명이 변경 목적, 주요 변경 사항, 테스트 항목을 포함하고 있으나 관련 이슈 번호만 있고 구체적 요구사항 확인이 필요합니다.
Linked Issues check ✅ Passed PR의 코드 변경이 이슈 #105의 주요 요구사항(추천 포스트 기능 구현, Quote 컴포넌트 수정, 모바일 cmdk 대응)을 만족합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.



📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee736f3 and c54854d.

📒 Files selected for processing (1)
  • .github/workflows/ci.yml

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: keypost.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

📥 Commits

Reviewing files that changed from the base of the PR and between 8d40ac0 and ee736f3.

📒 Files selected for processing (20)
  • src/app/(layout)/(shell)/(category)/[category]/_components/series/SeriesPostList.tsx
  • src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx
  • src/app/(layout)/(shell)/_components/posts/PostCard.tsx
  • src/app/(layout)/(shell)/page.tsx
  • src/app/(layout)/posts/[slug]/_components/PostToc.tsx
  • src/app/(layout)/posts/[slug]/_components/RecommendedPosts.tsx
  • src/app/(layout)/posts/[slug]/_components/post-recommended/RecommendedPostCard.tsx
  • src/app/(layout)/posts/[slug]/page.tsx
  • src/components/common/CommandPalette.tsx
  • src/components/common/fallback/BrowseFallback.tsx
  • src/components/mdx/Quote.tsx
  • src/hooks/usePostLike.ts
  • src/lib/posts/index.ts
  • src/lib/posts/query.ts
  • src/lib/posts/recommend.ts
  • src/lib/posts/source.ts
  • src/lib/posts/utils.ts
  • src/lib/posts/viewModels.ts
  • src/lib/supabase/postLikes.ts
  • velite.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-10mt-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! 인기순 정렬 구현

likeCounts optional 파라미터 추가와 정렬 로직이 깔끔합니다. 특히:

  • 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 데이터 페칭과 매핑 로직이 깔끔합니다.

VelitePostRecommendedPost 타입으로 변환하는 로직이 명확하고, 추천 포스트가 없을 때 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.

Comment on lines +87 to +108
<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>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
<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.

Comment on lines +88 to +97
<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>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

접근성 개선: 메뉴 상태에 따라 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.

Suggested change
<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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

excerpt가 optional로 변경되었지만 렌더링 시 처리가 없습니다.

excerptundefined인 경우에도 빈 <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).

Comment on lines 16 to 17
<a
href={post.href}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
<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.

Comment on lines +93 to +99
<RecommendedPosts post={post} />
</div>
</aside>
</div>

<div className="mt-10 xl:hidden">
<RecommendedPosts />
<RecommendedPosts post={post}/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
<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.

Comment on lines +64 to +71
<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(" ")}
>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

반응형 레이아웃 개선이 잘 되었습니다.

모바일에서는 inset-x-3으로 좌우 여백을 주고, sm 이상에서는 sm:inset-x-autosm: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.

Comment on lines +34 to +36
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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment on lines +16 to +43
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 };
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
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.

@swallowedB swallowedB merged commit a9e8e25 into dev Jan 15, 2026
2 of 3 checks passed
@swallowedB swallowedB deleted the feat/post-105 branch January 15, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ feat: 추천 포스트 기능 구현

1 participant