Skip to content
Draft
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
17,975 changes: 17,975 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@types/mdx": "^2.0.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"fathom-client": "^3.7.2",
"feed": "^5.1.0",
Expand Down
27 changes: 15 additions & 12 deletions src/components/Layouts/DefaultLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import Navbar from '@components/Navbar/Navbar';
import { SearchProvider } from '@components/SearchProvider';
import { SiteAnnouncement } from '@components/SiteAnnouncement';
import MDXProviderWrapper from '../../utils/MDXProviderWrapper';
import Footer from '../footer';
Expand All @@ -10,21 +11,23 @@ const DefaultLayout: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
return (
<main>
<div className="absolute top-[-5px] z-100 h-6 w-full bg-pink-400" />
<SiteAnnouncement />
<div className="w-full pt-8 md:pt-14">
<div className="mx-auto my-0 flex w-full flex-col gap-8 px-2 py-0 sm:px-4 lg:p-0">
<Navbar />
<SearchProvider>
<main>
<div className="absolute top-[-5px] z-100 h-6 w-full bg-pink-400" />
<SiteAnnouncement />
<div className="w-full pt-8 md:pt-14">
<div className="mx-auto my-0 flex w-full flex-col gap-8 px-2 py-0 sm:px-4 lg:p-0">
<Navbar />

<div className="flex flex-col">
<MDXProviderWrapper>{children}</MDXProviderWrapper>
<Footer />
<div className="flex flex-col">
<MDXProviderWrapper>{children}</MDXProviderWrapper>
<Footer />
</div>
</div>
</div>
</div>
<PolitePop />
</main>
<PolitePop />
</main>
</SearchProvider>
);
};

Expand Down
30 changes: 29 additions & 1 deletion src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
DisclosureButton,
DisclosurePanel,
} from '@headlessui/react';
import { Menu, X } from 'lucide-react';
import { Menu, Search, X } from 'lucide-react';

import { useSearch } from '@components/SearchProvider';
import clsxm from '@utils/clsxm';

const NavLinks: { title: string; href: string; badge?: boolean }[] = [
Expand All @@ -33,6 +34,8 @@ const NavLinks: { title: string; href: string; badge?: boolean }[] = [

const Navbar = () => {
const router = useRouter();
const { openSearch } = useSearch();

return (
<Disclosure as="nav" className="w-full bg-white">
{({ open }) => (
Expand Down Expand Up @@ -66,8 +69,24 @@ const Navbar = () => {
{link.title}
</Link>
))}
{/* Search Button */}
<button
onClick={openSearch}
className="inline-flex items-center px-1 pt-1 text-sm font-semibold text-gray-700 hover:text-gray-900 hover:no-underline transition-colors"
aria-label="Search articles"
>
<Search className="h-5 w-5" />
</button>
</div>
<div className="-mr-2 flex items-center sm:hidden">
{/* Mobile search button */}
<button
onClick={openSearch}
className="mr-2 inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:text-gray-500 focus:ring-2 focus:ring-pink-500 focus:outline-hidden focus:ring-inset"
aria-label="Search articles"
>
<Search className="h-6 w-6" />
</button>
{/* Mobile menu button */}
<DisclosureButton className="hover:bg--100 relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:text-gray-500 focus:ring-2 focus:ring-pink-500 focus:outline-hidden focus:ring-inset">
<span className="absolute -inset-0.5" />
Expand All @@ -84,6 +103,15 @@ const Navbar = () => {

<DisclosurePanel className="sm:hidden">
<div className="space-y-1 pt-2 pb-3">
{/* Search option in mobile menu */}
<DisclosureButton
as="button"
onClick={openSearch}
className="flex w-full items-center border-l-4 border-transparent py-2 pr-4 pl-3 text-base font-medium text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700"
>
<Search className="mr-3 h-5 w-5" />
Search Articles
</DisclosureButton>
{/* Current: "bg-indigo-50 border-indigo-500 text-indigo-700", Default: "border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700" */}
{NavLinks.map((link) => (
<DisclosureButton
Expand Down
162 changes: 162 additions & 0 deletions src/components/SearchCommand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
'use client';

import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { FileText, Hash, Quote, Search as SearchIcon } from 'lucide-react';

import type { BlogPost } from '@data/content-types';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@components/ui/command';
import { createHighlightedURL, searchPosts } from '@utils/search';
import type { SearchResult } from '@utils/search';

type SearchCommandProps = {
posts: BlogPost[];
open: boolean;
onOpenChange: (open: boolean) => void;
};

const getMatchIcon = (matchType: SearchResult['matchType']) => {
switch (matchType) {
case 'title':
return <FileText className="h-4 w-4" />;
case 'tags':
return <Hash className="h-4 w-4" />;
case 'excerpt':
return <Quote className="h-4 w-4" />;
case 'content':
return <SearchIcon className="h-4 w-4" />;
default:
return <FileText className="h-4 w-4" />;
}
};

const getMatchTypeLabel = (matchType: SearchResult['matchType']) => {
switch (matchType) {
case 'title':
return 'Title';
case 'tags':
return 'Tags';
case 'excerpt':
return 'Excerpt';
case 'content':
return 'Content';
default:
return 'Post';
}
};

export function SearchCommand({ posts, open, onOpenChange }: SearchCommandProps) {
const router = useRouter();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);

useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}

const searchResults = searchPosts(posts, query);
setResults(searchResults.slice(0, 10)); // Limit to 10 results
}, [query, posts]);

const handleSelect = (result: SearchResult) => {
const url = createHighlightedURL(result.post.frontmatter.slug, query);
router.push(url);
onOpenChange(false);
setQuery('');
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onOpenChange(false);
setQuery('');
}
};

// Close on click outside
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onOpenChange(false);
setQuery('');
}
};

if (!open) return null;

return (
<div
className="fixed inset-0 z-50 bg-black/50"
onClick={handleOverlayClick}
onKeyDown={handleKeyDown}
>
<div className="fixed left-[50%] top-[20%] max-h-[60vh] w-full max-w-[550px] translate-x-[-50%] rounded-lg border bg-white shadow-xl">
<Command>
<CommandInput
placeholder="Search articles..."
value={query}
onValueChange={setQuery}
className="h-12"
/>
<CommandList>
<CommandEmpty>No articles found.</CommandEmpty>
{results.length > 0 && (
<CommandGroup heading="Articles">
{results.map((result, index) => (
<CommandItem
key={`${result.post.frontmatter.slug}-${index}`}
onSelect={() => handleSelect(result)}
className="cursor-pointer space-y-1 p-3"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 text-gray-500">
{getMatchIcon(result.matchType)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium text-gray-900 line-clamp-1">
{result.post.frontmatter.title}
</div>
<div className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{getMatchTypeLabel(result.matchType)}
</div>
</div>
<div className="text-sm text-gray-600 line-clamp-2">
{result.snippet}
</div>
{result.post.frontmatter.tags && (
<div className="flex flex-wrap gap-1">
{result.post.frontmatter.tags.slice(0, 3).map(tag => (
<span
key={tag}
className="text-xs text-gray-500 bg-gray-50 px-1.5 py-0.5 rounded"
>
{tag}
</span>
))}
{result.post.frontmatter.tags.length > 3 && (
<span className="text-xs text-gray-500">
+{result.post.frontmatter.tags.length - 3} more
</span>
)}
</div>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</div>
</div>
);
}
64 changes: 64 additions & 0 deletions src/components/SearchProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import { createContext, useContext, useState, useCallback } from 'react';

import type { BlogPost } from '@data/content-types';
import { SearchCommand } from '@components/SearchCommand';

type SearchContextType = {
openSearch: () => void;
posts: BlogPost[];
setPosts: (posts: BlogPost[]) => void;
};

const SearchContext = createContext<SearchContextType | undefined>(undefined);

type SearchProviderProps = {
children: React.ReactNode;
};

export function SearchProvider({ children }: SearchProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [posts, setPosts] = useState<BlogPost[]>([]);
const [postsLoaded, setPostsLoaded] = useState(false);

const openSearch = useCallback(() => {
if (!postsLoaded) {
// Load posts on demand when search is first opened
fetch('/api/search-posts')
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Failed to fetch posts');
})
.then(data => {
setPosts(data.posts);
setPostsLoaded(true);
})
.catch(error => {
console.error('Failed to load posts for search:', error);
});
}
setIsOpen(true);
}, [postsLoaded]);

return (
<SearchContext.Provider value={{ openSearch, posts, setPosts }}>
{children}
<SearchCommand
posts={posts}
open={isOpen}
onOpenChange={setIsOpen}
/>
</SearchContext.Provider>
);
}

export function useSearch() {
const context = useContext(SearchContext);
if (context === undefined) {
throw new Error('useSearch must be used within a SearchProvider');
}
return context;
}
Loading
Loading