DB, Collections, Search

This commit is contained in:
Stefan Hardegger
2025-11-21 07:53:37 +01:00
parent c8eb6237c4
commit 8a03edbb88
67 changed files with 17703 additions and 103 deletions

View File

@@ -0,0 +1,701 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useParams } from "next/navigation"
import Link from "next/link"
import {
getCollection,
addHanziToCollection,
removeHanziFromCollection,
removeMultipleHanziFromCollection,
searchHanziForCollection,
parseHanziList,
deleteCollection,
} from "@/actions/collections"
type Hanzi = {
id: string
simplified: string
pinyin: string | null
meaning: string | null
orderIndex: number
}
type Collection = {
id: string
name: string
description: string | null
isPublic: boolean
isGlobal: boolean
createdBy: string | null
hanziCount: number
hanzi: Hanzi[]
}
export default function CollectionDetailPage() {
const router = useRouter()
const params = useParams()
const collectionId = params.id as string
const [collection, setCollection] = useState<Collection | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Selection mode
const [selectionMode, setSelectionMode] = useState(false)
const [selectedHanziIds, setSelectedHanziIds] = useState<Set<string>>(new Set())
// Add hanzi modal
const [showAddModal, setShowAddModal] = useState(false)
const [addTab, setAddTab] = useState<"search" | "paste">("search")
// Search state
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<
Array<{
id: string
simplified: string
pinyin: string | null
meaning: string | null
inCollection: boolean
}>
>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchSelectedIds, setSearchSelectedIds] = useState<Set<string>>(new Set())
// Paste state
const [pasteInput, setPasteInput] = useState("")
const [parseResult, setParseResult] = useState<{
valid: boolean
found: Array<{ id: string; simplified: string; pinyin: string | null }>
notFound: string[]
duplicates: string[]
} | null>(null)
const [actionLoading, setActionLoading] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
useEffect(() => {
loadCollection()
}, [collectionId])
const loadCollection = async () => {
setLoading(true)
setError(null)
try {
const result = await getCollection(collectionId)
if (result.success && result.data) {
setCollection(result.data)
} else {
setError(result.message || "Failed to load collection")
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setLoading(false)
}
}
const handleSearch = async () => {
if (!searchQuery.trim()) return
setSearchLoading(true)
try {
const result = await searchHanziForCollection(searchQuery, collectionId, 50, 0)
if (result.success && result.data) {
setSearchResults(result.data.hanzi)
}
} catch (err) {
console.error("Search failed:", err)
} finally {
setSearchLoading(false)
}
}
const handleParseList = async () => {
if (!pasteInput.trim()) {
setActionError("Please enter hanzi list")
return
}
setActionLoading(true)
setActionError(null)
try {
const result = await parseHanziList(pasteInput)
if (result.success && result.data) {
setParseResult(result.data)
if (!result.data.valid) {
setActionError(`${result.data.notFound.length} hanzi not found in database`)
}
} else {
setActionError(result.message || "Failed to parse hanzi list")
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "An error occurred")
} finally {
setActionLoading(false)
}
}
const handleAddSelectedFromSearch = async () => {
if (searchSelectedIds.size === 0) return
setActionLoading(true)
setActionError(null)
try {
const result = await addHanziToCollection(collectionId, Array.from(searchSelectedIds))
if (result.success) {
setShowAddModal(false)
setSearchQuery("")
setSearchResults([])
setSearchSelectedIds(new Set())
await loadCollection()
} else {
setActionError(result.message || "Failed to add hanzi")
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "An error occurred")
} finally {
setActionLoading(false)
}
}
const handleAddFromPaste = async () => {
if (!parseResult || !parseResult.valid) {
setActionError("Please preview and fix any errors first")
return
}
setActionLoading(true)
setActionError(null)
try {
const hanziIds = parseResult.found.map((h) => h.id)
const result = await addHanziToCollection(collectionId, hanziIds)
if (result.success) {
setShowAddModal(false)
setPasteInput("")
setParseResult(null)
await loadCollection()
} else {
setActionError(result.message || "Failed to add hanzi")
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "An error occurred")
} finally {
setActionLoading(false)
}
}
const handleRemoveSingle = async (hanziId: string) => {
if (!confirm("Remove this hanzi from the collection?")) return
setActionLoading(true)
try {
const result = await removeHanziFromCollection(collectionId, hanziId)
if (result.success) {
await loadCollection()
}
} catch (err) {
console.error("Remove failed:", err)
} finally {
setActionLoading(false)
}
}
const handleRemoveSelected = async () => {
if (selectedHanziIds.size === 0) return
if (!confirm(`Remove ${selectedHanziIds.size} hanzi from the collection?`)) return
setActionLoading(true)
try {
const result = await removeMultipleHanziFromCollection(collectionId, Array.from(selectedHanziIds))
if (result.success) {
setSelectionMode(false)
setSelectedHanziIds(new Set())
await loadCollection()
}
} catch (err) {
console.error("Remove failed:", err)
} finally {
setActionLoading(false)
}
}
const handleDeleteCollection = async () => {
if (!confirm("Delete this collection? This action cannot be undone.")) return
setActionLoading(true)
try {
const result = await deleteCollection(collectionId)
if (result.success) {
router.push("/collections")
} else {
setActionError(result.message || "Failed to delete collection")
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "An error occurred")
} finally {
setActionLoading(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
)
}
if (error || !collection) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 dark:text-red-400 mb-4">{error || "Collection not found"}</p>
<Link href="/collections" className="text-blue-600 hover:underline">
Back to Collections
</Link>
</div>
</div>
)
}
const canModify = !collection.isGlobal
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="/collections"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Collections
</Link>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-start mb-8">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">
{collection.name}
</h2>
{collection.isGlobal && (
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
HSK
</span>
)}
{collection.isPublic && !collection.isGlobal && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
Public
</span>
)}
</div>
{collection.description && (
<p className="text-gray-600 dark:text-gray-400 mb-2">{collection.description}</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
{collection.hanziCount} hanzi
</p>
</div>
{canModify && (
<div className="flex gap-2">
<button
onClick={() => setShowAddModal(true)}
disabled={actionLoading}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
Add Hanzi
</button>
{collection.hanziCount > 0 && (
<button
onClick={() => setSelectionMode(!selectionMode)}
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
>
{selectionMode ? "Cancel" : "Select"}
</button>
)}
<button
onClick={handleDeleteCollection}
disabled={actionLoading}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
>
Delete
</button>
</div>
)}
</div>
{selectionMode && selectedHanziIds.size > 0 && (
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg mb-6 flex justify-between items-center">
<span className="text-blue-900 dark:text-blue-200 font-medium">
{selectedHanziIds.size} selected
</span>
<button
onClick={handleRemoveSelected}
disabled={actionLoading}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
>
Remove Selected
</button>
</div>
)}
{actionError && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{actionError}
</div>
)}
{/* Hanzi Grid */}
{collection.hanzi.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
This collection is empty. Add some hanzi to get started.
</p>
{canModify && (
<button
onClick={() => setShowAddModal(true)}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Add Hanzi
</button>
)}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{collection.hanzi.map((hanzi) => (
<div
key={hanzi.id}
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 relative ${
selectedHanziIds.has(hanzi.id) ? "ring-2 ring-blue-600" : ""
}`}
>
{selectionMode && (
<input
type="checkbox"
checked={selectedHanziIds.has(hanzi.id)}
onChange={(e) => {
const newSet = new Set(selectedHanziIds)
if (e.target.checked) {
newSet.add(hanzi.id)
} else {
newSet.delete(hanzi.id)
}
setSelectedHanziIds(newSet)
}}
className="absolute top-2 left-2"
/>
)}
{!selectionMode && canModify && (
<button
onClick={() => handleRemoveSingle(hanzi.id)}
disabled={actionLoading}
className="absolute top-2 right-2 text-red-600 hover:text-red-800 disabled:text-gray-400"
title="Remove"
>
×
</button>
)}
<div className="text-center">
<div className="text-4xl mb-2">{hanzi.simplified}</div>
{hanzi.pinyin && (
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
{hanzi.pinyin}
</div>
)}
{hanzi.meaning && (
<div className="text-xs text-gray-500 dark:text-gray-500 line-clamp-2">
{hanzi.meaning}
</div>
)}
</div>
</div>
))}
</div>
)}
</main>
{/* Add Hanzi Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden">
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center p-4">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Add Hanzi</h3>
<button
onClick={() => {
setShowAddModal(false)
setSearchQuery("")
setSearchResults([])
setSearchSelectedIds(new Set())
setPasteInput("")
setParseResult(null)
setActionError(null)
}}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
×
</button>
</div>
<div className="flex border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setAddTab("search")}
className={`flex-1 py-3 px-6 text-center font-medium ${
addTab === "search"
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
: "text-gray-600 dark:text-gray-400"
}`}
>
Search
</button>
<button
onClick={() => setAddTab("paste")}
className={`flex-1 py-3 px-6 text-center font-medium ${
addTab === "paste"
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
: "text-gray-600 dark:text-gray-400"
}`}
>
Paste
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{actionError && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{actionError}
</div>
)}
{addTab === "search" ? (
<div className="space-y-4">
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search by character, pinyin, or meaning..."
className="flex-1 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"
/>
<button
onClick={handleSearch}
disabled={searchLoading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
{searchLoading ? "..." : "Search"}
</button>
</div>
{searchSelectedIds.size > 0 && (
<div className="flex justify-between items-center bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
<span className="text-sm text-blue-900 dark:text-blue-200">
{searchSelectedIds.size} selected
</span>
<button
onClick={() => setSearchSelectedIds(new Set())}
className="text-sm text-blue-600 hover:underline"
>
Clear
</button>
</div>
)}
{searchResults.length > 0 && (
<div className="space-y-2">
{searchResults.map((hanzi) => (
<label
key={hanzi.id}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 ${
hanzi.inCollection
? "opacity-50 cursor-not-allowed"
: "border-gray-300 dark:border-gray-600"
}`}
>
<input
type="checkbox"
checked={searchSelectedIds.has(hanzi.id)}
onChange={(e) => {
const newSet = new Set(searchSelectedIds)
if (e.target.checked) {
newSet.add(hanzi.id)
} else {
newSet.delete(hanzi.id)
}
setSearchSelectedIds(newSet)
}}
disabled={hanzi.inCollection}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-2xl">{hanzi.simplified}</span>
{hanzi.pinyin && (
<span className="text-gray-600 dark:text-gray-400">
{hanzi.pinyin}
</span>
)}
{hanzi.inCollection && (
<span className="text-xs bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">
Already in collection
</span>
)}
</div>
{hanzi.meaning && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{hanzi.meaning}
</p>
)}
</div>
</label>
))}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div>
<textarea
value={pasteInput}
onChange={(e) => {
setPasteInput(e.target.value)
setParseResult(null)
setActionError(null)
}}
placeholder="Paste hanzi here (newline, comma, or space separated)&#10;Example: 好 爱 你&#10;or: 好, 爱, 你"
rows={6}
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 font-mono text-sm"
/>
<button
onClick={handleParseList}
disabled={actionLoading || !pasteInput.trim()}
className="mt-2 bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 disabled:bg-gray-400"
>
{actionLoading ? "Parsing..." : "Preview"}
</button>
</div>
{parseResult && (
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
<h4 className="font-semibold mb-3 text-gray-900 dark:text-white">
Preview Results
</h4>
{parseResult.found.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
Found: {parseResult.found.length} hanzi
</p>
<div className="flex flex-wrap gap-2">
{parseResult.found.slice(0, 30).map((h) => (
<span
key={h.id}
className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded text-sm"
>
{h.simplified} {h.pinyin ? `(${h.pinyin})` : ""}
</span>
))}
{parseResult.found.length > 30 && (
<span className="text-gray-600 dark:text-gray-400 text-sm">
... and {parseResult.found.length - 30} more
</span>
)}
</div>
</div>
)}
{parseResult.notFound.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
Not found: {parseResult.notFound.length} hanzi
</p>
<div className="flex flex-wrap gap-2">
{parseResult.notFound.map((char, i) => (
<span
key={i}
className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-1 rounded text-sm"
>
{char}
</span>
))}
</div>
</div>
)}
{parseResult.duplicates.length > 0 && (
<div>
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400 mb-2">
Duplicates in input: {parseResult.duplicates.length}
</p>
<div className="flex flex-wrap gap-2">
{parseResult.duplicates.map((char, i) => (
<span
key={i}
className="bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded text-sm"
>
{char}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex justify-end gap-2">
<button
onClick={() => {
setShowAddModal(false)
setSearchQuery("")
setSearchResults([])
setSearchSelectedIds(new Set())
setPasteInput("")
setParseResult(null)
setActionError(null)
}}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={addTab === "search" ? handleAddSelectedFromSearch : handleAddFromPaste}
disabled={
actionLoading ||
(addTab === "search"
? searchSelectedIds.size === 0
: !parseResult || !parseResult.valid)
}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
{actionLoading
? "Adding..."
: addTab === "search"
? `Add Selected (${searchSelectedIds.size})`
: `Add ${parseResult?.found.length || 0} Hanzi`}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,439 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { createCollection, createCollectionWithHanzi, parseHanziList } from "@/actions/collections"
type Tab = "empty" | "fromList"
export default function NewCollectionPage() {
const router = useRouter()
const [activeTab, setActiveTab] = useState<Tab>("empty")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Form state
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [isPublic, setIsPublic] = useState(false)
const [hanziList, setHanziList] = useState("")
// Parse state
const [parseResult, setParseResult] = useState<{
valid: boolean
found: Array<{ id: string; simplified: string; pinyin: string | null }>
notFound: string[]
duplicates: string[]
} | null>(null)
const handleParseList = async () => {
if (!hanziList.trim()) {
setError("Please enter hanzi list")
return
}
setLoading(true)
setError(null)
try {
const result = await parseHanziList(hanziList)
if (result.success && result.data) {
setParseResult(result.data)
if (!result.data.valid) {
setError(`${result.data.notFound.length} hanzi not found in database`)
}
} else {
setError(result.message || "Failed to parse hanzi list")
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setLoading(false)
}
}
const handleCreateEmpty = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError("Name is required")
return
}
setLoading(true)
setError(null)
try {
const result = await createCollection(name, description || undefined, isPublic)
if (result.success && result.data) {
router.push(`/collections/${result.data.id}`)
} else {
setError(result.message || "Failed to create collection")
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setLoading(false)
}
}
const handleCreateWithHanzi = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError("Name is required")
return
}
if (!hanziList.trim()) {
setError("Hanzi list is required")
return
}
if (!parseResult || !parseResult.valid) {
setError("Please preview and fix any errors in the hanzi list first")
return
}
setLoading(true)
setError(null)
try {
const result = await createCollectionWithHanzi(
name,
description || undefined,
isPublic,
hanziList
)
if (result.success && result.data) {
router.push(`/collections/${result.data.id}`)
} else {
setError(result.message || "Failed to create collection")
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setLoading(false)
}
}
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="/collections"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Collections
</Link>
</div>
</div>
</div>
</nav>
<main className="max-w-4xl 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">
Create Collection
</h2>
<p className="text-gray-600 dark:text-gray-400">
Create a new collection to organize your hanzi
</p>
</div>
{/* Tabs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex">
<button
onClick={() => setActiveTab("empty")}
className={`flex-1 py-4 px-6 text-center font-medium ${
activeTab === "empty"
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Empty Collection
</button>
<button
onClick={() => setActiveTab("fromList")}
className={`flex-1 py-4 px-6 text-center font-medium ${
activeTab === "fromList"
? "border-b-2 border-blue-600 text-blue-600 dark:text-blue-400"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
From List
</button>
</div>
</div>
<div className="p-6">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<p className="font-bold">Error</p>
<p>{error}</p>
</div>
)}
{activeTab === "empty" ? (
<form onSubmit={handleCreateEmpty} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Collection Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., My First 100 Characters"
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}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this collection..."
rows={3}
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="flex items-center">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="mr-2"
disabled={loading}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Make this collection public
</span>
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
Public collections can be viewed by other users
</p>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? "Creating..." : "Create Collection"}
</button>
<Link
href="/collections"
className="bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</Link>
</div>
</form>
) : (
<form onSubmit={handleCreateWithHanzi} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Collection Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., HSK 1 Vocabulary"
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}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this collection..."
rows={2}
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">
Hanzi List *
</label>
<textarea
value={hanziList}
onChange={(e) => {
setHanziList(e.target.value)
setParseResult(null)
setError(null)
}}
placeholder="Paste hanzi here (newline, comma, or space separated)&#10;Example: 好 爱 你&#10;or: 好, 爱, 你&#10;or: 好&#10;爱&#10;你"
rows={6}
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 font-mono text-sm"
disabled={loading}
required
/>
<button
type="button"
onClick={handleParseList}
disabled={loading || !hanziList.trim()}
className="mt-2 bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? "Parsing..." : "Preview"}
</button>
</div>
{parseResult && (
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
<h4 className="font-semibold mb-3 text-gray-900 dark:text-white">
Preview Results
</h4>
{parseResult.found.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
Found: {parseResult.found.length} hanzi
</p>
<div className="flex flex-wrap gap-2">
{parseResult.found.slice(0, 20).map((h) => (
<span
key={h.id}
className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded text-sm"
>
{h.simplified} {h.pinyin ? `(${h.pinyin})` : ""}
</span>
))}
{parseResult.found.length > 20 && (
<span className="text-gray-600 dark:text-gray-400 text-sm">
... and {parseResult.found.length - 20} more
</span>
)}
</div>
</div>
)}
{parseResult.notFound.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
Not found: {parseResult.notFound.length} hanzi
</p>
<div className="flex flex-wrap gap-2">
{parseResult.notFound.map((char, i) => (
<span
key={i}
className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-1 rounded text-sm"
>
{char}
</span>
))}
</div>
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
All hanzi must exist in the database. Please remove or correct these.
</p>
</div>
)}
{parseResult.duplicates.length > 0 && (
<div>
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400 mb-2">
Duplicates in input: {parseResult.duplicates.length}
</p>
<div className="flex flex-wrap gap-2">
{parseResult.duplicates.map((char, i) => (
<span
key={i}
className="bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded text-sm"
>
{char}
</span>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="mr-2"
disabled={loading}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Make this collection public
</span>
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
Public collections can be viewed by other users
</p>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading || !parseResult || !parseResult.valid}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? "Creating..." : `Create with ${parseResult?.found.length || 0} Hanzi`}
</button>
<Link
href="/collections"
className="bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</Link>
</div>
</form>
)}
</div>
</div>
{/* Help Section */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6">
<h3 className="font-semibold text-blue-900 dark:text-blue-300 mb-2">Tips</h3>
<ul className="text-sm text-blue-800 dark:text-blue-400 space-y-1 list-disc list-inside">
<li>
<strong>Empty Collection:</strong> Create an empty collection and add hanzi later
</li>
<li>
<strong>From List:</strong> Create a collection with hanzi from a pasted list
</li>
<li>Supports both single characters () and multi-character words ()</li>
<li>List can be newline, comma, or space separated</li>
<li>All hanzi must exist in the database (use import to add new hanzi first)</li>
</ul>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { getUserCollections, getGlobalCollections } from "@/actions/collections"
import Link from "next/link"
export default async function CollectionsPage() {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
const [userCollectionsResult, globalCollectionsResult] = await Promise.all([
getUserCollections(),
getGlobalCollections(),
])
const userCollections = userCollectionsResult.success ? userCollectionsResult.data || [] : []
const globalCollections = globalCollectionsResult.success
? globalCollectionsResult.data || []
: []
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="/collections"
className="text-sm text-gray-900 dark:text-gray-200 font-medium"
>
Collections
</Link>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Collections</h2>
<p className="text-gray-600 dark:text-gray-400">
Organize your hanzi into collections for learning
</p>
</div>
<Link
href="/collections/new"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Create Collection
</Link>
</div>
{/* User Collections */}
<section className="mb-12">
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
My Collections
</h3>
{userCollections.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
You don't have any collections yet.
</p>
<Link
href="/collections/new"
className="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Create Your First Collection
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{userCollections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.id}`}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
>
<h4 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{collection.name}
</h4>
{collection.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{collection.description}
</p>
)}
<div className="flex justify-between items-center">
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{collection.hanziCount}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{collection.isPublic ? "Public" : "Private"}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Created {new Date(collection.createdAt).toLocaleDateString()}
</p>
</Link>
))}
</div>
)}
</section>
{/* Global Collections (HSK) */}
<section>
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
HSK Collections
</h3>
{globalCollections.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">
No HSK collections available yet. Ask an admin to create them.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{globalCollections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.id}`}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-lg transition-shadow border-2 border-green-200 dark:border-green-700"
>
<div className="flex items-center mb-2">
<h4 className="text-xl font-semibold text-gray-900 dark:text-white">
{collection.name}
</h4>
<span className="ml-2 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
HSK
</span>
</div>
{collection.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{collection.description}
</p>
)}
<div className="flex justify-between items-center">
<span className="text-2xl font-bold text-green-600 dark:text-green-400">
{collection.hanziCount}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">hanzi</span>
</div>
</Link>
))}
</div>
)}
</section>
</main>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { logout } from '@/actions/auth'
import Link from 'next/link'
async function logoutAction() {
'use server'
await logout()
redirect('/login')
}
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const user = session.user as any
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">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
MemoHanzi <span className="text-sm font-normal text-gray-500"></span>
</h1>
<Link
href="/collections"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Collections
</Link>
<Link
href="/hanzi"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Search Hanzi
</Link>
<Link
href="/settings"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Settings
</Link>
{(user.role === 'ADMIN' || user.role === 'MODERATOR') && (
<Link
href="/admin/import"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Import
</Link>
)}
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 dark:text-gray-300">
{user.name || user.email}
</span>
<form action={logoutAction}>
<button
type="submit"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Sign Out
</button>
</form>
</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">
Welcome back{user.name ? `, ${user.name}` : ''}!
</h2>
<p className="text-gray-600 dark:text-gray-400">
Start learning Chinese characters with spaced repetition
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Due Cards
</h3>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
0
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
No cards due right now
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Total Learned
</h3>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
0
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Characters mastered
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Daily Goal
</h3>
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
0/50
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Cards reviewed today
</p>
</div>
</div>
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-300 mb-2">
Quick Actions
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link
href="/collections"
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
>
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
Browse Collections
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
View and manage your hanzi collections
</p>
</Link>
<Link
href="/collections/new"
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
>
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
Create Collection
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Start a new hanzi collection
</p>
</Link>
<Link
href="/hanzi"
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
>
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
Search Hanzi
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Find hanzi by character, pinyin, or meaning
</p>
</Link>
</div>
<p className="text-sm text-blue-800 dark:text-blue-400 mt-4">
More features coming soon: Learning sessions, progress tracking, and more!
</p>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,368 @@
"use client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { getHanzi } from "@/actions/hanzi"
import { getUserCollections } from "@/actions/collections"
type HanziDetail = {
id: string
simplified: string
radical: string | null
frequency: number | null
forms: Array<{
id: string
traditional: string
isDefault: boolean
transcriptions: Array<{
type: string
value: string
}>
meanings: Array<{
language: string
meaning: string
orderIndex: number
}>
classifiers: Array<{
classifier: string
}>
}>
hskLevels: Array<{
level: string
}>
partsOfSpeech: Array<{
pos: string
}>
}
export default function HanziDetailPage() {
const params = useParams()
const router = useRouter()
const hanziId = params.id as string
const [hanzi, setHanzi] = useState<HanziDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Add to collection state
const [showAddModal, setShowAddModal] = useState(false)
const [collections, setCollections] = useState<Array<{ id: string; name: string }>>([])
useEffect(() => {
loadHanzi()
}, [hanziId])
const loadHanzi = async () => {
setLoading(true)
setError(null)
try {
const result = await getHanzi(hanziId)
if (result.success && result.data) {
setHanzi(result.data)
} else {
setError(result.message || "Failed to load hanzi")
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred")
} finally {
setLoading(false)
}
}
const handleAddToCollection = async () => {
const result = await getUserCollections()
if (result.success && result.data) {
setCollections(result.data)
setShowAddModal(true)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
)
}
if (error || !hanzi) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 dark:text-red-400 mb-4">{error || "Hanzi not found"}</p>
<Link href="/hanzi" className="text-blue-600 hover:underline">
Back to Search
</Link>
</div>
</div>
)
}
const defaultForm = hanzi.forms.find((f) => f.isDefault) || hanzi.forms[0]
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-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Search Hanzi
</Link>
</div>
</div>
</div>
</nav>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 mb-8">
<div className="flex items-start justify-between mb-6">
<div>
<div className="text-8xl font-bold text-gray-900 dark:text-white mb-4">
{hanzi.simplified}
</div>
{defaultForm && defaultForm.traditional !== hanzi.simplified && (
<p className="text-2xl text-gray-600 dark:text-gray-400 mb-2">
Traditional: {defaultForm.traditional}
</p>
)}
</div>
<div className="flex flex-col gap-2">
{hanzi.hskLevels.map((level) => (
<span
key={level.level}
className="text-sm bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-3 py-1 rounded text-center"
>
{level.level.toUpperCase()}
</span>
))}
</div>
</div>
<button
onClick={handleAddToCollection}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Add to Collection
</button>
</div>
{/* Main Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
{/* Transcriptions */}
{defaultForm && defaultForm.transcriptions.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Transcriptions
</h3>
<div className="space-y-2">
{defaultForm.transcriptions.map((trans, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
{trans.type}:
</span>
<span className="text-lg font-medium text-gray-900 dark:text-white">
{trans.value}
</span>
</div>
))}
</div>
</div>
)}
{/* Meanings */}
{defaultForm && defaultForm.meanings.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Meanings
</h3>
<ol className="list-decimal list-inside space-y-2">
{defaultForm.meanings.map((meaning, index) => (
<li key={index} className="text-gray-900 dark:text-white">
{meaning.meaning}
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
({meaning.language})
</span>
</li>
))}
</ol>
</div>
)}
</div>
{/* Additional Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Radical & Frequency */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Character Info
</h3>
<div className="space-y-2">
{hanzi.radical && (
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Radical:</span>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{hanzi.radical}
</p>
</div>
)}
{hanzi.frequency !== null && (
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Frequency:</span>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{hanzi.frequency}
</p>
</div>
)}
</div>
</div>
{/* Parts of Speech */}
{hanzi.partsOfSpeech.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Parts of Speech
</h3>
<div className="flex flex-wrap gap-2">
{hanzi.partsOfSpeech.map((pos, index) => (
<span
key={index}
className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-3 py-1 rounded text-sm"
>
{pos.pos}
</span>
))}
</div>
</div>
)}
{/* Classifiers */}
{defaultForm && defaultForm.classifiers.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Classifiers
</h3>
<div className="flex flex-wrap gap-2">
{defaultForm.classifiers.map((classifier, index) => (
<span
key={index}
className="bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 px-3 py-1 rounded text-lg"
>
{classifier.classifier}
</span>
))}
</div>
</div>
)}
</div>
{/* All Forms */}
{hanzi.forms.length > 1 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
All Forms
</h3>
<div className="space-y-6">
{hanzi.forms.map((form, index) => (
<div key={form.id} className="border-b border-gray-200 dark:border-gray-700 pb-6 last:border-0 last:pb-0">
<div className="flex items-center gap-2 mb-3">
<h4 className="text-2xl font-bold text-gray-900 dark:text-white">
{form.traditional}
</h4>
{form.isDefault && (
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded">
Default
</span>
)}
</div>
{form.transcriptions.length > 0 && (
<div className="mb-3">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Transcriptions:</p>
<div className="flex flex-wrap gap-2">
{form.transcriptions.map((trans, i) => (
<span key={i} className="text-sm text-gray-900 dark:text-white">
{trans.type}: <strong>{trans.value}</strong>
</span>
))}
</div>
</div>
)}
{form.meanings.length > 0 && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Meanings:</p>
<ol className="list-decimal list-inside text-sm">
{form.meanings.map((meaning, i) => (
<li key={i} className="text-gray-900 dark:text-white">
{meaning.meaning}
</li>
))}
</ol>
</div>
)}
</div>
))}
</div>
</div>
)}
</main>
{/* Add to Collection Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
Add to Collection
</h3>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
×
</button>
</div>
{collections.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400 mb-4">
You don't have any collections yet.
</p>
<Link
href="/collections/new"
className="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Create Collection
</Link>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{collections.map((collection) => (
<button
key={collection.id}
onClick={() => router.push(`/collections/${collection.id}`)}
className="w-full text-left p-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-900 dark:text-white"
>
{collection.name}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,283 @@
"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>
)
}

View File

@@ -0,0 +1,71 @@
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getPreferences, getAvailableLanguages } from '@/actions/preferences'
import SettingsForm from './settings-form'
export default async function SettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const user = session.user as any
const preferencesResult = await getPreferences()
const languagesResult = await getAvailableLanguages()
if (!preferencesResult.success || !languagesResult.success) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 dark:text-red-400">Error loading preferences</p>
</div>
</div>
)
}
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">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
MemoHanzi <span className="text-sm font-normal text-gray-500"></span>
</h1>
<Link
href="/dashboard"
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Dashboard
</Link>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 dark:text-gray-300">
{user.name || user.email}
</span>
</div>
</div>
</div>
</nav>
<main className="max-w-4xl 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">
Settings
</h2>
<p className="text-gray-600 dark:text-gray-400">
Manage your account and learning preferences
</p>
</div>
<SettingsForm
user={user}
preferences={preferencesResult.data!}
languages={languagesResult.data!}
/>
</main>
</div>
)
}

View File

@@ -0,0 +1,284 @@
'use client'
import { useState } from 'react'
import { updateProfile, updatePassword } from '@/actions/auth'
import { updatePreferences, type UserPreferences, type Language } from '@/actions/preferences'
type SettingsFormProps = {
user: { id: string; name: string | null; email: string }
preferences: UserPreferences
languages: Language[]
}
export default function SettingsForm({ user, preferences, languages }: SettingsFormProps) {
// Profile state
const [name, setName] = useState(user.name || '')
const [email, setEmail] = useState(user.email)
const [profileMessage, setProfileMessage] = useState('')
const [profileLoading, setProfileLoading] = useState(false)
// Password state
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [passwordMessage, setPasswordMessage] = useState('')
const [passwordLoading, setPasswordLoading] = useState(false)
// Preferences state
const [prefs, setPrefs] = useState(preferences)
const [prefsMessage, setPrefsMessage] = useState('')
const [prefsLoading, setPrefsLoading] = useState(false)
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setProfileMessage('')
setProfileLoading(true)
try {
const result = await updateProfile(name, email)
setProfileMessage(result.message || (result.success ? 'Profile updated' : 'Update failed'))
} catch (err) {
setProfileMessage('An error occurred')
} finally {
setProfileLoading(false)
}
}
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setPasswordMessage('')
setPasswordLoading(true)
try {
const result = await updatePassword(currentPassword, newPassword)
if (result.success) {
setCurrentPassword('')
setNewPassword('')
}
setPasswordMessage(result.message || (result.success ? 'Password updated' : 'Update failed'))
} catch (err) {
setPasswordMessage('An error occurred')
} finally {
setPasswordLoading(false)
}
}
const handlePreferencesSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setPrefsMessage('')
setPrefsLoading(true)
try {
const result = await updatePreferences(prefs)
setPrefsMessage(result.message || (result.success ? 'Preferences updated' : 'Update failed'))
} catch (err) {
setPrefsMessage('An error occurred')
} finally {
setPrefsLoading(false)
}
}
return (
<div className="space-y-8">
{/* Profile Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Profile
</h3>
<form onSubmit={handleProfileSubmit} className="space-y-4">
{profileMessage && (
<div className={`px-4 py-3 rounded ${
profileMessage.includes('success') || profileMessage.includes('updated')
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
}`}>
{profileMessage}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
/>
</div>
<button
type="submit"
disabled={profileLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{profileLoading ? 'Saving...' : 'Save Profile'}
</button>
</form>
</div>
{/* Password Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Change Password
</h3>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
{passwordMessage && (
<div className={`px-4 py-3 rounded ${
passwordMessage.includes('success') || passwordMessage.includes('updated')
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
}`}>
{passwordMessage}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
/>
</div>
<button
type="submit"
disabled={passwordLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{passwordLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
{/* Learning Preferences Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Learning Preferences
</h3>
<form onSubmit={handlePreferencesSubmit} className="space-y-4">
{prefsMessage && (
<div className={`px-4 py-3 rounded ${
prefsMessage.includes('success') || prefsMessage.includes('updated')
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400'
}`}>
{prefsMessage}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preferred Language
</label>
<select
value={prefs.preferredLanguageId}
onChange={(e) => setPrefs({ ...prefs, preferredLanguageId: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
>
{languages.map((lang) => (
<option key={lang.id} value={lang.id}>
{lang.name} ({lang.nativeName})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Character Display
</label>
<select
value={prefs.characterDisplay}
onChange={(e) => setPrefs({ ...prefs, characterDisplay: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
>
<option value="SIMPLIFIED">Simplified</option>
<option value="TRADITIONAL">Traditional</option>
<option value="BOTH">Both</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cards Per Session: {prefs.cardsPerSession}
</label>
<input
type="range"
min="5"
max="100"
value={prefs.cardsPerSession}
onChange={(e) => setPrefs({ ...prefs, cardsPerSession: parseInt(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Daily Goal: {prefs.dailyGoal} cards
</label>
<input
type="range"
min="10"
max="500"
step="10"
value={prefs.dailyGoal}
onChange={(e) => setPrefs({ ...prefs, dailyGoal: parseInt(e.target.value) })}
className="w-full"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="allowManualDifficulty"
checked={prefs.allowManualDifficulty}
onChange={(e) => setPrefs({ ...prefs, allowManualDifficulty: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="allowManualDifficulty" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Allow manual difficulty adjustment
</label>
</div>
<button
type="submit"
disabled={prefsLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{prefsLoading ? 'Saving...' : 'Save Preferences'}
</button>
</form>
</div>
</div>
)
}