NotebooksView.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import React, { useMemo } from 'react'
  2. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  3. import { KnowledgeGroup, UpdateGroupData, CreateGroupData } from '../../types'
  4. import { Plus, Book, Library, MessageSquare, Trash2, Edit2, FolderInput, ChevronLeft, ChevronRight } from 'lucide-react'
  5. import { motion, AnimatePresence } from 'framer-motion'
  6. import { NotebookDetailView } from './NotebookDetailView'
  7. import { CreateNotebookDrawer } from '../CreateNotebookDrawer'
  8. import { EditNotebookDrawer } from '../EditNotebookDrawer'
  9. import { ImportFolderDrawer } from '../ImportFolderDrawer'
  10. import { useLanguage } from '../../contexts/LanguageContext'
  11. import { useToast } from '../../contexts/ToastContext'
  12. import { useConfirm } from '../../contexts/ConfirmContext'
  13. interface NotebooksViewProps {
  14. authToken: string
  15. onChatWithContext: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void
  16. isAdmin?: boolean
  17. }
  18. /** Flatten a tree of groups into a flat list */
  19. function flattenGroups(groups: KnowledgeGroup[]): KnowledgeGroup[] {
  20. const result: KnowledgeGroup[] = [];
  21. function walk(items: KnowledgeGroup[]) {
  22. for (const g of items) {
  23. result.push(g);
  24. if (g.children?.length) walk(g.children);
  25. }
  26. }
  27. walk(groups);
  28. return result;
  29. }
  30. const PAGE_SIZE = 12;
  31. export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatWithContext, isAdmin = false }) => {
  32. const { t } = useLanguage()
  33. const { showError } = useToast()
  34. const { confirm } = useConfirm()
  35. const [notebooks, setNotebooks] = React.useState<KnowledgeGroup[]>([])
  36. const [isLoading, setIsLoading] = React.useState(true)
  37. const [selectedNotebook, setSelectedNotebook] = React.useState<KnowledgeGroup | null>(null)
  38. const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false)
  39. const [isImportDrawerOpen, setIsImportDrawerOpen] = React.useState(false)
  40. const [editingNotebook, setEditingNotebook] = React.useState<KnowledgeGroup | null>(null)
  41. const [currentPage, setCurrentPage] = React.useState(1)
  42. // Flatten tree for display in the grid
  43. const flatNotebooks = useMemo(() => flattenGroups(notebooks), [notebooks])
  44. const totalPages = Math.ceil(flatNotebooks.length / PAGE_SIZE)
  45. const paginatedNotebooks = useMemo(() => {
  46. const start = (currentPage - 1) * PAGE_SIZE;
  47. return flatNotebooks.slice(start, start + PAGE_SIZE);
  48. }, [flatNotebooks, currentPage])
  49. const fetchNotebooks = async () => {
  50. try {
  51. const result = await knowledgeGroupService.getGroups()
  52. // result can be an array (tree/list) or an object (paginated flat list)
  53. if (Array.isArray(result)) {
  54. setNotebooks(result)
  55. } else if (result && result.items) {
  56. setNotebooks(result.items)
  57. } else {
  58. setNotebooks([])
  59. }
  60. } catch (error) {
  61. console.error(error)
  62. } finally {
  63. setIsLoading(false)
  64. }
  65. }
  66. React.useEffect(() => {
  67. fetchNotebooks()
  68. }, [authToken, selectedNotebook])
  69. const handleCreateNotebook = async (data: CreateGroupData) => {
  70. try {
  71. setIsLoading(true)
  72. await knowledgeGroupService.createGroup(data)
  73. await fetchNotebooks()
  74. setIsCreateDrawerOpen(false)
  75. } catch (error) {
  76. console.error(error)
  77. showError(t('createFailed'))
  78. } finally {
  79. setIsLoading(false)
  80. }
  81. }
  82. const handleUpdateNotebook = async (id: string, data: UpdateGroupData) => {
  83. await knowledgeGroupService.updateGroup(id, data)
  84. await fetchNotebooks()
  85. }
  86. const handleDeleteNotebook = async (e: React.MouseEvent, id: string, name: string) => {
  87. e.stopPropagation()
  88. if (!(await confirm(t('confirmDeleteNotebook').replace('$1', name)))) return
  89. try {
  90. setIsLoading(true)
  91. await knowledgeGroupService.deleteGroup(id)
  92. setNotebooks(prev => flattenGroups(prev).filter(n => n.id !== id) as any)
  93. await fetchNotebooks()
  94. } catch (error) {
  95. console.error(error)
  96. showError(t('deleteFailed'))
  97. } finally {
  98. setIsLoading(false)
  99. }
  100. }
  101. if (selectedNotebook) {
  102. return (
  103. <NotebookDetailView
  104. authToken={authToken}
  105. notebook={selectedNotebook}
  106. onBack={() => setSelectedNotebook(null)}
  107. onChatWithContext={onChatWithContext}
  108. isAdmin={!!isAdmin}
  109. />
  110. )
  111. }
  112. return (
  113. <div className="flex flex-col h-full bg-transparent overflow-hidden">
  114. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  115. <div>
  116. <h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navKnowledgeGroups')}</h1>
  117. <p className="text-[15px] text-slate-500 mt-1">{t('notebooksDesc')}</p>
  118. </div>
  119. <div className="flex items-center gap-3">
  120. {isAdmin && (
  121. <button
  122. onClick={() => setIsImportDrawerOpen(true)}
  123. className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 text-sm font-semibold rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 shadow-sm"
  124. >
  125. <FolderInput size={18} className="text-blue-600" />
  126. <span>{t('importFolder')}</span>
  127. </button>
  128. )}
  129. {isAdmin && (
  130. <button
  131. onClick={() => setIsCreateDrawerOpen(true)}
  132. className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
  133. >
  134. <Plus size={18} />
  135. <span>{t('newGroup')}</span>
  136. </button>
  137. )}
  138. </div>
  139. </div>
  140. <div className="px-8 flex-1 overflow-y-auto pb-4">
  141. {isLoading ? (
  142. <div className="flex h-64 items-center justify-center">
  143. <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  144. </div>
  145. ) : flatNotebooks.length === 0 ? (
  146. <div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
  147. <Library className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  148. <h3 className="text-slate-900 font-bold">{t('noKnowledgeGroups')}</h3>
  149. <p className="text-slate-500 text-sm mt-1">{t('createGroupDesc')}</p>
  150. {isAdmin && (
  151. <button
  152. onClick={() => setIsCreateDrawerOpen(true)}
  153. className="mt-6 px-5 py-2 bg-blue-600 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 transition-all shadow-sm"
  154. >
  155. {t('createNotebook')}
  156. </button>
  157. )}
  158. </div>
  159. ) : (
  160. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
  161. <AnimatePresence>
  162. {paginatedNotebooks.map((notebook) => (
  163. <motion.div
  164. key={notebook.id}
  165. layout
  166. initial={{ opacity: 0, y: 10 }}
  167. animate={{ opacity: 1, y: 0 }}
  168. onClick={() => setSelectedNotebook(notebook)}
  169. className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-64 cursor-pointer"
  170. >
  171. <div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
  172. <button
  173. onClick={(e) => {
  174. e.stopPropagation()
  175. onChatWithContext({ selectedGroups: [notebook.id] })
  176. }}
  177. className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
  178. >
  179. <MessageSquare size={16} />
  180. </button>
  181. {isAdmin && (
  182. <button
  183. onClick={(e) => {
  184. e.stopPropagation()
  185. setEditingNotebook(notebook)
  186. }}
  187. className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
  188. >
  189. <Edit2 size={16} />
  190. </button>
  191. )}
  192. {isAdmin && (
  193. <button
  194. onClick={(e) => handleDeleteNotebook(e, notebook.id, notebook.name)}
  195. className="p-1.5 text-slate-400 hover:text-red-500 rounded-md"
  196. >
  197. <Trash2 size={16} />
  198. </button>
  199. )}
  200. </div>
  201. <div className="flex-1">
  202. <div className="w-11 h-11 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 flex items-center justify-center mb-4 transition-transform group-hover:scale-105">
  203. <Book size={20} />
  204. </div>
  205. <h3 className="font-bold text-slate-900 text-[16px] mb-1 leading-tight group-hover:text-blue-600 transition-colors truncate">
  206. {notebook.parentId && <span className="text-slate-300 text-xs mr-1">↳</span>}
  207. {notebook.name}
  208. </h3>
  209. <p className="text-[13px] text-slate-500 leading-relaxed line-clamp-3 italic opacity-85">
  210. {notebook.description || t('noDescriptionProvided')}
  211. </p>
  212. </div>
  213. <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
  214. <div className="flex items-center gap-2 px-2.5 py-1 bg-slate-50 border border-slate-100 rounded-md">
  215. <span className="text-[11px] font-bold text-slate-700">{notebook.fileCount || 0}</span>
  216. <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-tight">{t('files')}</span>
  217. </div>
  218. <span className="text-[11px] font-medium text-slate-300">
  219. {notebook.updatedAt ? new Date(notebook.updatedAt).toLocaleDateString() : ''}
  220. </span>
  221. </div>
  222. </motion.div>
  223. ))}
  224. </AnimatePresence>
  225. </div>
  226. )}
  227. </div>
  228. {/* Pagination: always show when there are notebooks */}
  229. {flatNotebooks.length > 0 && (
  230. <div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
  231. <button
  232. onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
  233. disabled={currentPage === 1}
  234. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
  235. >
  236. <ChevronLeft size={16} />
  237. {t('previous')}
  238. </button>
  239. <div className="px-3 py-2 text-sm font-semibold text-slate-700">
  240. {t('showingRange', (currentPage - 1) * PAGE_SIZE + 1, Math.min(currentPage * PAGE_SIZE, flatNotebooks.length), flatNotebooks.length)}
  241. </div>
  242. <button
  243. onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
  244. disabled={currentPage === totalPages || totalPages === 0}
  245. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
  246. >
  247. {t('next')}
  248. <ChevronRight size={16} />
  249. </button>
  250. </div>
  251. )}
  252. {isCreateDrawerOpen && (
  253. <CreateNotebookDrawer
  254. isOpen={isCreateDrawerOpen}
  255. onClose={() => setIsCreateDrawerOpen(false)}
  256. onCreate={handleCreateNotebook}
  257. />
  258. )}
  259. {editingNotebook && (
  260. <EditNotebookDrawer
  261. isOpen={!!editingNotebook}
  262. onClose={() => setEditingNotebook(null)}
  263. notebook={editingNotebook}
  264. onUpdate={handleUpdateNotebook}
  265. />
  266. )}
  267. <ImportFolderDrawer
  268. isOpen={isImportDrawerOpen}
  269. onClose={() => setIsImportDrawerOpen(false)}
  270. authToken={authToken}
  271. onImportSuccess={fetchNotebooks}
  272. />
  273. </div>
  274. )
  275. }