feat: extract shared search component (#669)
This commit is contained in:
parent
cf7c017a72
commit
43cdc009f8
@ -1,6 +1,7 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import Link from 'next/link'
|
||||
import SearchComponent from '@components/search'
|
||||
import { getHomepagePosts } from '@cms/content'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
@ -31,12 +32,13 @@ function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
searchParams: { page?: string }
|
||||
searchParams?: { page?: string } | Promise<{ page?: string }>
|
||||
}
|
||||
|
||||
export default async function BlogPage({ searchParams }: PageProps) {
|
||||
const posts = await getHomepagePosts()
|
||||
const { page } = searchParams ?? {}
|
||||
const resolvedSearchParams = await Promise.resolve(searchParams ?? {})
|
||||
const { page } = resolvedSearchParams ?? {}
|
||||
const postsPerPage = 10
|
||||
const currentPage = parseInt(page || '1', 10)
|
||||
const totalPages = Math.max(1, Math.ceil(posts.length / postsPerPage))
|
||||
@ -57,6 +59,7 @@ export default async function BlogPage({ searchParams }: PageProps) {
|
||||
<p className="text-lg text-slate-600">
|
||||
Latest updates, releases, and insights from the Cloud-Neutral community.
|
||||
</p>
|
||||
<SearchComponent variant="hero" className="mt-6" />
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Mail, Search } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageProvider'
|
||||
import { translations } from '../i18n/translations'
|
||||
import LanguageToggle from './LanguageToggle'
|
||||
import ReleaseChannelSelector, { ReleaseChannel } from './ReleaseChannelSelector'
|
||||
import { getFeatureToggleInfo } from '@lib/featureToggles'
|
||||
import { useUser } from '@lib/userStore'
|
||||
import { AskAIDialog } from './AskAIDialog'
|
||||
import SearchComponent from './search'
|
||||
|
||||
const CHANNEL_ORDER: ReleaseChannel[] = ['stable', 'beta', 'develop']
|
||||
const DEFAULT_CHANNELS: ReleaseChannel[] = ['stable']
|
||||
@ -231,13 +231,8 @@ export default function Navbar() {
|
||||
download: isChinese ? '下载' : 'Download',
|
||||
openSource: isChinese ? '开源项目' : 'Open source',
|
||||
moreServices: isChinese ? '更多服务' : 'More services',
|
||||
searchPlaceholder: isChinese ? '请输入关键字搜索内容' : 'Ask anything about your docs',
|
||||
}
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [askDialogOpen, setAskDialogOpen] = useState(false)
|
||||
const [pendingQuestion, setPendingQuestion] = useState<{ key: number; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
@ -281,15 +276,6 @@ export default function Navbar() {
|
||||
{ key: 'xscopehub', label: 'XScopeHub', href: '/xscopehub' },
|
||||
]
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const trimmed = searchValue.trim()
|
||||
if (!trimmed) return
|
||||
setPendingQuestion({ key: Date.now(), text: trimmed })
|
||||
setAskDialogOpen(true)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
if (isHiddenRoute) {
|
||||
return null
|
||||
}
|
||||
@ -397,22 +383,7 @@ export default function Navbar() {
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 items-center justify-end gap-4 lg:flex">
|
||||
<form onSubmit={handleSearchSubmit} className="relative w-full max-w-xs">
|
||||
<input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder={labels.searchPlaceholder}
|
||||
className="w-full rounded-full border border-brand-border bg-brand-surface/60 py-2 pl-4 pr-10 text-sm text-brand-heading transition focus:border-brand focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-brand text-white transition hover:bg-brand-light"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
<SearchComponent className="relative w-full max-w-xs" />
|
||||
{user ? (
|
||||
<div className="relative" ref={accountMenuRef}>
|
||||
<button
|
||||
@ -501,22 +472,11 @@ export default function Navbar() {
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="flex flex-col gap-4 border-t border-gray-200 py-4 lg:hidden">
|
||||
<form onSubmit={handleSearchSubmit} className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder={labels.searchPlaceholder}
|
||||
className="w-full rounded-full border border-brand-border bg-brand-surface/60 py-2 pl-4 pr-10 text-sm text-brand-heading transition focus:border-brand focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-brand text-white transition hover:bg-brand-light"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
<SearchComponent
|
||||
className="relative"
|
||||
buttonClassName="h-8 w-8"
|
||||
inputClassName="py-2 pr-12"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-sm font-medium text-gray-700">
|
||||
{mainLinks.map((link) => (
|
||||
<Link key={link.key} href={link.href} className="py-2" onClick={() => setMenuOpen(false)}>
|
||||
@ -632,15 +592,6 @@ export default function Navbar() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AskAIDialog
|
||||
open={askDialogOpen}
|
||||
onMinimize={() => setAskDialogOpen(false)}
|
||||
onEnd={() => {
|
||||
setAskDialogOpen(false)
|
||||
setPendingQuestion(null)
|
||||
}}
|
||||
initialQuestion={pendingQuestion ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
87
dashboard/src/components/search/index.tsx
Normal file
87
dashboard/src/components/search/index.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { AskAIDialog } from '../AskAIDialog'
|
||||
import { useLanguage } from '../i18n/LanguageProvider'
|
||||
|
||||
type SearchComponentProps = {
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
buttonClassName?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
type PendingQuestion = { key: number; text: string }
|
||||
|
||||
const baseWrapperClass = 'relative w-full max-w-xs'
|
||||
const heroWrapperClass = 'relative w-full max-w-2xl'
|
||||
|
||||
const baseInputClass =
|
||||
'w-full rounded-full border border-brand-border bg-brand-surface/60 py-2 pl-4 pr-10 text-sm text-brand-heading transition focus:border-brand focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20'
|
||||
const heroInputClass =
|
||||
'w-full rounded-full border border-brand-border bg-white/80 py-3 pl-5 pr-12 text-base text-brand-heading shadow-sm transition focus:border-brand focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand/20'
|
||||
|
||||
const baseButtonClass =
|
||||
'absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-brand text-white transition hover:bg-brand-light'
|
||||
const heroButtonClass =
|
||||
'absolute right-3 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-brand text-white shadow-sm transition hover:bg-brand-light'
|
||||
|
||||
export default function SearchComponent({
|
||||
className,
|
||||
inputClassName,
|
||||
buttonClassName,
|
||||
variant = 'default'
|
||||
}: SearchComponentProps) {
|
||||
const { language } = useLanguage()
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [askDialogOpen, setAskDialogOpen] = useState(false)
|
||||
const [pendingQuestion, setPendingQuestion] = useState<PendingQuestion | null>(null)
|
||||
|
||||
const placeholder =
|
||||
language === 'zh' ? '请输入关键字搜索内容' : 'Ask anything about your docs'
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const trimmed = searchValue.trim()
|
||||
if (!trimmed) return
|
||||
setPendingQuestion({ key: Date.now(), text: trimmed })
|
||||
setAskDialogOpen(true)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
const wrapperClasses = clsx(variant === 'hero' ? heroWrapperClass : baseWrapperClass, className)
|
||||
const inputClasses = clsx(variant === 'hero' ? heroInputClass : baseInputClass, inputClassName)
|
||||
const submitButtonClasses = clsx(
|
||||
variant === 'hero' ? heroButtonClass : baseButtonClass,
|
||||
buttonClassName
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSearchSubmit} className={wrapperClasses}>
|
||||
<input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClasses}
|
||||
/>
|
||||
<button type="submit" className={submitButtonClasses} aria-label="Ask AI">
|
||||
<Search className={variant === 'hero' ? 'h-5 w-5' : 'h-4 w-4'} />
|
||||
</button>
|
||||
</form>
|
||||
<AskAIDialog
|
||||
open={askDialogOpen}
|
||||
onMinimize={() => setAskDialogOpen(false)}
|
||||
onEnd={() => {
|
||||
setAskDialogOpen(false)
|
||||
setPendingQuestion(null)
|
||||
}}
|
||||
initialQuestion={pendingQuestion ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user