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

702 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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