Skip to content

Comments

[v0.6.6] - cmdk 검색 UX 개선 및 인기순 정렬 로직 최적화#124

Merged
swallowedB merged 6 commits intomainfrom
feat/ux-123
Feb 7, 2026
Merged

[v0.6.6] - cmdk 검색 UX 개선 및 인기순 정렬 로직 최적화#124
swallowedB merged 6 commits intomainfrom
feat/ux-123

Conversation

@swallowedB
Copy link
Owner

@swallowedB swallowedB commented Feb 7, 2026

요약

  • 변경 목적(왜?):
    커맨드 팔레트(cmdk) UX와 인기순 정렬 흐름에서 발생하던 사용성·성능 이슈를 개선하고, 디자인 시스템 일관성을 정리하기 위함

  • 주요 변경(무엇을?):

    • cmdk 검색 결과 노출 로직 개선 (최근 방문 게시글 3개 제한)
    • 인기순 정렬 UX 개선 (불필요한 전량 정렬/조회 제거)
    • 커스텀 브레이크포인트 rem 단위로 통일
    • 섬네일 교체 및 누락된 설정 보완으로 공유/UX 오류 수정

변경 내용

  • UI/컴포넌트

    • cmdk 초기 진입 시 고정 메뉴만 노출
    • 검색/이동 기록 기반 최근 게시글 UX 개선
    • 섬네일 리소스 교체 및 공유 UI 안정화
  • 로직/유틸

    • 인기순 정렬 로직 개선 (페이지 단위 계산, UX 일관성 확보)
    • cmdk 최근 기록 저장/필터링 로직 리팩토링
    • 누락된 설정 추가로 공유/정렬 관련 오류 해결
  • 문서/설정

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

테스트

  • 유닛 테스트 추가/수정됨
  • 로컬에서 pnpm test 통과
  • 타입체크/린트 통과 (pnpm typecheck, pnpm lint)

관련 이슈

close #123

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • Command Palette에서 최근 검색 항목 자동 저장 및 표시
    • Sort 옵션 선택 시 빠른 페이지 로딩 (prefetch 기능)
    • Share 기능에서 이미지 함께 공유 옵션 추가
    • 인기 게시물 정렬 성능 개선
  • 스타일

    • 반응형 레이아웃 및 그리드 최적화
    • 폴더 아이콘 색상 조정
    • 스크롤바 스타일 추가

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

vercel bot commented Feb 7, 2026

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

Project Deployment Actions Updated (UTC)
b0o0a Error Error Feb 7, 2026 9:26am

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

UI/UX 개선을 위한 복합적 업데이트입니다. 반응형 디자인 개선(xl2→3xl 브레이크포인트), 검색 팔레트 최근 항목 기록 추가, 인기도 정렬 알고리즘 개선, 이미지 공유 기능 확대, 컴포넌트 색상/스타일 조정 등이 포함됩니다.

Changes

Cohort / File(s) Summary
Metadata & Assets
content/posts/INSIGHT/git-merge.mdx
블로그 포스트 썸네일 URL 업데이트; 콘텐츠 및 렌더링 로직 변경 없음.
반응형 디자인
src/app/(layout)/(shell)/(category)/[category]/layout.tsx, src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx, tailwind.config.ts
xl2 커스텀 브레이크포인트 제거 및 3xl로 통일. 레이아웃 컨테이너 패딩과 그리드 컬럼 조정으로 큰 화면에서의 반응형 동작 개선.
스타일 토큰 & 글로벌 스타일
src/styles/tokens.css, src/app/globals.css
3xl 브레이크포인트, 스크롤바 색상 커스텀 프로퍼티 추가; .heading-anchor 스타일 신규 추가.
Command Palette (최근 항목 기록)
src/hooks/useCommandPaletteInternal.ts, src/components/common/CommandPalette.tsx
displayItems로 명칭 변경 및 sessionStorage 기반 최근 항목 추적 기능 추가. 검색 결과/정적 항목/최근 항목을 조건부로 표시; 항목 선택 시 최근 기록에 저장.
정렬 컨트롤 (Prefetch)
src/components/common/controls/sort/SortSelect.tsx, src/components/common/controls/sort/SortSelectClient.tsx
onPrefetch 핸들러 추가로 정렬 옵션 hover 시 URL prefetch 지원; 내부 buildUrl 헬퍼로 경로 일관성 확보.
아이콘 색상 업데이트
src/components/common/icons/FolderIcon.tsx
FolderTone 컬러 토큰 업데이트 (gray, blue, darkblue, purple 색상값 조정).
Post Like 개선
src/hooks/usePostLike.ts, src/lib/supabase/viewerId.ts
viewerId 타입 변경 (string | null → string); crypto.randomUUID 기반 ID 생성으로 단순화. 에러 로깅 추가.
Share 기능 확장
src/hooks/useShare.ts
includeImageFile 옵션 추가로 네이티브 공유에 이미지 파일 첨부 지원. URL 정규화 및 abort 에러 분류 헬퍼 추가.
Popular 정렬 알고리즘
src/lib/posts/query.ts, src/lib/supabase/postLikes.ts
fetchPopularRankPage로 인기도 기반 순위 페칭 후, 좋아요순/최신순으로 분할 병합. 기존 단순 정렬보다 다층적 정렬 로직 구현.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🎨 UI는 수고롭지만, 최근 기억과 함께라면
즐겁게 정렬하고, 이미지 공유해 봅시다 📸
3xl 화면에서도 아름답게, 스크롤도 부드럽게 ✨


리뷰 포인트

🟡 주목할 영역

  1. viewerId 타입 변경 (src/lib/supabase/viewerId.ts, src/hooks/usePostLike.ts)

    • string | nullstring 으로 변경되었으나, 실제 에러 발생 시 빈 문자열을 반환하는 점을 확인하세요.
    • usePostLike에서 viewerIdRef.current가 항상 유효한 값이라고 가정하는데, 빈 문자열이 예상대로 처리되는지 검증 필요.
  2. Command Palette 최근 항목 로직 (src/hooks/useCommandPaletteInternal.ts)

    • sessionStorage 의존성: 브라우저 개인정보 보호 모드에서 sessionStorage 접근 실패 시 에러 처리가 충분한지 확인
    • displayItems 조건부 반환 로직(query 유무 → 최근 항목 표시)이 의도대로 동작하는지 단계별 테스트 권장
  3. Popular 정렬 알고리즘 (src/lib/posts/query.ts, src/lib/supabase/postLikes.ts)

    • fetchPopularRankPage는 pagination 지원하지만, POPULAR_CANDIDATE_MULTIPLIER 값이 적절한지 성능 테스트 권장
    • 좋아요순/최신순 분할 로직에서 중복 제거 및 정렬 안정성 확인
  4. Share 이미지 파일 첨부 (src/hooks/useShare.ts)

    • 이미지 fetch 중 네트워크 에러 시 graceful fallback 동작 확인 (buildShareFile 에러 처리)
    • navigator.canShare 지원 여부 체크 로직이 누락되었는지 재확인

✅ 긍정적 측면

  • 브레이크포인트 정리(xl2 제거)로 설정 단순화
  • 최근 항목 기록으로 UX 개선
  • Prefetch로 정렬 시 인터랙션 개선
  • 타입 안정성 강화 (viewerId null 제거)
🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% 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 대부분의 변경이 목표와 일치하지만, 색상 토큰 업데이트(FolderIcon.tsx)와 스크롤바 스타일 추가(tokens.css)는 명시된 이슈 목표와 직접적 연관이 불명확합니다. 색상 토큰 변경과 스크롤바 스타일이 이슈 #123의 '스타일링 수정' 범위에 포함되는지, 아니면 별도 이슈와 관련된 것인지 확인이 필요합니다.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 PR의 주요 변경사항을 명확하게 반영합니다: cmdk 검색 UX 개선과 인기순 정렬 로직 최적화는 변경 요약에서 핵심 내용입니다.
Description check ✅ Passed PR 설명이 템플릿의 모든 주요 섹션을 포함하고 있습니다: 변경 목적, 주요 변경, 변경 내용 체크박스, 테스트 현황이 모두 기입되어 있습니다.
Linked Issues check ✅ Passed 변경사항들이 연결된 이슈 #123의 목표인 'UX 및 상호작용 개선', '공유 기능 수정'과 일치합니다: cmdk UX 개선(useCommandPaletteInternal), 정렬 성능 개선(query.ts, postLikes.ts), 공유 기능 안정화(useShare.ts)가 구현되어 있습니다.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ux-123

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: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/hooks/useShare.ts (1)

134-136: 🧹 Nitpick | 🔵 Trivial

useCallback의 의존성 배열에 defaultOptions 객체 참조가 직접 들어있습니다.

호출자(예: ShareButton)가 매 렌더마다 새 객체 리터럴을 defaultOptions로 전달하면 share 함수가 매번 재생성됩니다. 현재 ShareButton에서 useShare({ title, imageUrl })로 인라인 객체를 넘기고 있어서, title이나 imageUrl이 변경되지 않아도 share가 재생성될 수 있습니다.

실제 동작에 영향을 주지는 않지만(ref 기반이므로), 하위 컴포넌트에 share를 prop으로 전달하거나 useEffect 의존성으로 사용하게 되면 불필요한 리렌더가 발생할 수 있습니다.

♻️ 호출부에서 useMemo로 옵션 안정화하는 방법

ShareButton.tsx 등 호출부에서:

const shareOptions = useMemo(() => ({ title, imageUrl }), [title, imageUrl]);
const { share } = useShare(shareOptions);
src/hooks/usePostLike.ts (2)

31-44: ⚠️ Potential issue | 🟡 Minor

데이터 로딩 시 viewerId가 빈 문자열일 때 불필요한 API 호출이 발생할 수 있습니다

toggleLike(Line 48)에서는 if (!viewerId) 가드가 있지만, 이 useEffect 내부(Line 35)에서는 viewerIdRef.current가 빈 문자열이어도 fetchPostLikeState(postId, "")가 그대로 호출됩니다.

getOrCreateViewerId()가 실패하거나 SSR 환경 등에서 빈 문자열이 반환된 경우, 의미 없는 Supabase 요청이 발생하게 됩니다. toggleLike와 동일한 가드를 추가하는 것을 권장합니다.

🛡️ viewerId 가드 추가 제안
   useEffect(() => {
     if (!postId) return;
 
     const run = async () => {
       const viewerId = viewerIdRef.current;
+      if (!viewerId) {
+        setLoading(false);
+        return;
+      }
       const { count, liked } = await fetchPostLikeState(postId, viewerId);
 
       setCount(count);
       setLiked(liked);
       setLoading(false);
     };
 
     void run();
   }, [postId]);

27-29: 🧹 Nitpick | 🔵 Trivial

useEffect 간 실행 순서 의존성에 대한 참고

첫 번째 useEffect(Line 27-29)가 viewerIdRef.current를 설정하고, 두 번째 useEffect(Line 31-44)가 그 값을 읽습니다. React는 같은 렌더 사이클에서 선언 순서대로 effect를 실행하므로 현재는 정상 동작합니다.

다만, 향후 리팩토링 시 이 순서가 바뀌면 빈 문자열로 API가 호출될 수 있습니다. 위에서 제안한 viewerId 가드를 추가하면 이 의존성이 깨져도 안전합니다.

Also applies to: 31-44

src/hooks/useCommandPaletteInternal.ts (1)

192-205: 🧹 Nitpick | 🔵 Trivial

navigator.platform은 deprecated입니다.

기존 코드이긴 하지만, navigator.platform은 웹 표준에서 deprecated 처리되었습니다. navigator.userAgentData?.platform 또는 User-Agent 기반 감지로 전환을 검토해 보세요.

src/components/common/CommandPalette.tsx (1)

75-118: ⚠️ Potential issue | 🔴 Critical

<Command>shouldFilter={false}가 빠져 있어 검색 결과가 이중 필터링됩니다.

현재 훅(useCommandPaletteInternal)에서 label + hint + searchableText를 기반으로 직접 필터링하여 displayItems를 반환하고 있습니다. 그런데 cmdk의 <Command> 컴포넌트는 기본적으로 자체 내장 필터도 적용합니다(각 Command.Itemvalue 속성 기준).

Line 105에서 value={item.label}로 설정되어 있으므로, cmdk는 label 텍스트만 기준으로 다시 필터링합니다. 결과적으로:

  • 훅에서 hintsearchableText로 매칭된 항목이 cmdk 내장 필터에 의해 숨겨질 수 있습니다.
  • Command.Empty("검색 결과가 없습니다")가 실제로 훅이 반환한 결과가 있는 상황에서도 표시될 수 있습니다.

shouldFilter={false}를 추가하여 cmdk의 내장 필터를 비활성화해야 합니다.

🐛 수정 제안
-            <Command className="flex max-h-[70vh] flex-col overflow-hidden rounded-2xl bg-transparent text-sm text-background">
+            <Command shouldFilter={false} className="flex max-h-[70vh] flex-col overflow-hidden rounded-2xl bg-transparent text-sm text-background">
🤖 Fix all issues with AI agents
In `@src/app/`(layout)/posts/[slug]/page.tsx:
- Around line 24-25: Remove or clarify the ineffective ISR setting: delete the
export const revalidate = 60; (or replace it with a comment) because velitePosts
are static artifacts produced into the .velite directory at build time and won’t
update with ISR; if you intend to keep it as a placeholder for future dynamic
data, replace the line with a comment referencing velitePosts and .velite (e.g.,
explaining that new posts require velite --clean → next build) so readers won’t
expect revalidate to refresh content.

In `@src/app/globals.css`:
- Line 3: The Biome CSS parser is flagging the Tailwind v4 directive '@config
"../../tailwind.config.ts"' — enable Tailwind directive support by setting the
Biome CSS parser option tailwindDirectives to true in your Biome configuration
so the '@config' directive (and other Tailwind directives) are recognized;
update the Biome config's css parser settings accordingly.

In `@src/components/common/controls/sort/SortSelectClient.tsx`:
- Around line 23-25: handleChange currently always calls
router.push(buildUrl(next)) which can cause redundant navigation when next ===
currentValue; add a defensive early return at the start of
SortSelectClient.handleChange (the function named handleChange that calls
router.push(buildUrl(next))) to check if next === currentValue and return
immediately, ensuring you reference the same currentValue used by SortSelect and
leaving buildUrl and router.push unchanged.

In `@src/hooks/useCommandPaletteInternal.ts`:
- Around line 40-44: The isPostItem predicate mixes two criteria and should be
narrowed to only check the id to avoid false positives; update the isPostItem
function to return item.id.startsWith("post-") only (referencing isPostItem and
buildPostItems so you know post items are created with id `post-${post.slug}`
and href `/posts/${post.slug}`), removing the href-based check to ensure only
items explicitly created as posts are classified as posts.
- Around line 171-183: handleSelect currently calls
setRecentItems(pushRecent(item)) which mixes the side-effecting sessionStorage
write inside pushRecent with the state update; extract the side effect by
calling pushRecent(item) first, store its return value in a const (e.g.
newRecent) and then call setRecentItems(newRecent), and ensure the useCallback
dependency array includes pushRecent and setRecentItems (and any other
referenced symbols) so the memoization is correct; this makes pushRecent's
sessionStorage side effect explicit and separates it from the state update in
handleSelect.
- Around line 94-99: writeRecent currently serializes the entire CommandItem
array (including the potentially large searchableText) into sessionStorage under
RECENT_KEY; change writeRecent to first map items.slice(0, RECENT_LIMIT) to a
compact shape that omits searchableText (and any other non-essential or large
fields) before JSON.stringify, ensuring only necessary fields (e.g., id,
title/name, category, shortcut) are stored to reduce size while preserving the
recent list behavior.
- Around line 83-109: Add the same SSR guard used in readRecent to any function
that accesses sessionStorage: update writeRecent and pushRecent to first check
typeof window !== "undefined" (or otherwise no-op/return current items) before
calling sessionStorage.setItem/getItem; ensure writeRecent still respects
RECENT_KEY and RECENT_LIMIT and pushRecent returns the current list unchanged
when running in SSR so callers of pushRecent (e.g., handleSelect) remain safe.
- Around line 83-92: The readRecent function returns parsed sessionStorage data
without runtime shape validation; change it to validate each parsed item (from
RECENT_KEY) is an object matching CommandItem (e.g., has required properties
like id and label and correct types) before including it in the result, filter
out invalid entries, then return at most RECENT_LIMIT items (keeping the
existing slice logic), and preserve the try/catch behavior to fall back to [] on
parse errors.

In `@src/hooks/usePostLike.ts`:
- Around line 63-65: Remove the duplicate short debug block that logs
error.code/error.message/error.details/error.hint (the first if (error) around
the early part of usePostLike) and consolidate any needed logging into the
existing main error handler later in the same function (the second if (error)
block that already handles and returns); also fix indentation to match the
file's 4-space style and avoid emitting development-only fields (remove
details/hint or replace with a single contextual message) so only one error
handler remains with consistent formatting.

In `@src/hooks/useShare.ts`:
- Around line 25-27: normalizeUrl currently reads window.location.href at module
scope which can throw in SSR; change normalizeUrl to avoid accessing window at
import time by computing the base URL lazily inside the function with a typeof
window !== "undefined" guard (or by adding an optional base parameter) and fall
back to a safe default or require callers to pass a base. Update callers such as
buildShareFile to pass a base when running in non-browser contexts or rely on
the guarded behavior so normalizeUrl never accesses window during SSR.
- Around line 29-37: In buildShareFile, after calling
fetch(normalizeUrl(imageUrl), ...) check response.ok and throw or reject with a
clear error (include response.status/statusText) to avoid turning error HTML
into a blob; then call response.blob() only for ok responses. Also derive the
file extension from blob.type (e.g., split after "image/" and fallback to "png"
if missing) and use that extension when constructing the File instead of the
hardcoded "post-thumbnail.png" so the filename matches the actual MIME type.
- Around line 122-125: Wrap the navigator.clipboard.writeText call in a
try-catch inside the useShare hook (the block currently using
navigator.clipboard?.writeText) so any thrown exception is caught and an
explicit error result is returned instead of bubbling as an unhandled rejection;
on catch return a failure object (e.g., { ok: false, method: "clipboard", error
}) and ensure isSharingRef is still released in the existing finally block. Use
the symbols navigator.clipboard.writeText, isSharingRef, and the share function
inside useShare to locate and update the code.

In `@src/lib/posts/query.ts`:
- Line 80: fetchPopularRankPage is always called with offset = 0 (const
candidateRows = await fetchPopularRankPage(candidateLimit, 0);) making the
offset parameter dead; either remove the offset parameter from
fetchPopularRankPage's signature and all its callers (including this call) or
explicitly mark it as intentional with a TODO explaining future server-side
pagination plans; update the function definition (fetchPopularRankPage) and any
references (candidateRows, candidateLimit, paginate()) accordingly so the
signature and usage are consistent.
- Around line 76-77: The code redundantly recalculates safePage and safePerPage
even though paginate() already applies Math.max(1, Math.floor(...)); remove the
duplicate Math.max/Math.floor calls in query.ts (the safePage and safePerPage
assignments) and rely on the sanitized values from paginate(), or if you
intentionally need a sanitized safePerPage specifically for candidateLimit
calculation, keep only safePerPage and add a concise comment above it
referencing candidateLimit to explain the purpose; ensure references to
paginate(), safePage, safePerPage, and candidateLimit are preserved so reviewers
can locate the logic.
- Around line 75-124: The current popular-sort logic can misclassify liked posts
outside the fetched candidate window because candidateLimit is fixed; update the
candidateLimit calculation to account for the current pool size before calling
fetchPopularRankPage (use pool.length to raise the limit when pool is smaller
than candidateLimit or use Math.min(pool.length, MAX_CANDIDATE_CEILING) to avoid
excessive queries), then call fetchPopularRankPage with this adjusted limit
(symbols to edit: POPULAR_CANDIDATE_MULTIPLIER, candidateLimit,
fetchPopularRankPage, and pool), or alternatively detect when the fetched
candidateRows length < pool.length and log a warning and/or fall back to
treating all pool items as potentially liked so sorting remains correct.

In `@src/lib/supabase/viewerId.ts`:
- Around line 4-6: 현재 getOrCreateViewerId 함수 내에서 매 호출마다 const KEY = "viewer_id"를
선언하고 있어 KEY를 모듈 스코프로 옮기면 일관성과 가독성이 좋아집니다; 해결책은 getOrCreateViewerId 함수 바깥에 KEY
상수를 선언(예: const KEY = "viewer_id";)하고 기존 함수 내부의 선언을 제거하여 UUID_REGEX 등 다른 모듈 수준
상수들과 같은 스코프에 두는 것입니다.
- Around line 8-12: Wrap the localStorage and crypto.randomUUID calls in a
try-catch inside the viewerId logic so any exceptions (missing SecureContext,
TypeError from crypto.randomUUID, or localStorage throws) are caught and the
function returns an empty string; specifically, in the block that reads KEY via
window.localStorage.getItem(KEY), tests UUID_REGEX, generates id via
crypto.randomUUID(), and calls window.localStorage.setItem(KEY, id), guard
crypto.randomUUID existence (e.g. typeof crypto?.randomUUID === "function")
before calling and surround getItem/setItem and id creation with try-catch,
returning "" on error to avoid throwing from viewerId.

In `@src/styles/tokens.css`:
- Around line 40-41: The hover color variable --color-scrollbar-thumb-hover is
currently the same for dark and light themes and is too faint on the dark
background; update the dark-theme value so it is visibly lighter/more opaque
than --color-scrollbar-thumb (e.g., increase alpha or use a brighter rgba) and
ensure you scope it for dark mode (using the .dark or :root[data-theme="dark"]
selector) while keeping the light-theme hover value unchanged.

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@config "../../tailwind.config.ts";
@config "../../tailwind.config.ts";
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Biome 정적 분석 경고는 설정 이슈입니다.

Biome가 @config 디렉티브를 인식하지 못하고 있습니다. 이는 Tailwind CSS v4의 정상적인 문법이므로, Biome CSS 파서 옵션에서 tailwindDirectives를 활성화하면 해결됩니다. 코드 자체에는 문제가 없습니다.

🧰 Tools
🪛 Biome (2.3.13)

[error] 3-3: Tailwind-specific syntax is disabled.

Enable tailwindDirectives in the css parser options, or remove this if you are not using Tailwind CSS.

(parse)

🤖 Prompt for AI Agents
In `@src/app/globals.css` at line 3, The Biome CSS parser is flagging the Tailwind
v4 directive '@config "../../tailwind.config.ts"' — enable Tailwind directive
support by setting the Biome CSS parser option tailwindDirectives to true in
your Biome configuration so the '@config' directive (and other Tailwind
directives) are recognized; update the Biome config's css parser settings
accordingly.

Comment on lines +23 to +25
const handleChange = (next: PostSortValue) => {
router.push(buildUrl(next), { scroll: false });
};
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

handleChange에서 중복 네비게이션 가드가 빠진 부분 확인 필요

이전 코드에서 handleChange 내에 next === currentValue 가드가 있었는지 확인이 필요합니다. 현재는 SortSelect 내부의 handleChange에서 if (next === currentValue) return; 가드가 있어 onChange가 호출되지 않으므로 실질적으로는 안전합니다. 다만, SortSelectClienthandleChange가 직접 호출될 가능성이 있다면 방어 코드를 추가하는 것도 고려해볼 수 있습니다.

🛡️ 선택적 방어 코드
  const handleChange = (next: PostSortValue) => {
+   if (next === value) return;
    router.push(buildUrl(next), { scroll: false });
  };
📝 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
const handleChange = (next: PostSortValue) => {
router.push(buildUrl(next), { scroll: false });
};
const handleChange = (next: PostSortValue) => {
if (next === value) return;
router.push(buildUrl(next), { scroll: false });
};
🤖 Prompt for AI Agents
In `@src/components/common/controls/sort/SortSelectClient.tsx` around lines 23 -
25, handleChange currently always calls router.push(buildUrl(next)) which can
cause redundant navigation when next === currentValue; add a defensive early
return at the start of SortSelectClient.handleChange (the function named
handleChange that calls router.push(buildUrl(next))) to check if next ===
currentValue and return immediately, ensuring you reference the same
currentValue used by SortSelect and leaving buildUrl and router.push unchanged.

Comment on lines +40 to +44
function isPostItem(item: CommandItem) {
return (
item.id.startsWith("post-") || (item.href?.startsWith("/posts/") ?? false)
);
}
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

isPostItem — 판별 조건이 buildPostItems와 결합되어 있습니다.

item.id.startsWith("post-")item.href?.startsWith("/posts/") 두 조건을 OR로 묶고 있는데, buildPostItems에서 생성하는 아이템은 항상 두 조건을 동시에 만족합니다(Line 69: post-${post.slug}, Line 74: /posts/${post.slug}).

현재 동작에 문제는 없지만, 만약 나중에 /posts/ 경로를 가지는 static 아이템이 추가되거나 post- prefix가 다른 용도로 쓰이면 의도치 않게 recent에 저장될 수 있습니다. id 기반 판별 하나로 충분해 보이므로, 단일 조건으로 좁히는 것도 고려해 주세요.

🤖 Prompt for AI Agents
In `@src/hooks/useCommandPaletteInternal.ts` around lines 40 - 44, The isPostItem
predicate mixes two criteria and should be narrowed to only check the id to
avoid false positives; update the isPostItem function to return
item.id.startsWith("post-") only (referencing isPostItem and buildPostItems so
you know post items are created with id `post-${post.slug}` and href
`/posts/${post.slug}`), removing the href-based check to ensure only items
explicitly created as posts are classified as posts.

Comment on lines +83 to +109
function readRecent(): CommandItem[] {
if (typeof window === "undefined") return [];
try {
const raw = sessionStorage.getItem(RECENT_KEY);
const parsed = raw ? (JSON.parse(raw) as CommandItem[]) : [];
return Array.isArray(parsed) ? parsed.slice(0, RECENT_LIMIT) : [];
} catch {
return [];
}
}

function writeRecent(items: CommandItem[]) {
sessionStorage.setItem(
RECENT_KEY,
JSON.stringify(items.slice(0, RECENT_LIMIT)),
);
}

function pushRecent(item: CommandItem): CommandItem[] {
const current = readRecent();
const next = [item, ...current.filter((x) => x.id !== item.id)].slice(
0,
RECENT_LIMIT,
);
writeRecent(next);
return next;
}
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

readRecent에는 SSR 가드가 있지만 writeRecent에는 없습니다.

readRecenttypeof window === "undefined" 체크를 하고 있지만(Line 84), writeRecent(Line 94-99)와 pushRecent(Line 101-109)는 SSR 가드 없이 바로 sessionStorage에 접근합니다.

현재 pushRecenthandleSelect 콜백 안에서만 호출되므로 실제 SSR에서 실행될 가능성은 낮지만, 방어적으로 writeRecent에도 동일한 가드를 추가하면 일관성과 안전성이 높아집니다.

🛡️ 제안
 function writeRecent(items: CommandItem[]) {
+  if (typeof window === "undefined") return;
   sessionStorage.setItem(
     RECENT_KEY,
     JSON.stringify(items.slice(0, RECENT_LIMIT)),
   );
 }
🤖 Prompt for AI Agents
In `@src/hooks/useCommandPaletteInternal.ts` around lines 83 - 109, Add the same
SSR guard used in readRecent to any function that accesses sessionStorage:
update writeRecent and pushRecent to first check typeof window !== "undefined"
(or otherwise no-op/return current items) before calling
sessionStorage.setItem/getItem; ensure writeRecent still respects RECENT_KEY and
RECENT_LIMIT and pushRecent returns the current list unchanged when running in
SSR so callers of pushRecent (e.g., handleSelect) remain safe.

Comment on lines +76 to +77
const safePage = Math.max(1, Math.floor(page));
const safePerPage = Math.max(1, Math.floor(perPage));
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

safePage/safePerPage 중복 계산

paginate() 내부(utils.ts Line 67~74)에서 이미 동일한 안전 처리(Math.max(1, Math.floor(...)))를 하고 있어서 여기서 한 번 더 하는 것은 중복입니다. 해를 끼치진 않지만, candidateLimit 계산에만 safePerPage가 필요하다면 그 목적을 주석으로 명시하면 의도가 더 명확해집니다.

🤖 Prompt for AI Agents
In `@src/lib/posts/query.ts` around lines 76 - 77, The code redundantly
recalculates safePage and safePerPage even though paginate() already applies
Math.max(1, Math.floor(...)); remove the duplicate Math.max/Math.floor calls in
query.ts (the safePage and safePerPage assignments) and rely on the sanitized
values from paginate(), or if you intentionally need a sanitized safePerPage
specifically for candidateLimit calculation, keep only safePerPage and add a
concise comment above it referencing candidateLimit to explain the purpose;
ensure references to paginate(), safePage, safePerPage, and candidateLimit are
preserved so reviewers can locate the logic.

const safePerPage = Math.max(1, Math.floor(perPage));

const candidateLimit = safePerPage * POPULAR_CANDIDATE_MULTIPLIER;
const candidateRows = await fetchPopularRankPage(candidateLimit, 0);
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

offset이 항상 0으로 고정되어 있습니다

fetchPopularRankPageoffset 파라미터를 받지만, 여기서는 항상 0을 전달합니다. 현재 구조에서 페이지네이션은 클라이언트에서 paginate()로 처리하므로 동작에는 문제가 없지만, fetchPopularRankPageoffset 파라미터가 사실상 미사용(dead parameter) 상태입니다. 향후 서버사이드 페이지네이션으로 전환할 계획이 아니라면, 함수 시그니처를 단순화하거나 TODO 주석을 남겨 두면 좋겠습니다.

🤖 Prompt for AI Agents
In `@src/lib/posts/query.ts` at line 80, fetchPopularRankPage is always called
with offset = 0 (const candidateRows = await
fetchPopularRankPage(candidateLimit, 0);) making the offset parameter dead;
either remove the offset parameter from fetchPopularRankPage's signature and all
its callers (including this call) or explicitly mark it as intentional with a
TODO explaining future server-side pagination plans; update the function
definition (fetchPopularRankPage) and any references (candidateRows,
candidateLimit, paginate()) accordingly so the signature and usage are
consistent.

Comment on lines +40 to +41
--color-scrollbar-thumb: rgba(187, 187, 187, 0.852);
--color-scrollbar-thumb-hover: rgba(15, 23, 42, 0.28);
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

다크 모드 스크롤바 hover 색상이 라이트 모드와 동일합니다 — 복사 실수로 보입니다.

--color-scrollbar-thumb-hover가 다크/라이트 모드 모두 rgba(15, 23, 42, 0.28)로 동일합니다. 다크 배경(#040523) 위에서 이 색상은 거의 보이지 않아, hover 피드백이 사실상 없게 됩니다.

의도된 것이 아니라면, 다크 모드 thumb 색상(rgba(187, 187, 187, 0.852))보다 약간 밝거나 불투명한 값으로 수정하는 것이 좋겠습니다.

🎨 수정 제안
     --color-scrollbar-thumb: rgba(187, 187, 187, 0.852);
-    --color-scrollbar-thumb-hover: rgba(15, 23, 42, 0.28);
+    --color-scrollbar-thumb-hover: rgba(200, 200, 200, 0.95);
📝 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
--color-scrollbar-thumb: rgba(187, 187, 187, 0.852);
--color-scrollbar-thumb-hover: rgba(15, 23, 42, 0.28);
--color-scrollbar-thumb: rgba(187, 187, 187, 0.852);
--color-scrollbar-thumb-hover: rgba(200, 200, 200, 0.95);
🤖 Prompt for AI Agents
In `@src/styles/tokens.css` around lines 40 - 41, The hover color variable
--color-scrollbar-thumb-hover is currently the same for dark and light themes
and is too faint on the dark background; update the dark-theme value so it is
visibly lighter/more opaque than --color-scrollbar-thumb (e.g., increase alpha
or use a brighter rgba) and ensure you scope it for dark mode (using the .dark
or :root[data-theme="dark"] selector) while keeping the light-theme hover value
unchanged.

@swallowedB swallowedB merged commit 0cfd37a into main Feb 7, 2026
3 of 4 checks passed
@swallowedB swallowedB deleted the feat/ux-123 branch February 7, 2026 08:56
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: UX 및 상호작용 개선

1 participant