Files
soul/components/search-modal.tsx

213 lines
7.6 KiB
TypeScript
Raw Normal View History

"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Search, X, ChevronRight, FileText, Hash } from "lucide-react"
interface SearchResult {
id: string
title: string
partTitle: string
chapterTitle: string
price: number
isFree: boolean
matchType: 'title' | 'content'
snippet?: string
score: number
}
interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SearchModal({ open, onOpenChange }: SearchModalProps) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
const [isLoading, setIsLoading] = useState(false)
const [keywords, setKeywords] = useState<string[]>([])
// 热门搜索词
const hotKeywords = ["私域", "流量", "赚钱", "电商", "AI", "社群"]
// 搜索防抖
useEffect(() => {
if (!query.trim()) {
setResults([])
setKeywords([])
return
}
const timer = setTimeout(async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=all`)
const data = await res.json()
if (data.success) {
setResults(data.data.results || [])
setKeywords(data.data.keywords || [])
}
} catch (error) {
console.error("Search error:", error)
} finally {
setIsLoading(false)
}
}, 300)
return () => clearTimeout(timer)
}, [query])
// 打开时聚焦输入框
useEffect(() => {
if (open && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
const handleResultClick = (result: SearchResult) => {
onOpenChange(false)
router.push(`/read/${result.id}`)
}
const handleKeywordClick = (keyword: string) => {
setQuery(keyword)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[#1c1c1e] border-white/10 text-white max-w-lg p-0 gap-0 overflow-hidden">
{/* 搜索输入 */}
<div className="p-4 border-b border-white/5">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索章节标题或内容..."
className="pl-10 pr-10 bg-[#2c2c2e] border-white/5 text-white placeholder:text-gray-500 focus:border-[#00CED1]/50"
/>
{query && (
<button
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* 搜索结果 */}
<div className="max-h-[60vh] overflow-y-auto">
{/* 热门搜索 */}
{!query && (
<div className="p-4">
<p className="text-gray-500 text-xs mb-3"></p>
<div className="flex flex-wrap gap-2">
{hotKeywords.map((keyword) => (
<button
key={keyword}
onClick={() => handleKeywordClick(keyword)}
className="px-3 py-1.5 text-xs rounded-full bg-[#2c2c2e] text-gray-300 hover:bg-[#3c3c3e] transition-colors"
>
{keyword}
</button>
))}
</div>
</div>
)}
{/* 加载状态 */}
{isLoading && (
<div className="p-8 text-center">
<div className="w-5 h-5 border-2 border-[#00CED1] border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-gray-500 text-sm mt-2">...</p>
</div>
)}
{/* 搜索结果列表 */}
{!isLoading && query && results.length > 0 && (
<div>
<p className="px-4 py-2 text-gray-500 text-xs border-b border-white/5">
{results.length}
</p>
{results.map((result) => (
<button
key={result.id}
onClick={() => handleResultClick(result)}
className="w-full p-4 text-left hover:bg-[#2c2c2e] transition-colors border-b border-white/5 last:border-0"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-[#00CED1]/10 flex items-center justify-center shrink-0">
<FileText className="w-4 h-4 text-[#00CED1]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[#00CED1] text-xs font-mono">{result.id}</span>
{result.isFree && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[#00CED1]/10 text-[#00CED1]">
</span>
)}
{result.matchType === 'content' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
</span>
)}
</div>
<h4 className="text-white font-medium text-sm mt-1 truncate">{result.title}</h4>
{result.snippet && (
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{result.snippet}</p>
)}
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-600 shrink-0 mt-1" />
</div>
</button>
))}
</div>
)}
{/* 相关关键词 */}
{!isLoading && query && keywords.length > 0 && (
<div className="p-4 border-t border-white/5">
<p className="text-gray-500 text-xs mb-2">
<Hash className="w-3 h-3 inline mr-1" />
</p>
<div className="flex flex-wrap gap-2">
{keywords.slice(0, 8).map((keyword) => (
<button
key={keyword}
onClick={() => handleKeywordClick(keyword)}
className="px-2 py-1 text-xs rounded bg-[#2c2c2e] text-gray-400 hover:text-[#00CED1] transition-colors"
>
#{keyword}
</button>
))}
</div>
</div>
)}
{/* 无结果 */}
{!isLoading && query && results.length === 0 && (
<div className="p-8 text-center">
<Search className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm"></p>
<p className="text-gray-600 text-xs mt-1"></p>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}