Files
memohanzi/src/app/(app)/hanzi/page.tsx
2025-11-21 09:51:16 +01:00

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>
)
}