NotebookDetailView.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. import React, { useEffect, useState, useRef } from 'react'
  2. import { ArrowLeft, Plus, FileText, File as FileIcon, MoreVertical, Trash2, Edit2, MessageSquare, Eye, EyeOff, Database, Book } from 'lucide-react'
  3. import ReactMarkdown from 'react-markdown'
  4. import { KnowledgeGroup, KnowledgeFile } from '../../types'
  5. import { noteService, Note } from '../../services/noteService'
  6. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  7. import { modelConfigService } from '../../services/modelConfigService'
  8. import { uploadService } from '../../services/uploadService'
  9. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  10. import { useToast } from '../../contexts/ToastContext'
  11. import { useConfirm } from '../../contexts/ConfirmContext'
  12. import { ModelType, RawFile, IndexingConfig, ModelConfig } from '../../types'
  13. import IndexingModalWithMode from '../IndexingModalWithMode'
  14. import { PDFPreview, PDFPreviewButton } from '../PDFPreview'
  15. import { AICommandDrawer } from '../AICommandDrawer'
  16. import { ImportFolderDrawer } from '../ImportFolderDrawer'
  17. import { NotebookDragDropUpload } from '../NotebookDragDropUpload'
  18. import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
  19. import { useLanguage } from '../../contexts/LanguageContext'
  20. import { readFile, formatBytes } from '../../utils/fileUtils'
  21. import { Sparkles } from 'lucide-react'
  22. import remarkGfm from 'remark-gfm'
  23. import remarkMath from 'remark-math'
  24. import rehypeKatex from 'rehype-katex'
  25. import mermaid from 'mermaid'
  26. import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
  27. import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
  28. import { Info } from 'lucide-react'
  29. import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, getSupportedFormatsLabel } from '../../constants/fileSupport'
  30. // mermaid の初期化
  31. mermaid.initialize({
  32. startOnLoad: false,
  33. theme: 'default',
  34. securityLevel: 'loose',
  35. })
  36. const Mermaid = ({ chart }: { chart: string }) => {
  37. const [svg, setSvg] = useState('')
  38. const [error, setError] = useState('')
  39. const { t } = useLanguage()
  40. useEffect(() => {
  41. const renderChart = async () => {
  42. try {
  43. const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
  44. const { svg } = await mermaid.render(id, chart)
  45. setSvg(svg)
  46. setError('')
  47. } catch (err) {
  48. console.error('Mermaid render error:', err)
  49. setError(t('errorRenderFlowchart'))
  50. }
  51. }
  52. if (chart) renderChart()
  53. }, [chart])
  54. if (error) return <div className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</div>
  55. return <div className="mermaid-chart my-4 flex justify-center" dangerouslySetInnerHTML={{ __html: svg }} />
  56. }
  57. interface NotebookDetailViewProps {
  58. authToken: string;
  59. notebook: KnowledgeGroup;
  60. onBack: () => void;
  61. onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
  62. isAdmin?: boolean;
  63. }
  64. export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
  65. const [activeTab, setActiveTab] = useState<'files' | 'notes'>('files')
  66. const [files, setFiles] = useState<KnowledgeFile[]>([])
  67. const [notes, setNotes] = useState<Note[]>([])
  68. const [isLoading, setIsLoading] = useState(false)
  69. const { showError, showSuccess } = useToast()
  70. const { confirm } = useConfirm()
  71. const { t, language } = useLanguage()
  72. // メモエディタの状態
  73. const [isEditingNote, setIsEditingNote] = useState(false)
  74. const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
  75. const [showPreview, setShowPreview] = useState(true)
  76. const [isUploading, setIsUploading] = useState(false)
  77. const fileInputRef = React.useRef<HTMLInputElement>(null)
  78. const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false)
  79. // インデックスモーダルの状態
  80. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  81. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  82. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  83. const [models, setModels] = useState<ModelConfig[]>([])
  84. // PDFプレビューの状態
  85. const [previewFile, setPreviewFile] = useState<{ id: string, name: string } | null>(null);
  86. // AIアシスタントの状態
  87. const [isAIModalOpen, setIsAIModalOpen] = useState(false)
  88. useEffect(() => {
  89. // モーダル用のモデルを取得
  90. const fetchModels = async () => {
  91. try {
  92. const res = await modelConfigService.getAll(authToken)
  93. setModels(res)
  94. } catch (error) {
  95. console.error('Failed to fetch models', error)
  96. }
  97. }
  98. if (authToken) fetchModels()
  99. }, [authToken])
  100. // Effect to open modal when pending files are set
  101. useEffect(() => {
  102. if (shouldOpenModal && pendingFiles.length > 0) {
  103. setIsIndexingModalOpen(true);
  104. setShouldOpenModal(false);
  105. }
  106. }, [shouldOpenModal, pendingFiles.length]);
  107. const loadData = async () => {
  108. setIsLoading(true)
  109. try {
  110. const [allFiles, notebookNotes] = await Promise.all([
  111. knowledgeBaseService.getAll(authToken),
  112. noteService.getAll(authToken, notebook.id)
  113. ])
  114. const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
  115. setFiles(notebookFiles)
  116. setNotes(notebookNotes)
  117. } catch (error) {
  118. console.error(error)
  119. showError(t('errorLoadData'))
  120. } finally {
  121. setIsLoading(false)
  122. }
  123. }
  124. useEffect(() => {
  125. loadData()
  126. }, [authToken, notebook.id, activeTab])
  127. const handleFileUpload = async (fileList: FileList | File[]) => {
  128. if (!fileList || fileList.length === 0) return
  129. const MAX_FILE_SIZE = 104857600; // 100MB
  130. const MAX_SIZE_MB = 100
  131. const newPendingFiles: RawFile[] = []
  132. const errors: string[] = []
  133. const filesArray = Array.from(fileList)
  134. for (let i = 0; i < filesArray.length; i++) {
  135. const file = filesArray[i]
  136. try {
  137. // Check file size
  138. if (file.size > MAX_FILE_SIZE) {
  139. errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
  140. continue
  141. }
  142. const extension = file.name.split('.').pop() || ''
  143. if (!isExtensionAllowed(extension, 'group')) {
  144. if (!(await confirm(t('confirmUnsupportedFile', extension || 'unknown')))) {
  145. continue
  146. }
  147. }
  148. // Read file
  149. const rawFile = await readFile(file)
  150. newPendingFiles.push(rawFile)
  151. } catch (error: any) {
  152. console.error(`Error processing file ${file.name}:`, error)
  153. errors.push(`${file.name} - ${t('errorReadFile', error.message || t('unknownError'))}`)
  154. }
  155. }
  156. if (errors.length > 0) {
  157. showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
  158. }
  159. if (newPendingFiles.length > 0) {
  160. setPendingFiles(prev => [...prev, ...newPendingFiles])
  161. setShouldOpenModal(true)
  162. }
  163. if (fileInputRef.current) {
  164. fileInputRef.current.value = ''
  165. }
  166. }
  167. const handleConfirmIndexing = async (config: IndexingConfig) => {
  168. setIsUploading(true)
  169. setIsIndexingModalOpen(false)
  170. try {
  171. for (const rawFile of pendingFiles) {
  172. let uploadRes;
  173. if (rawFile.isNote) {
  174. uploadRes = await uploadService.uploadText(rawFile.textContent || rawFile.content, rawFile.name, config, authToken)
  175. } else {
  176. uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  177. }
  178. if (uploadRes && uploadRes.id) {
  179. await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
  180. }
  181. }
  182. showSuccess(t('successUploadFile'))
  183. loadData()
  184. } catch (error) {
  185. console.error(error)
  186. showError(t('errorUploadFile', error.message || t('unknownError')))
  187. } finally {
  188. setIsUploading(false)
  189. setPendingFiles([])
  190. }
  191. }
  192. const handleIndexNote = async (note: Note) => {
  193. if (!note.title || !note.content) return;
  194. try {
  195. const file = new File([note.content], `${note.title}.md`, { type: 'text/markdown' });
  196. const rawFile = await readFile(file);
  197. rawFile.isNote = true;
  198. rawFile.textContent = note.content;
  199. setPendingFiles([rawFile]);
  200. setIsIndexingModalOpen(true);
  201. } catch (error) {
  202. console.error(error);
  203. showError(t('errorProcessFile'));
  204. }
  205. }
  206. const handleSaveNote = async () => {
  207. if (!currentNote.title || !currentNote.content) {
  208. showError(t('errorTitleContentRequired'))
  209. return
  210. }
  211. try {
  212. if (currentNote.id) {
  213. await noteService.update(authToken, currentNote.id, {
  214. title: currentNote.title,
  215. content: currentNote.content
  216. })
  217. showSuccess(t('successNoteUpdated'))
  218. } else {
  219. await noteService.create(authToken, {
  220. title: currentNote.title,
  221. content: currentNote.content,
  222. groupId: notebook.id
  223. })
  224. showSuccess(t('successNoteCreated'))
  225. }
  226. setIsEditingNote(false)
  227. setCurrentNote({})
  228. loadData()
  229. } catch (error: any) {
  230. console.error('Save note error:', error)
  231. showError(t('errorSaveFailed', error.message || t('unknownError')))
  232. }
  233. }
  234. const handleDeleteNote = async (id: string) => {
  235. if (!(await confirm(t('confirmDeleteNote')))) return
  236. try {
  237. await noteService.delete(authToken, id)
  238. showSuccess(t('successNoteDeleted'))
  239. loadData()
  240. } catch (error) {
  241. showError(t('deleteFailed'))
  242. }
  243. }
  244. const handleRemoveFile = async (fileId: string, fileName: string) => {
  245. if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
  246. try {
  247. const { knowledgeGroupService } = await import('../../services/knowledgeGroupService');
  248. await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
  249. showSuccess(t('fileDeleted'));
  250. loadData();
  251. } catch (error) {
  252. console.error(error);
  253. showError(t('deleteFailed'));
  254. }
  255. }
  256. // メモエディタのレンダリング
  257. if (isEditingNote) {
  258. return (
  259. <div className="flex flex-col h-full bg-slate-50">
  260. {/* エディタヘッダー */}
  261. <div className="h-14 bg-white border-b border-slate-200 flex items-center px-4 justify-between shrink-0">
  262. <div className="flex items-center gap-2">
  263. <button onClick={() => setIsEditingNote(false)} className="p-2 hover:bg-slate-100 rounded-full">
  264. <ArrowLeft size={20} className="text-slate-500" />
  265. </button>
  266. <h2 className="font-semibold text-slate-800">{currentNote.id ? t('editNote') : t('newNote')}</h2>
  267. </div>
  268. <div className="flex items-center gap-2">
  269. <button
  270. onClick={handleSaveNote}
  271. className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 shadow-sm"
  272. >
  273. {t('save')}
  274. </button>
  275. <div className="h-6 w-px bg-slate-200 mx-1"></div>
  276. <button
  277. onClick={() => setShowPreview(!showPreview)}
  278. className={`p-2 rounded-lg transition-colors ${showPreview ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
  279. title={showPreview ? t('togglePreviewClose') : t('togglePreviewOpen')}
  280. >
  281. {showPreview ? <Eye size={20} /> : <EyeOff size={20} />}
  282. </button>
  283. <button
  284. onClick={() => setIsAIModalOpen(true)}
  285. className={`p-2 rounded-lg transition-colors ${isAIModalOpen ? 'bg-purple-100 text-purple-600' : 'text-slate-400 hover:text-purple-600 hover:bg-purple-50'}`}
  286. title={t('aiAssistant')}
  287. >
  288. <Sparkles size={20} />
  289. </button>
  290. </div>
  291. </div>
  292. <div className="flex-1 overflow-hidden w-full max-w-[95%] mx-auto flex flex-col p-4">
  293. <input
  294. type="text"
  295. placeholder={t('noteTitlePlaceholder')}
  296. value={currentNote.title || ''}
  297. onChange={e => setCurrentNote(prev => ({ ...prev, title: e.target.value }))}
  298. className="w-full text-2xl font-bold border-none outline-none bg-transparent mb-4 placeholder-slate-300 shrink-0 px-2"
  299. />
  300. <div className="flex-1 flex gap-4 min-h-0">
  301. {/* エディタ */}
  302. <div className={`flex flex-col transition-all duration-300 ${showPreview ? 'w-1/2' : 'w-full'}`}>
  303. <textarea
  304. placeholder={t('noteContentPlaceholder')}
  305. value={currentNote.content || ''}
  306. onChange={e => setCurrentNote(prev => ({ ...prev, content: e.target.value }))}
  307. className="w-full h-full resize-none border border-slate-200 rounded-lg bg-white p-4 text-base leading-relaxed placeholder-slate-300 font-mono focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none shadow-sm"
  308. />
  309. </div>
  310. {/* プレビュー */}
  311. {showPreview && (
  312. <div className="w-1/2 bg-white border border-slate-200 rounded-lg p-6 overflow-y-auto shadow-sm prose prose-slate max-w-none">
  313. {currentNote.content ? (
  314. <ReactMarkdown
  315. remarkPlugins={[remarkGfm, remarkMath]}
  316. rehypePlugins={[rehypeKatex]}
  317. components={{
  318. code({ node, inline, className, children, ...props }: any) {
  319. const match = /language-(\w+)/.exec(className || '')
  320. const isMermaid = match && match[1] === 'mermaid'
  321. if (!inline && isMermaid) {
  322. return <Mermaid chart={String(children).replace(/\n$/, '')} />
  323. }
  324. return !inline && match ? (
  325. <SyntaxHighlighter
  326. style={oneLight}
  327. language={match[1]}
  328. PreTag="div"
  329. {...props}
  330. >
  331. {String(children).replace(/\n$/, '')}
  332. </SyntaxHighlighter>
  333. ) : (
  334. <code className={className} {...props}>
  335. {children}
  336. </code>
  337. )
  338. }
  339. }}
  340. >
  341. {currentNote.content}
  342. </ReactMarkdown>
  343. ) : (
  344. <div className="text-slate-300 text-center mt-10 italic">{t('markdownPreviewArea')}</div>
  345. )}
  346. </div>
  347. )}
  348. </div>
  349. </div>
  350. {/* AIコマンドドロワー - エディタ表示用 */}
  351. <AICommandDrawer
  352. isOpen={isAIModalOpen}
  353. onClose={() => setIsAIModalOpen(false)}
  354. context={currentNote.content || ''}
  355. authToken={authToken}
  356. onApply={(newContent) => {
  357. setCurrentNote(prev => ({ ...prev, content: newContent }))
  358. }}
  359. />
  360. </div>
  361. )
  362. }
  363. return (
  364. <div className="flex flex-col h-full bg-slate-50">
  365. {/* ヘッダー */}
  366. <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0 shadow-sm z-10">
  367. <div className="flex items-center gap-4">
  368. <button onClick={onBack} className="p-2 -ml-2 hover:bg-slate-100 rounded-full transition-colors" title={t('back')}>
  369. <ArrowLeft size={20} className="text-slate-500" />
  370. </button>
  371. <div>
  372. <h1 className="font-bold text-slate-800 text-xl flex items-center gap-2">
  373. <Book className="w-6 h-6 text-blue-600" />
  374. <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
  375. {notebook.name}
  376. </span>
  377. </h1>
  378. {notebook.description && <p className="text-sm text-slate-500 max-w-[300px] truncate mt-1">{notebook.description}</p>}
  379. </div>
  380. <div className="h-6 w-px bg-slate-200 mx-2"></div>
  381. <button
  382. onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
  383. className="flex items-center gap-2 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
  384. title={t('chatWithGroup')}
  385. >
  386. <MessageSquare size={18} />
  387. <span className="text-sm font-medium">{t('chatWithGroup')}</span>
  388. </button>
  389. </div>
  390. <div className="flex bg-slate-100 p-1 rounded-lg">
  391. <button
  392. onClick={() => setActiveTab('files')}
  393. className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'files'
  394. ? 'bg-white text-slate-800 shadow-sm'
  395. : 'text-slate-500 hover:text-slate-700'
  396. }`}
  397. >
  398. {t('filesCountLabel', files.length)}
  399. </button>
  400. <button
  401. onClick={() => setActiveTab('notes')}
  402. className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'notes'
  403. ? 'bg-white text-slate-800 shadow-sm'
  404. : 'text-slate-500 hover:text-slate-700'
  405. }`}
  406. >
  407. {t('notesCountLabel', notes.length)}
  408. </button>
  409. </div>
  410. </div>
  411. {/* コンテンツアクション */}
  412. <div className="p-4 flex justify-end">
  413. {activeTab === 'notes' && (
  414. <button
  415. onClick={() => {
  416. setCurrentNote({})
  417. setIsEditingNote(true)
  418. }}
  419. className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
  420. >
  421. <Plus size={16} />
  422. <span>{t('newNote')}</span>
  423. </button>
  424. )}
  425. {activeTab === 'files' && (
  426. <>
  427. {isAdmin && (
  428. <input
  429. type="file"
  430. ref={fileInputRef}
  431. className="hidden"
  432. onChange={(e) => {
  433. if (e.target.files) handleFileUpload(e.target.files)
  434. }}
  435. multiple
  436. disabled={isUploading}
  437. accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  438. />
  439. )}
  440. {isAdmin && (
  441. <button
  442. className="flex items-center gap-2 px-3 py-1.5 bg-white text-slate-600 border border-slate-200 text-sm rounded-lg hover:bg-slate-50 mr-2"
  443. onClick={() => setIsImportDrawerOpen(true)}
  444. >
  445. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" /><path d="M12 10v6" /><path d="m9 13 3-3 3 3" /></svg>
  446. <span>{t('importFolder')}</span>
  447. </button>
  448. )}
  449. {isAdmin && (
  450. <div className="flex items-center gap-2">
  451. <div className="group relative">
  452. <Info className="w-5 h-5 text-slate-400 cursor-help hover:text-blue-500 transition-colors" />
  453. <div className="absolute bottom-full right-0 mb-2 w-max bg-slate-900 text-white text-xs px-3 py-2 rounded-lg opacity-0 group-hover:opacity-100 transition-all pointer-events-none z-[100] shadow-xl border border-slate-700 whitespace-nowrap">
  454. {t('supportedFormatsInfo')}
  455. </div>
  456. </div>
  457. <button
  458. className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
  459. onClick={() => fileInputRef.current?.click()}
  460. disabled={isUploading}
  461. >
  462. <Plus size={16} />
  463. <span>{isUploading ? t('uploading') : t('addFile')}</span>
  464. </button>
  465. </div>
  466. )}
  467. </>
  468. )}
  469. </div>
  470. {/* ノートブック全域ドラッグアップロードオーバーレイ */}
  471. <NotebookGlobalDragDropOverlay
  472. onFilesSelected={handleFileUpload}
  473. isAdmin={isAdmin}
  474. />
  475. {/* リスト表示 */}
  476. <div className="flex-1 overflow-y-auto px-6 pb-6">
  477. {isLoading ? (
  478. <div className="text-center py-10 text-slate-400">Loading...</div>
  479. ) : (
  480. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  481. {activeTab === 'files' && files.map(file => (
  482. <div key={file.id} className="bg-white p-4 rounded-xl border border-slate-200 flex items-start gap-3 hover:shadow-md transition-shadow group relative">
  483. <div className="p-2 bg-slate-100 rounded-lg text-slate-500">
  484. <FileIcon size={24} />
  485. </div>
  486. <div className="flex-1 min-w-0">
  487. <h3 className="font-medium text-slate-800 truncate" title={file.title || file.name}>{file.title || file.name}</h3>
  488. <p className="text-xs text-slate-400 mt-1">{formatBytes(file.size)}</p>
  489. </div>
  490. <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
  491. <PDFPreviewButton
  492. fileId={file.id}
  493. fileName={file.name}
  494. onPreview={() => setPreviewFile({ id: file.id, name: file.name })}
  495. />
  496. <button
  497. onClick={(e) => {
  498. e.stopPropagation();
  499. onChatWithContext?.({ selectedGroups: [notebook.id], selectedFiles: [file.id] });
  500. }}
  501. className="p-1.5 text-slate-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
  502. title={t('chatWithFile')}
  503. >
  504. <MessageSquare size={16} />
  505. </button>
  506. {isAdmin && (
  507. <button
  508. onClick={(e) => {
  509. e.stopPropagation();
  510. handleRemoveFile(file.id, file.name);
  511. }}
  512. className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
  513. title={t('confirmRemoveFileFromGroup', '')}
  514. >
  515. <Trash2 size={16} />
  516. </button>
  517. )}
  518. </div>
  519. </div>
  520. ))}
  521. {activeTab === 'notes' && notes.map(note => (
  522. <div key={note.id} className="bg-white p-5 rounded-xl border border-slate-200 hover:shadow-md transition-all group flex flex-col cursor-pointer" onClick={() => {
  523. setCurrentNote(note)
  524. setIsEditingNote(true)
  525. }}>
  526. <div className="flex justify-between items-start mb-2 flex-shrink-0">
  527. <h3 className="font-bold text-slate-800 line-clamp-1">{note.title}</h3>
  528. <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
  529. {isAdmin && (
  530. <button onClick={() => handleIndexNote(note)} className="p-1 hover:bg-blue-50 text-slate-400 hover:text-blue-500 rounded" title={t('indexIntoKB')}>
  531. <Database size={16} />
  532. </button>
  533. )}
  534. <button onClick={() => handleDeleteNote(note.id)} className="p-1 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded">
  535. <Trash2 size={16} />
  536. </button>
  537. </div>
  538. </div>
  539. <p className="text-slate-500 text-sm line-clamp-3 mb-2 whitespace-pre-line flex-shrink-0">
  540. {note.content}
  541. </p>
  542. {note.screenshotPath && (
  543. <div className="mt-2 mb-2 rounded overflow-hidden border border-slate-100 flex-shrink-0 h-32">
  544. <img
  545. src={`/uploads/${note.screenshotPath}`}
  546. alt="Screenshot"
  547. className="w-full h-full object-cover"
  548. />
  549. </div>
  550. )}
  551. <div className="text-xs text-slate-400 mt-auto pt-3 border-t border-slate-100 flex-shrink-0">
  552. {new Date(note.updatedAt).toLocaleDateString()}
  553. {note.user && (
  554. <span className="ml-2 px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-medium">
  555. {note.user.username}
  556. </span>
  557. )}
  558. </div>
  559. </div>
  560. ))}
  561. {((activeTab === 'files' && files.length === 0) || (activeTab === 'notes' && notes.length === 0)) && (
  562. <div className="col-span-full">
  563. {activeTab === 'files' ? (
  564. <NotebookDragDropUpload
  565. onFilesSelected={handleFileUpload}
  566. isAdmin={isAdmin}
  567. />
  568. ) : (
  569. <div className="text-center py-20 text-slate-400">
  570. {t('noFilesOrNotes', t('notes'))}
  571. </div>
  572. )}
  573. </div>
  574. )}
  575. </div>
  576. )}
  577. </div>
  578. {/* インデックスモーダル */}
  579. <IndexingModalWithMode
  580. isOpen={isIndexingModalOpen}
  581. onClose={() => {
  582. setIsIndexingModalOpen(false)
  583. setPendingFiles([])
  584. }}
  585. onConfirm={handleConfirmIndexing}
  586. files={pendingFiles}
  587. embeddingModels={models.filter(m => m.type === ModelType.EMBEDDING)}
  588. defaultEmbeddingId={models.find(m => m.type === ModelType.EMBEDDING)?.id || ''}
  589. />
  590. {/* PDFプレビューモーダル */}
  591. {
  592. previewFile && (
  593. <PDFPreview
  594. fileId={previewFile.id}
  595. fileName={previewFile.name}
  596. authToken={authToken}
  597. groupId={notebook.id}
  598. onClose={() => setPreviewFile(null)}
  599. />
  600. )
  601. }
  602. {/* AIコマンドドロワー */}
  603. <AICommandDrawer
  604. isOpen={isAIModalOpen}
  605. onClose={() => setIsAIModalOpen(false)}
  606. context={currentNote.content || ''}
  607. authToken={authToken}
  608. onApply={(newContent) => {
  609. setCurrentNote(prev => ({ ...prev, content: newContent })) // 内容を置換(必要に応じて将来的に追加オプションを検討)
  610. }}
  611. />
  612. <ImportFolderDrawer
  613. isOpen={isImportDrawerOpen}
  614. onClose={() => setIsImportDrawerOpen(false)}
  615. authToken={authToken}
  616. initialGroupId={notebook.id}
  617. initialGroupName={notebook.name}
  618. onImportSuccess={loadData}
  619. />
  620. </div >
  621. )
  622. }