DB, Collections, Search
This commit is contained in:
701
src/app/(app)/collections/[id]/page.tsx
Normal file
701
src/app/(app)/collections/[id]/page.tsx
Normal 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) Example: 好 爱 你 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>
|
||||
)
|
||||
}
|
||||
439
src/app/(app)/collections/new/page.tsx
Normal file
439
src/app/(app)/collections/new/page.tsx
Normal 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) Example: 好 爱 你 or: 好, 爱, 你 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"
|
||||
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>
|
||||
)
|
||||
}
|
||||
159
src/app/(app)/collections/page.tsx
Normal file
159
src/app/(app)/collections/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
src/app/(app)/dashboard/page.tsx
Normal file
168
src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
368
src/app/(app)/hanzi/[id]/page.tsx
Normal file
368
src/app/(app)/hanzi/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
src/app/(app)/hanzi/page.tsx
Normal file
283
src/app/(app)/hanzi/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
src/app/(app)/settings/page.tsx
Normal file
71
src/app/(app)/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
284
src/app/(app)/settings/settings-form.tsx
Normal file
284
src/app/(app)/settings/settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user