284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import Link from "next/link"
|
|
import { searchHanzi } from "@/actions/hanzi"
|
|
|
|
type HanziResult = {
|
|
id: string
|
|
simplified: string
|
|
traditional: string | null
|
|
pinyin: string | null
|
|
meaning: string | null
|
|
hskLevels: string[]
|
|
radical: string | null
|
|
frequency: number | null
|
|
}
|
|
|
|
const HSK_LEVELS = ["new-1", "new-2", "new-3", "new-4", "new-5", "new-6", "old-1", "old-2", "old-3", "old-4", "old-5", "old-6"]
|
|
|
|
export default function HanziSearchPage() {
|
|
const [query, setQuery] = useState("")
|
|
const [hskLevel, setHskLevel] = useState<string>("")
|
|
const [results, setResults] = useState<HanziResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [total, setTotal] = useState(0)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [offset, setOffset] = useState(0)
|
|
const limit = 20
|
|
|
|
const handleSearch = async (newOffset: number = 0) => {
|
|
if (!query.trim()) {
|
|
setError("Please enter a search query")
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const result = await searchHanzi(
|
|
query,
|
|
hskLevel || undefined,
|
|
limit,
|
|
newOffset
|
|
)
|
|
|
|
if (result.success && result.data) {
|
|
setResults(result.data.hanzi)
|
|
setTotal(result.data.total)
|
|
setHasMore(result.data.hasMore)
|
|
setOffset(newOffset)
|
|
} else {
|
|
setError(result.message || "Failed to search hanzi")
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "An error occurred")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch(0)
|
|
}
|
|
}
|
|
|
|
const handleNextPage = () => {
|
|
handleSearch(offset + limit)
|
|
}
|
|
|
|
const handlePrevPage = () => {
|
|
handleSearch(Math.max(0, offset - limit))
|
|
}
|
|
|
|
const currentPage = Math.floor(offset / limit) + 1
|
|
const totalPages = Math.ceil(total / limit)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
<nav className="bg-white dark:bg-gray-800 shadow">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="flex justify-between h-16 items-center">
|
|
<div className="flex items-center space-x-8">
|
|
<Link href="/dashboard">
|
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
|
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
|
</h1>
|
|
</Link>
|
|
<Link
|
|
href="/hanzi"
|
|
className="text-sm text-gray-900 dark:text-gray-200 font-medium"
|
|
>
|
|
Search Hanzi
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div className="mb-8">
|
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Search Hanzi</h2>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Search by character, pinyin, or meaning
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search Form */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
|
Search Query
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Enter character, pinyin, or meaning..."
|
|
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
|
HSK Level (optional)
|
|
</label>
|
|
<select
|
|
value={hskLevel}
|
|
onChange={(e) => setHskLevel(e.target.value)}
|
|
className="block w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
disabled={loading}
|
|
>
|
|
<option value="">All Levels</option>
|
|
{HSK_LEVELS.map((level) => (
|
|
<option key={level} value={level}>
|
|
{level.toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleSearch(0)}
|
|
disabled={loading || !query.trim()}
|
|
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? "Searching..." : "Search"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{results.length > 0 && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Found {total} result{total !== 1 ? "s" : ""}
|
|
{hskLevel && ` for HSK ${hskLevel.toUpperCase()}`}
|
|
</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Page {currentPage} of {totalPages}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
|
{results.map((hanzi) => (
|
|
<Link
|
|
key={hanzi.id}
|
|
href={`/hanzi/${hanzi.id}`}
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
|
|
>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="text-4xl font-bold text-gray-900 dark:text-white">
|
|
{hanzi.simplified}
|
|
</div>
|
|
{hanzi.hskLevels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{hanzi.hskLevels.map((level) => (
|
|
<span
|
|
key={level}
|
|
className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded"
|
|
>
|
|
{level.toUpperCase()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hanzi.traditional && hanzi.traditional !== hanzi.simplified && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
|
Traditional: {hanzi.traditional}
|
|
</p>
|
|
)}
|
|
|
|
{hanzi.pinyin && (
|
|
<p className="text-lg text-gray-700 dark:text-gray-300 mb-2">
|
|
{hanzi.pinyin}
|
|
</p>
|
|
)}
|
|
|
|
{hanzi.meaning && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
{hanzi.meaning}
|
|
</p>
|
|
)}
|
|
|
|
{hanzi.radical && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
Radical: {hanzi.radical}
|
|
</p>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center items-center gap-4">
|
|
<button
|
|
onClick={handlePrevPage}
|
|
disabled={offset === 0}
|
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:text-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
Page {currentPage} of {totalPages}
|
|
</span>
|
|
|
|
<button
|
|
onClick={handleNextPage}
|
|
disabled={!hasMore}
|
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:text-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!loading && results.length === 0 && query && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
No hanzi found matching "{query}"
|
|
{hskLevel && ` in HSK ${hskLevel.toUpperCase()}`}
|
|
</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Try a different search term or remove the HSK filter
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Initial State */}
|
|
{!loading && results.length === 0 && !query && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
Enter a search term to find hanzi
|
|
</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Search by simplified character, traditional character, pinyin, or English meaning
|
|
</p>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|