Skip to content

Commit 284ab8d

Browse files
ericyangpanclaude
andcommitted
feat: add search functionality and update header navigation
- Add search dialog component with keyboard shortcuts (Cmd/Ctrl+K) - Integrate search into header with icon button - Add search page for displaying search results - Add cmdk library for command palette UI - Add search translations for all supported locales - Replace "Features" link with "Manifesto" in navigation - Add "Landscape" link to header navigation - Remove GitHub link from header navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 26eca40 commit 284ab8d

File tree

8 files changed

+1563
-38
lines changed

8 files changed

+1563
-38
lines changed

package-lock.json

Lines changed: 598 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@mdx-js/react": "^3.1.1",
3737
"@next/third-parties": "^15.5.4",
3838
"@opennextjs/cloudflare": "^1.13.1",
39+
"cmdk": "^1.1.1",
3940
"lucide-react": "^0.554.0",
4041
"next": "^15.5.6",
4142
"next-intl": "^4.5.5",
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
'use client'
2+
3+
import { useRouter, useSearchParams } from 'next/navigation'
4+
import { useTranslations } from 'next-intl'
5+
import { useEffect, useMemo, useState } from 'react'
6+
import SearchInput from '@/components/controls/SearchInput'
7+
import Footer from '@/components/Footer'
8+
import Header from '@/components/Header'
9+
import { Link } from '@/i18n/navigation'
10+
import type { SearchResult } from '@/lib/search'
11+
import { search } from '@/lib/search'
12+
13+
type Props = {
14+
locale: string
15+
initialQuery: string
16+
}
17+
18+
export default function SearchPageClient({ locale, initialQuery }: Props) {
19+
const t = useTranslations('search')
20+
const router = useRouter()
21+
const searchParams = useSearchParams()
22+
const [query, setQuery] = useState(initialQuery)
23+
const [isSearching, setIsSearching] = useState(false)
24+
25+
// Perform search
26+
const results = useMemo<SearchResult[]>(() => {
27+
if (!query.trim()) return []
28+
return search(query, locale)
29+
}, [query, locale])
30+
31+
// Update query when URL changes
32+
useEffect(() => {
33+
const urlQuery = searchParams.get('q') || ''
34+
if (urlQuery !== query) {
35+
setQuery(urlQuery)
36+
}
37+
}, [searchParams, query])
38+
39+
// Handle search from input
40+
const handleSearch = (searchQuery: string) => {
41+
setIsSearching(true)
42+
setQuery(searchQuery)
43+
const params = new URLSearchParams()
44+
if (searchQuery.trim()) {
45+
params.set('q', searchQuery.trim())
46+
}
47+
router.push(`/${locale}/search?${params.toString()}`)
48+
setTimeout(() => setIsSearching(false), 300)
49+
}
50+
51+
// Get category route
52+
const getCategoryRoute = (category: string, id: string) => {
53+
return `/${locale}/${category}/${id}`
54+
}
55+
56+
return (
57+
<>
58+
<Header />
59+
60+
<div className="max-w-8xl mx-auto px-[var(--spacing-md)] py-[var(--spacing-lg)]">
61+
<main className="w-full">
62+
{/* Page Header */}
63+
<div className="mb-[var(--spacing-lg)]">
64+
<h1 className="text-[2rem] font-semibold tracking-[-0.03em] mb-[var(--spacing-sm)]">
65+
{t('title')}
66+
</h1>
67+
</div>
68+
69+
{/* Search Input */}
70+
<div className="mb-[var(--spacing-md)] max-w-xl">
71+
<SearchInput initialQuery={query} onSearch={handleSearch} autoFocus />
72+
</div>
73+
74+
{/* Results Section */}
75+
{query.trim() ? (
76+
<div>
77+
{/* Results Count */}
78+
<div className="mb-[var(--spacing-md)] text-sm text-[var(--color-text-secondary)]">
79+
{isSearching ? (
80+
<span>{t('searching')}</span>
81+
) : results.length > 0 ? (
82+
<span>{t('resultsCountFor', { count: results.length, query })}</span>
83+
) : (
84+
<span className="text-[var(--color-text-muted)]">
85+
{t('noResultsFor', { query })}
86+
</span>
87+
)}
88+
</div>
89+
90+
{/* Results Grid */}
91+
{results.length > 0 ? (
92+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-[var(--spacing-md)]">
93+
{results.map(result => (
94+
<Link
95+
key={`${result.category}-${result.id}`}
96+
href={getCategoryRoute(result.category, result.id)}
97+
className="block border border-[var(--color-border)] p-[var(--spacing-md)] hover:border-[var(--color-border-strong)] transition-all hover:-translate-y-0.5 group flex flex-col"
98+
>
99+
{/* Header with category badge */}
100+
<div className="flex justify-between items-start mb-[var(--spacing-sm)]">
101+
<div className="flex-1 min-w-0">
102+
<h3 className="text-lg font-semibold tracking-tight truncate">
103+
{result.name}
104+
</h3>
105+
<div className="mt-1">
106+
<span className="inline-block px-2 py-0.5 text-xs border border-[var(--color-border)] text-[var(--color-text-muted)]">
107+
{t(`categories.${result.category}`)}
108+
</span>
109+
</div>
110+
</div>
111+
<span className="text-lg text-[var(--color-text-muted)] group-hover:text-[var(--color-text)] group-hover:translate-x-1 transition-all ml-2">
112+
113+
</span>
114+
</div>
115+
116+
{/* Description */}
117+
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)] font-light min-h-[4rem] line-clamp-3">
118+
{result.description}
119+
</p>
120+
</Link>
121+
))}
122+
</div>
123+
) : (
124+
!isSearching && (
125+
<div className="text-center py-[var(--spacing-xl)]">
126+
<div className="text-[var(--color-text-muted)] mb-4">
127+
<svg
128+
className="w-16 h-16 mx-auto mb-4"
129+
fill="none"
130+
stroke="currentColor"
131+
viewBox="0 0 24 24"
132+
strokeWidth={1}
133+
aria-hidden="true"
134+
>
135+
<path
136+
strokeLinecap="round"
137+
strokeLinejoin="round"
138+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
139+
/>
140+
</svg>
141+
<p className="text-base">{t('noResults')}</p>
142+
</div>
143+
</div>
144+
)
145+
)}
146+
</div>
147+
) : (
148+
<div className="text-center py-[var(--spacing-xl)]">
149+
<div className="text-[var(--color-text-muted)]">
150+
<svg
151+
className="w-16 h-16 mx-auto mb-4"
152+
fill="none"
153+
stroke="currentColor"
154+
viewBox="0 0 24 24"
155+
strokeWidth={1}
156+
aria-hidden="true"
157+
>
158+
<path
159+
strokeLinecap="round"
160+
strokeLinejoin="round"
161+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
162+
/>
163+
</svg>
164+
<p className="text-base">{t('placeholder')}</p>
165+
</div>
166+
</div>
167+
)}
168+
</main>
169+
</div>
170+
171+
<Footer />
172+
</>
173+
)
174+
}

src/app/[locale]/search/page.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Metadata } from 'next'
2+
import { getTranslations } from 'next-intl/server'
3+
import { buildOpenGraph, buildTitle, buildTwitterCard, SITE_CONFIG } from '@/lib/metadata'
4+
import SearchPageClient from './page.client'
5+
6+
export const revalidate = 3600
7+
8+
type Props = {
9+
params: Promise<{ locale: string }>
10+
searchParams: Promise<{ q?: string }>
11+
}
12+
13+
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
14+
const { locale } = await params
15+
const { q } = await searchParams
16+
const t = await getTranslations({ locale, namespace: 'search' })
17+
18+
const title = q ? t('resultsCountFor', { count: 0, query: q }) : t('title')
19+
const description = t('placeholder')
20+
const searchUrl = `/${locale}/search${q ? `?q=${encodeURIComponent(q)}` : ''}`
21+
22+
return {
23+
title: buildTitle({ title }),
24+
description,
25+
openGraph: buildOpenGraph({
26+
title: buildTitle({ title }),
27+
description,
28+
url: searchUrl,
29+
locale: locale as 'en' | 'zh-Hans' | 'de',
30+
}),
31+
twitter: buildTwitterCard({
32+
title: buildTitle({ title }),
33+
description,
34+
}),
35+
alternates: {
36+
canonical: `${SITE_CONFIG.url}${searchUrl}`,
37+
languages: {
38+
en: `${SITE_CONFIG.url}/en/search${q ? `?q=${encodeURIComponent(q)}` : ''}`,
39+
'zh-Hans': `${SITE_CONFIG.url}/zh-Hans/search${q ? `?q=${encodeURIComponent(q)}` : ''}`,
40+
de: `${SITE_CONFIG.url}/de/search${q ? `?q=${encodeURIComponent(q)}` : ''}`,
41+
},
42+
},
43+
}
44+
}
45+
46+
export default async function SearchPage({ params, searchParams }: Props) {
47+
const { locale } = await params
48+
const { q } = await searchParams
49+
50+
return <SearchPageClient locale={locale} initialQuery={q || ''} />
51+
}

0 commit comments

Comments
 (0)