Skip to content
Closed
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: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,3 @@ TODO
.env*.local
fonts/*
!fonts/init.mjs
.claude
.mcp.json
36 changes: 36 additions & 0 deletions app/components/Highlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';

type HighlightProps = {
text: string;
indices: readonly [number, number][];
};

export function Highlight({ text, indices }: HighlightProps) {
if (!indices || indices.length === 0) {
return <span>{text}</span>;
}

const parts: React.ReactNode[] = [];
let lastIndex = 0;

indices.forEach(([start, end]) => {
// Add the text part before the match
if (start > lastIndex) {
parts.push(<span key={`unmatched-${lastIndex}`}>{text.substring(lastIndex, start)}</span>);
}
// Add the matched part
parts.push(
<mark key={`matched-${start}`} className="bg-yellow-200 dark:bg-yellow-500 text-black rounded-sm px-0.5">
{text.substring(start, end + 1)}
</mark>
);
lastIndex = end + 1;
});

// Add the remaining text part after the last match
if (lastIndex < text.length) {
parts.push(<span key={`unmatched-${lastIndex}`}>{text.substring(lastIndex)}</span>);
}

return <span>{parts}</span>;
}
197 changes: 197 additions & 0 deletions app/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"use client";

import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { Fragment, useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import Fuse, { type FuseResult } from "fuse.js";
import type { Post } from "@/app/get-posts";
import { Highlight } from "./Highlight";

type FusePostResult = FuseResult<Post>;

export function Search({ posts }: { posts: Post[] }) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);

const fuse = useMemo(
() =>
new Fuse(posts, {
keys: [
{ name: "title", weight: 2 },
{ name: "description", weight: 1 },
{ name: "content", weight: 0.5 },
{ name: "tags", weight: 1.5 },
],
includeScore: true,
includeMatches: true,
minMatchCharLength: 2,
threshold: 0.3,
ignoreLocation: true,
}),
[posts]
);

const results: FusePostResult[] = query ? fuse.search(query).slice(0, 10) : [];

const openModal = () => setIsOpen(true);
const closeModal = () => {
setQuery("");
setActiveIndex(-1);
setIsOpen(false);
};

const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
setActiveIndex(-1);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prev => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prev => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
if (activeIndex !== -1) {
e.preventDefault();
const post = results[activeIndex].item;
router.push(`/${post.slug}`);
closeModal();
}
}
};

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
openModal();
}
};

window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);

return (
<>
<button
type="button"
onClick={openModal}
aria-label="Search posts"
className="inline-flex hover:bg-gray-200 dark:hover:bg-[#313131] active:bg-gray-300 dark:active:bg-[#242424] items-center p-2 rounded-sm transition-[background-color]"
>
<SearchIcon />
</button>

<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>

<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-16 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-xl transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">
Search Posts
</DialogTitle>
<div className="mt-4">
<input
type="text"
value={query}
onChange={handleSearch}
onKeyDown={handleKeyDown}
placeholder="Search by title, content, or tags..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>

<div className="mt-4 max-h-96 overflow-y-auto">
{results.length > 0 ? (
<ul>
{results.map(({ item: post, matches }, index) => {
const titleMatch = matches?.find(m => m.key === 'title');
const contentMatch = matches?.find(m => m.key === 'content' || m.key === 'description');
const isActive = index === activeIndex;

return (
<li key={post.slug} className="mt-2">
<a
href={`/${post.slug}`}
className={`block p-4 rounded-md transition-colors ${
isActive ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<div className="font-semibold text-gray-900 dark:text-gray-100">
{titleMatch ? (
<Highlight text={post.title} indices={titleMatch.indices} />
) : (
post.title
)}
</div>
{contentMatch && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
<Highlight text={contentMatch.value!} indices={contentMatch.indices} />
</div>
)}
</a>
</li>
);
})}
</ul>
) : (
query.length > 0 && <p className="text-center text-gray-500 py-4">No results found.</p>
)}
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
);
}

function SearchIcon(props: any) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
5 changes: 4 additions & 1 deletion app/header.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { ThemeToggle } from "./theme-toggle";
import { Logo } from "./logo";
import Link from "next/link";
import { Search } from "./components/Search";
import type { Post } from "./get-posts";

export function Header() {
export function Header({ posts }: { posts: Post[] }) {
return (
<header className="flex mb-5 md:mb-10 items-center">
<Logo />

<nav className="font-mono text-xs grow justify-end items-center flex gap-1 md:gap-3">
<Search posts={posts} />
<ThemeToggle />

<Link
Expand Down
12 changes: 8 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Analytics } from "./analytics";
import { Header } from "./header";
import { Footer } from "./footer";
import { ConditionalWebsiteJsonLd } from "./components/ConditionalJsonLd";
import { getPosts } from "./get-posts";

const inter = Inter({ subsets: ["latin"] });

Expand Down Expand Up @@ -85,11 +86,13 @@ export const viewport = {
themeColor: "transparent",
};

export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const posts = await getPosts();

return (
<html
lang="ko"
Expand All @@ -106,12 +109,13 @@ export default function RootLayout({

<body className="dark:text-gray-100 max-w-2xl m-auto">
{/* 조건부 Website JSON-LD (포스트 페이지가 아닐 때만) */}
<ConditionalWebsiteJsonLd
<ConditionalWebsiteJsonLd
websiteData={{
"@context": "https://schema.org",
"@type": "Website",
name: "LeeGyuHa Blog",
description: "개발자 이규하의 기술 블로그입니다. AI, 웹 개발, 프론트엔드 기술에 대한 인사이트를 공유합니다.",
description:
"개발자 이규하의 기술 블로그입니다. AI, 웹 개발, 프론트엔드 기술에 대한 인사이트를 공유합니다.",
url: "https://blog-leegyuha.vercel.app",
author: {
"@type": "Person",
Expand All @@ -132,7 +136,7 @@ export default function RootLayout({
/>

<main className="p-6 pt-3 md:pt-6 min-h-screen">
<Header />
<Header posts={posts} />
{children}
</main>

Expand Down
2 changes: 1 addition & 1 deletion app/posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function List({ posts, sort }: ListProps) {

return (
<li key={post.slug}>
<Link href={`/${post.slug}`}>
<Link href={post.slug}>
<span
className={`flex transition-[background-color] hover:bg-gray-100 dark:hover:bg-[#242424] active:bg-gray-200 dark:active:bg-[#222] border-y border-gray-200 dark:border-[#313131]
${!firstOfYear ? "border-t-0" : ""}
Expand Down
10 changes: 4 additions & 6 deletions app/tags/[tag]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,17 @@ export async function generateMetadata({
};
}

export default async function TagPage({
params,
}: {
params: Promise<{ tag: string }>;
}) {
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
const allPosts = await getPosts();
const posts = allPosts.filter(p => p.tags?.includes(decodedTag));

return (
<div className="max-w-2xl mx-auto px-4">
<h1 className="text-3xl font-bold mb-8">Tag: {decodedTag}</h1>
<h1 className="text-3xl font-bold mb-8">
Tag: <span className="text-blue-500">{decodedTag}</span>
</h1>
<Posts posts={posts} />
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@fontsource/inter": "^4.5.15",
"@fontsource/roboto-mono": "^4.5.10",
"@headlessui/react": "^2.2.7",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "15.0.2",
Expand All @@ -13,6 +14,7 @@
"@vercel/speed-insights": "^1.1.0",
"comma-number": "^2.1.0",
"date-fns": "^2.29.3",
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"image-size": "^1.0.2",
"load-script": "^1.0.0",
Expand Down
Loading
Loading