Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"prepare": "husky"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@supabase/supabase-js": "^2.80.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.553.0",
"next": "^15.5.9",
Expand Down
487 changes: 487 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

58 changes: 45 additions & 13 deletions src/app/(layout)/(shell)/_components/home/HeroLeft.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import SearchBar from "../../../../../components/ui/SearchBar";
import { HERO_POPULAR_TAGS } from "../../_constants/home";
import HeroTagList from "./HeroTagList";
"use client";

import { useCommandPalette } from "@/components/common/CommandPalette";
import clsx from "clsx";
import { Search } from "lucide-react";

interface HeroLeftProps {
intro: string;
}

export default function HeroLeft({ intro }: HeroLeftProps) {
const { openPalette } = useCommandPalette();

return (
<div className="flex-1 space-y-12">
{/* 소개 타이틀/텍스트 */}
<div className="space-y-3">
<h1 className="text-xl font-black md:text-[40px]">
Like the Boa That
Expand All @@ -21,16 +24,45 @@ export default function HeroLeft({ intro }: HeroLeftProps) {
</p>
</div>

<div className="pr-10">
<SearchBar />
</div>
<div className="pr-2 -ml-0.5 w-full ">
<button
type="button"
onClick={openPalette}
className={clsx(
"cursor-pointer",
"group flex w-full items-center gap-3 rounded-2xl px-4 py-3 shadow-sm backdrop-blur-sm transition",
"bg-white/60 text-neutral-700 border border-neutral-200 hover:bg-white/80 hover:border-neutral-300/80",
"dark:bg-white/10 dark:text-white/60 dark:border-white/20 dark:hover:bg-white/20 dark:hover:border-white/40"
)}
>
<Search
className={clsx(
"h-5 w-5 transition",
"text-neutral-500 group-hover:text-neutral-800",
"dark:text-white/80 dark:group-hover:text-white"
)}
/>

<span
className={clsx(
"flex-1 text-left text-sm transition",
"text-neutral-400 group-hover:text-neutral-900",
"dark:text-white/60 dark:group-hover:text-white"
)}
>
삼킨 것들은 지금 무엇이 되었을까요?
</span>

{/* 인기 태그 섹션 */}
<div className="flex gap-3 items-center">
<span className="text-xs md:text-base font-medium text-foreground/70">
Trending :
</span>
<HeroTagList tags={HERO_POPULAR_TAGS} />
<span
className={clsx(
"rounded-md px-2 py-0.5 text-[10px] transition font-semibold",
"bg-neutral-300/50 text-neutral-500",
"dark:bg-white/20 dark:text-white"
)}
>
⌘K
</span>
</button>
</div>
</div>
);
Expand Down
20 changes: 0 additions & 20 deletions src/app/(layout)/(shell)/_components/home/HeroTagList.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useCommandPalette } from "@/components/common/CommandPalette";

export function useSearchTransition() {
const pathname = usePathname();
const isHome = pathname === "/";
const { openPalette } = useCommandPalette();

const [pastThreshold, setPastThreshold] = useState(false);

useEffect(() => {
if (!isHome) {
return;
}

const threshold = 320;

const handleScroll = () => {
setPastThreshold(window.scrollY > threshold);
};

handleScroll();

window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [isHome]);

const showSearch = !isHome || pastThreshold;

const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

const toggleMobileMenu = () =>
setIsMobileMenuOpen((prev) => !prev);

const closeMobileMenu = () => setIsMobileMenuOpen(false);

const handleSearchClick = () => {
openPalette();
};
Comment on lines +40 to +42
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

handleSearchClickopenPalette의 단순 래퍼입니다

현재 handleSearchClickopenPalette()만 호출합니다. 향후 analytics 등 추가 로직이 필요하다면 이 구조가 유용하지만, 그렇지 않다면 openPalette를 직접 반환하는 것도 고려해 볼 수 있습니다.

🤖 Prompt for AI Agents
In @src/app/(layout)/(shell)/_components/home/_hooks/useSearchTransition.ts
around lines 40 - 42, handleSearchClick is just a thin wrapper that only calls
openPalette; remove the unnecessary wrapper by either returning/exporting
openPalette directly from the hook or replacing the function body with a direct
assignment (e.g., const handleSearchClick = openPalette) and update all callers
to use openPalette (or the reassigned handleSearchClick) accordingly; adjust any
tests or usages that referenced handleSearchClick to the chosen exported symbol
to keep behavior unchanged.


return {
isHome,
showSearch,
isMobileMenuOpen,
toggleMobileMenu,
closeMobileMenu,
handleSearchClick,
};
}
158 changes: 105 additions & 53 deletions src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,136 @@
"use client";

import { Menu, X } from "lucide-react";
import { useSearchTransition } from "@/app/(layout)/(shell)/_components/home/_hooks/useSearchTransition";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import { Menu, Search, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";

import DarkModeWheel from "../../../../../components/ui/DarkModeWheel";

export default function SiteHeader() {
const [open, setOpen] = useState(false);
const {
showSearch,
isMobileMenuOpen,
toggleMobileMenu,
closeMobileMenu,
handleSearchClick,
} = useSearchTransition();
Comment on lines 10 to +17
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the file exists and read relevant sections
echo "=== Checking SiteHeader.tsx file ==="
if [ -f "src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx" ]; then
  wc -l "src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx"
  echo ""
  echo "=== Lines 10-17 ==="
  sed -n '10,17p' "src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx"
  echo ""
  echo "=== Lines 90-95 (around line 93) ==="
  sed -n '90,95p' "src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx"
else
  echo "File not found at src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx"
  # Try to find it
  fd "SiteHeader.tsx"
fi

Repository: swallowedB/b-log

Length of output: 630


🏁 Script executed:

#!/bin/bash
# Find test files related to SiteHeader
echo "=== Finding test files ==="
fd -type f \( -name "*SiteHeader*" -o -name "*site-header*" \) | head -20
echo ""
echo "=== Finding all test files in the directory ==="
fd -path "*shell*" -type f \( -name "*.test.tsx" -o -name "*.spec.tsx" -o -name "*.test.ts" -o -name "*.spec.ts" \) | head -20

Repository: swallowedB/b-log

Length of output: 492


🏁 Script executed:

#!/bin/bash
# Search for CommandPaletteProvider in the codebase
echo "=== Searching for CommandPaletteProvider usage ==="
rg "CommandPaletteProvider" -n | head -30

Repository: swallowedB/b-log

Length of output: 697


🏁 Script executed:

#!/bin/bash
# Find and examine the useSearchTransition hook
echo "=== Finding useSearchTransition hook ==="
fd -name "*.ts" -o -name "*.tsx" | xargs rg -l "useSearchTransition" | head -5

Repository: swallowedB/b-log

Length of output: 479


🏁 Script executed:

#!/bin/bash
# Check the layout.tsx file to see if SiteHeader is rendered within CommandPaletteProvider
echo "=== Checking layout.tsx ==="
cat -n src/app/\(layout\)/layout.tsx | head -40

Repository: swallowedB/b-log

Length of output: 572


🏁 Script executed:

#!/bin/bash
# Read the useSearchTransition hook implementation
echo "=== Reading useSearchTransition hook ==="
cat -n src/app/\(layout\)/\(shell\)/_components/home/_hooks/useSearchTransition.ts

Repository: swallowedB/b-log

Length of output: 1652


🏁 Script executed:

#!/bin/bash
# Search for test files more broadly
echo "=== Searching for test files ==="
find . -type f \( -name "*.test.ts*" -o -name "*.spec.ts*" \) | grep -i "header\|shell" | head -20

Repository: swallowedB/b-log

Length of output: 240


🏁 Script executed:

#!/bin/bash
# Read the SiteHeader component fully to understand context
echo "=== Reading full SiteHeader.tsx ==="
cat -n src/app/\(layout\)/\(shell\)/_components/layout/SiteHeader.tsx

Repository: swallowedB/b-log

Length of output: 6045


🏁 Script executed:

#!/bin/bash
# Read the SiteHeader test file to check if it wraps with CommandPaletteProvider
echo "=== Reading SiteHeader.test.tsx ==="
cat -n src/app/\(layout\)/\(shell\)/_components/layout/_tests_/SiteHeader.test.tsx

Repository: swallowedB/b-log

Length of output: 1407


🏁 Script executed:

#!/bin/bash
# Check CommandPalette implementation to understand the error
echo "=== Reading CommandPalette.tsx context setup ==="
head -50 src/components/common/CommandPalette.tsx | cat -n

Repository: swallowedB/b-log

Length of output: 1552


테스트에서 CommandPaletteProvider로 감싸기 필요

SiteHeader.test.tsx를 확인하니 현재 CommandPaletteProvider 없이 컴포넌트를 렌더링하고 있습니다(14줄). 하지만 useSearchTransition 훅이 내부적으로 useCommandPalette()를 호출하므로(useSearchTransition.ts 10줄), 프로바이더 컨텍스트 없이 실행되면 오류가 발생합니다.

테스트를 다음과 같이 수정해 주세요:

describe("SiteHeader", () => {
  test("로고가 렌더링된다", () => {
    render(
      <CommandPaletteProvider>
        <SiteHeader />
      </CommandPaletteProvider>
    );
    // ...
  });
  // 다른 테스트도 동일하게 적용
});

모바일 메뉴 토글 버튼의 aria-label 수정

93줄의 aria-label이 "모바일 메뉴 열기"라고 고정되어 있는데, 실제로는 토글 버튼입니다(메뉴를 열거나 닫음). 접근성을 위해 현재 상태를 반영하도록 수정하세요:

aria-label={isMobileMenuOpen ? "모바일 메뉴 닫기" : "모바일 메뉴 열기"}
🧰 Tools
🪛 GitHub Actions: CI

[error] 17-17: Test failed: useCommandPalette must be used within CommandPaletteProvider.

🤖 Prompt for AI Agents
In @src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx around lines 10 -
17, Tests render SiteHeader without the CommandPaletteProvider causing
useSearchTransition (which calls useCommandPalette) to fail; wrap SiteHeader in
CommandPaletteProvider in SiteHeader.test.tsx around each render (e.g., the
render in the "로고가 렌더링된다" test and other tests) so the context is available.
Also update the mobile menu toggle button in SiteHeader (the element using
isMobileMenuOpen) to set its aria-label dynamically using isMobileMenuOpen so it
reads "모바일 메뉴 닫기" when open and "모바일 메뉴 열기" when closed instead of a fixed
string.


return (
<header className="fixed inset-x-0 top-0 z-40 bg-background/5 backdrop-blur-md border-b border-foreground/10 ">
<div className="flex items-center justify-between px-5 md:px-10 py-1">
<Link href="/" aria-label="홈으로 이동" className="hidden md:block">
<Image src="/favicon.svg" alt="B-log" width={30} height={10} />
</Link>
<header className="fixed inset-x-0 top-0 z-40 border-b border-foreground/10 bg-background/5 backdrop-blur-md">
<div className="flex items-center justify-between px-5 py-1 md:px-10">
<div className="flex items-center gap-4">
<Link href="/" aria-label="홈으로 이동" className="hidden md:block">
<Image src="/favicon.svg" alt="B-log" width={30} height={10} />
</Link>

<AnimatePresence initial={false}>
{showSearch && (
<motion.button
key="header-search"
type="button"
onClick={handleSearchClick}
initial={{ opacity: 0, y: -6, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.96 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className={clsx(
"min-w-46 cursor-pointer ",
"hidden items-center gap-2 rounded-full",
"bg-neutral-200/50 ",
"dark:border dark:border-foreground/15 dark:bg-background/40 pl-3 pr-2 py-1.5",
"text-xs text-foreground/70 md:inline-flex"
)}
>
<Search className="h-4 w-4" />
<div className=" flex justify-between items-center w-full">
<span className="hidden lg:inline">검색</span>
<span className="hidden lg:inline dark:bg-gray-400/20 py-1 px-2.5 rounded-3xl text-[10px]">
⌘K
</span>
</div>
</motion.button>
)}
</AnimatePresence>
</div>

<div className="flex items-center gap-6">
{/* 데스크탑 버전 */}
<div className="hidden md:flex gap-9 items-center font-medium text-foreground transition-colors ">
<nav aria-label="주요 페이지">
<ul className="flex gap-10 items-center ">
<li>
<Link href="/resume">Resume</Link>
<div className="hidden items-center gap-9 font-medium text-foreground/70 md:flex">
<nav aria-label="주요 페이지" >
<ul className="flex items-center gap-10">
<li className="hover:text-foreground">
<Link href="/resume">Resume</Link>
</li>
<li>
<li className="hover:text-foreground">
<Link href="/guestbook">Guestbook</Link>
</li>
<li>
<li className="hover:text-foreground">
<Link href="/lab">Lab</Link>
</li>
</ul>
</nav>

<div className="hidden md:block">
{/* <ToggleModeButton /> */}
<DarkModeWheel />
</div>
</div>

{/* 모바일 반응형 - 메뉴 아이콘 추가 */}
<button
<motion.button
type="button"
className="mr-2 md:hidden"
onClick={handleSearchClick}
aria-label="검색 열기"
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
<Search size={22} />
</motion.button>

<motion.button
type="button"
className="md:hidden"
onClick={() => setOpen(!open)}
onClick={toggleMobileMenu}
aria-label="모바일 메뉴 열기"
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>
Comment on lines +89 to +98
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

모바일 메뉴 토글 버튼의 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>
📝 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={() => setOpen(!open)}
onClick={toggleMobileMenu}
aria-label="모바일 메뉴 열기"
whileTap={{ scale: 0.9, opacity: 0.8 }}
transition={{ duration: 0.08 }}
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
{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 89 -
98, The aria-label on the mobile menu button is static; update the motion.button
inside the SiteHeader component to set aria-label dynamically based on
isMobileMenuOpen (e.g., use "모바일 메뉴 닫기" when true and "모바일 메뉴 열기" when false)
and also add aria-expanded={isMobileMenuOpen} to reflect state; the change
should be applied to the existing motion.button that calls toggleMobileMenu and
renders {isMobileMenuOpen ? <X .../> : <Menu .../>}.

</div>
</div>
{/* 모바일 메뉴 */}
<div
className={`
md:hidden
overflow-hidden
transition-all duration-300
${open ? "max-h-60 opacity-100" : "max-h-0 opacity-0"}
`}
>
<nav className="px-10 pb-4 text-lg font-medium text-foreground">
<ul className="flex flex-col gap-4">
<li>
<Link href="/resume" onClick={() => setOpen(false)}>
Resume
</Link>
</li>
<li>
<Link href="/guestbook" onClick={() => setOpen(false)}>
Guestbook
</Link>
</li>
<li>
<Link href="/lab" onClick={() => setOpen(false)}>
Lab
</Link>
</li>
</ul>
</nav>
</div>

<AnimatePresence initial={false}>
{isMobileMenuOpen && (
<motion.div
key="mobile-menu"
initial={{ opacity: 0, height: 0, y: -8 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="md:hidden overflow-hidden border-t border-foreground/10 bg-background/90 backdrop-blur-md"
>
<nav className="px-10 pb-4 pt-3 text-lg font-medium text-foreground">
<ul className="flex flex-col gap-4">
<li>
<Link href="/resume" onClick={closeMobileMenu}>
Resume
</Link>
</li>
<li>
<Link href="/guestbook" onClick={closeMobileMenu}>
Guestbook
</Link>
</li>
<li>
<Link href="/lab" onClick={closeMobileMenu}>
Lab
</Link>
</li>
</ul>
</nav>
</motion.div>
)}
</AnimatePresence>
</header>
);
}

This file was deleted.

Loading
Loading