| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- import React, { useEffect, useState, useRef } from 'react'
- import { ArrowLeft, Plus, FileText, File as FileIcon, MoreVertical, Trash2, Edit2, MessageSquare, Eye, EyeOff, Database, Book } from 'lucide-react'
- import ReactMarkdown from 'react-markdown'
- import { KnowledgeGroup, KnowledgeFile } from '../../types'
- import { noteService, Note } from '../../services/noteService'
- import { knowledgeBaseService } from '../../services/knowledgeBaseService'
- import { modelConfigService } from '../../services/modelConfigService'
- import { uploadService } from '../../services/uploadService'
- import { knowledgeGroupService } from '../../services/knowledgeGroupService'
- import { useToast } from '../../contexts/ToastContext'
- import { useConfirm } from '../../contexts/ConfirmContext'
- import { ModelType, RawFile, IndexingConfig, ModelConfig } from '../../types'
- import IndexingModalWithMode from '../IndexingModalWithMode'
- import { PDFPreview, PDFPreviewButton } from '../PDFPreview'
- import { AICommandDrawer } from '../AICommandDrawer'
- import { ImportFolderDrawer } from '../ImportFolderDrawer'
- import { NotebookDragDropUpload } from '../NotebookDragDropUpload'
- import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
- import { useLanguage } from '../../contexts/LanguageContext'
- import { readFile, formatBytes } from '../../utils/fileUtils'
- import { Sparkles } from 'lucide-react'
- import remarkGfm from 'remark-gfm'
- import remarkMath from 'remark-math'
- import rehypeKatex from 'rehype-katex'
- import mermaid from 'mermaid'
- import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
- import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
- import { Info } from 'lucide-react'
- import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, getSupportedFormatsLabel } from '../../constants/fileSupport'
- // mermaid の初期化
- mermaid.initialize({
- startOnLoad: false,
- theme: 'default',
- securityLevel: 'loose',
- })
- const Mermaid = ({ chart }: { chart: string }) => {
- const [svg, setSvg] = useState('')
- const [error, setError] = useState('')
- const { t } = useLanguage()
- useEffect(() => {
- const renderChart = async () => {
- try {
- const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
- const { svg } = await mermaid.render(id, chart)
- setSvg(svg)
- setError('')
- } catch (err) {
- console.error('Mermaid render error:', err)
- setError(t('errorRenderFlowchart'))
- }
- }
- if (chart) renderChart()
- }, [chart])
- if (error) return <div className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</div>
- return <div className="mermaid-chart my-4 flex justify-center" dangerouslySetInnerHTML={{ __html: svg }} />
- }
- interface NotebookDetailViewProps {
- authToken: string;
- notebook: KnowledgeGroup;
- onBack: () => void;
- onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
- isAdmin?: boolean;
- }
- export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
- const [activeTab, setActiveTab] = useState<'files' | 'notes'>('files')
- const [files, setFiles] = useState<KnowledgeFile[]>([])
- const [notes, setNotes] = useState<Note[]>([])
- const [isLoading, setIsLoading] = useState(false)
- const { showError, showSuccess } = useToast()
- const { confirm } = useConfirm()
- const { t, language } = useLanguage()
- // メモエディタの状態
- const [isEditingNote, setIsEditingNote] = useState(false)
- const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
- const [showPreview, setShowPreview] = useState(true)
- const [isUploading, setIsUploading] = useState(false)
- const fileInputRef = React.useRef<HTMLInputElement>(null)
- const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false)
- // インデックスモーダルの状態
- const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
- const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
- const [shouldOpenModal, setShouldOpenModal] = useState(false)
- const [models, setModels] = useState<ModelConfig[]>([])
- // PDFプレビューの状態
- const [previewFile, setPreviewFile] = useState<{ id: string, name: string } | null>(null);
- // AIアシスタントの状態
- const [isAIModalOpen, setIsAIModalOpen] = useState(false)
- useEffect(() => {
- // モーダル用のモデルを取得
- const fetchModels = async () => {
- try {
- const res = await modelConfigService.getAll(authToken)
- setModels(res)
- } catch (error) {
- console.error('Failed to fetch models', error)
- }
- }
- if (authToken) fetchModels()
- }, [authToken])
- // Effect to open modal when pending files are set
- useEffect(() => {
- if (shouldOpenModal && pendingFiles.length > 0) {
- setIsIndexingModalOpen(true);
- setShouldOpenModal(false);
- }
- }, [shouldOpenModal, pendingFiles.length]);
- const loadData = async () => {
- setIsLoading(true)
- try {
- const [allFiles, notebookNotes] = await Promise.all([
- knowledgeBaseService.getAll(authToken),
- noteService.getAll(authToken, notebook.id)
- ])
- const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
- setFiles(notebookFiles)
- setNotes(notebookNotes)
- } catch (error) {
- console.error(error)
- showError(t('errorLoadData'))
- } finally {
- setIsLoading(false)
- }
- }
- useEffect(() => {
- loadData()
- }, [authToken, notebook.id, activeTab])
- const handleFileUpload = async (fileList: FileList | File[]) => {
- if (!fileList || fileList.length === 0) return
- const MAX_FILE_SIZE = 104857600; // 100MB
- const MAX_SIZE_MB = 100
- const newPendingFiles: RawFile[] = []
- const errors: string[] = []
- const filesArray = Array.from(fileList)
- for (let i = 0; i < filesArray.length; i++) {
- const file = filesArray[i]
- try {
- // Check file size
- if (file.size > MAX_FILE_SIZE) {
- errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
- continue
- }
- const extension = file.name.split('.').pop() || ''
- if (!isExtensionAllowed(extension, 'group')) {
- if (!(await confirm(t('confirmUnsupportedFile', extension || 'unknown')))) {
- continue
- }
- }
- // Read file
- const rawFile = await readFile(file)
- newPendingFiles.push(rawFile)
- } catch (error: any) {
- console.error(`Error processing file ${file.name}:`, error)
- errors.push(`${file.name} - ${t('errorReadFile', error.message || t('unknownError'))}`)
- }
- }
- if (errors.length > 0) {
- showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
- }
- if (newPendingFiles.length > 0) {
- setPendingFiles(prev => [...prev, ...newPendingFiles])
- setShouldOpenModal(true)
- }
- if (fileInputRef.current) {
- fileInputRef.current.value = ''
- }
- }
- const handleConfirmIndexing = async (config: IndexingConfig) => {
- setIsUploading(true)
- setIsIndexingModalOpen(false)
- try {
- for (const rawFile of pendingFiles) {
- let uploadRes;
- if (rawFile.isNote) {
- uploadRes = await uploadService.uploadText(rawFile.textContent || rawFile.content, rawFile.name, config, authToken)
- } else {
- uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
- }
- if (uploadRes && uploadRes.id) {
- await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
- }
- }
- showSuccess(t('successUploadFile'))
- loadData()
- } catch (error) {
- console.error(error)
- showError(t('errorUploadFile', error.message || t('unknownError')))
- } finally {
- setIsUploading(false)
- setPendingFiles([])
- }
- }
- const handleIndexNote = async (note: Note) => {
- if (!note.title || !note.content) return;
- try {
- const file = new File([note.content], `${note.title}.md`, { type: 'text/markdown' });
- const rawFile = await readFile(file);
- rawFile.isNote = true;
- rawFile.textContent = note.content;
- setPendingFiles([rawFile]);
- setIsIndexingModalOpen(true);
- } catch (error) {
- console.error(error);
- showError(t('errorProcessFile'));
- }
- }
- const handleSaveNote = async () => {
- if (!currentNote.title || !currentNote.content) {
- showError(t('errorTitleContentRequired'))
- return
- }
- try {
- if (currentNote.id) {
- await noteService.update(authToken, currentNote.id, {
- title: currentNote.title,
- content: currentNote.content
- })
- showSuccess(t('successNoteUpdated'))
- } else {
- await noteService.create(authToken, {
- title: currentNote.title,
- content: currentNote.content,
- groupId: notebook.id
- })
- showSuccess(t('successNoteCreated'))
- }
- setIsEditingNote(false)
- setCurrentNote({})
- loadData()
- } catch (error: any) {
- console.error('Save note error:', error)
- showError(t('errorSaveFailed', error.message || t('unknownError')))
- }
- }
- const handleDeleteNote = async (id: string) => {
- if (!(await confirm(t('confirmDeleteNote')))) return
- try {
- await noteService.delete(authToken, id)
- showSuccess(t('successNoteDeleted'))
- loadData()
- } catch (error) {
- showError(t('deleteFailed'))
- }
- }
- const handleRemoveFile = async (fileId: string, fileName: string) => {
- if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
- try {
- const { knowledgeGroupService } = await import('../../services/knowledgeGroupService');
- await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
- showSuccess(t('fileDeleted'));
- loadData();
- } catch (error) {
- console.error(error);
- showError(t('deleteFailed'));
- }
- }
- // メモエディタのレンダリング
- if (isEditingNote) {
- return (
- <div className="flex flex-col h-full bg-slate-50">
- {/* エディタヘッダー */}
- <div className="h-14 bg-white border-b border-slate-200 flex items-center px-4 justify-between shrink-0">
- <div className="flex items-center gap-2">
- <button onClick={() => setIsEditingNote(false)} className="p-2 hover:bg-slate-100 rounded-full">
- <ArrowLeft size={20} className="text-slate-500" />
- </button>
- <h2 className="font-semibold text-slate-800">{currentNote.id ? t('editNote') : t('newNote')}</h2>
- </div>
- <div className="flex items-center gap-2">
- <button
- onClick={handleSaveNote}
- className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 shadow-sm"
- >
- {t('save')}
- </button>
- <div className="h-6 w-px bg-slate-200 mx-1"></div>
- <button
- onClick={() => setShowPreview(!showPreview)}
- 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'}`}
- title={showPreview ? t('togglePreviewClose') : t('togglePreviewOpen')}
- >
- {showPreview ? <Eye size={20} /> : <EyeOff size={20} />}
- </button>
- <button
- onClick={() => setIsAIModalOpen(true)}
- 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'}`}
- title={t('aiAssistant')}
- >
- <Sparkles size={20} />
- </button>
- </div>
- </div>
- <div className="flex-1 overflow-hidden w-full max-w-[95%] mx-auto flex flex-col p-4">
- <input
- type="text"
- placeholder={t('noteTitlePlaceholder')}
- value={currentNote.title || ''}
- onChange={e => setCurrentNote(prev => ({ ...prev, title: e.target.value }))}
- className="w-full text-2xl font-bold border-none outline-none bg-transparent mb-4 placeholder-slate-300 shrink-0 px-2"
- />
- <div className="flex-1 flex gap-4 min-h-0">
- {/* エディタ */}
- <div className={`flex flex-col transition-all duration-300 ${showPreview ? 'w-1/2' : 'w-full'}`}>
- <textarea
- placeholder={t('noteContentPlaceholder')}
- value={currentNote.content || ''}
- onChange={e => setCurrentNote(prev => ({ ...prev, content: e.target.value }))}
- 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"
- />
- </div>
- {/* プレビュー */}
- {showPreview && (
- <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">
- {currentNote.content ? (
- <ReactMarkdown
- remarkPlugins={[remarkGfm, remarkMath]}
- rehypePlugins={[rehypeKatex]}
- components={{
- code({ node, inline, className, children, ...props }: any) {
- const match = /language-(\w+)/.exec(className || '')
- const isMermaid = match && match[1] === 'mermaid'
- if (!inline && isMermaid) {
- return <Mermaid chart={String(children).replace(/\n$/, '')} />
- }
- return !inline && match ? (
- <SyntaxHighlighter
- style={oneLight}
- language={match[1]}
- PreTag="div"
- {...props}
- >
- {String(children).replace(/\n$/, '')}
- </SyntaxHighlighter>
- ) : (
- <code className={className} {...props}>
- {children}
- </code>
- )
- }
- }}
- >
- {currentNote.content}
- </ReactMarkdown>
- ) : (
- <div className="text-slate-300 text-center mt-10 italic">{t('markdownPreviewArea')}</div>
- )}
- </div>
- )}
- </div>
- </div>
- {/* AIコマンドドロワー - エディタ表示用 */}
- <AICommandDrawer
- isOpen={isAIModalOpen}
- onClose={() => setIsAIModalOpen(false)}
- context={currentNote.content || ''}
- authToken={authToken}
- onApply={(newContent) => {
- setCurrentNote(prev => ({ ...prev, content: newContent }))
- }}
- />
- </div>
- )
- }
- return (
- <div className="flex flex-col h-full bg-slate-50">
- {/* ヘッダー */}
- <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0 shadow-sm z-10">
- <div className="flex items-center gap-4">
- <button onClick={onBack} className="p-2 -ml-2 hover:bg-slate-100 rounded-full transition-colors" title={t('back')}>
- <ArrowLeft size={20} className="text-slate-500" />
- </button>
- <div>
- <h1 className="font-bold text-slate-800 text-xl flex items-center gap-2">
- <Book className="w-6 h-6 text-blue-600" />
- <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
- {notebook.name}
- </span>
- </h1>
- {notebook.description && <p className="text-sm text-slate-500 max-w-[300px] truncate mt-1">{notebook.description}</p>}
- </div>
- <div className="h-6 w-px bg-slate-200 mx-2"></div>
- <button
- onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
- 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"
- title={t('chatWithGroup')}
- >
- <MessageSquare size={18} />
- <span className="text-sm font-medium">{t('chatWithGroup')}</span>
- </button>
- </div>
- <div className="flex bg-slate-100 p-1 rounded-lg">
- <button
- onClick={() => setActiveTab('files')}
- className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'files'
- ? 'bg-white text-slate-800 shadow-sm'
- : 'text-slate-500 hover:text-slate-700'
- }`}
- >
- {t('filesCountLabel', files.length)}
- </button>
- <button
- onClick={() => setActiveTab('notes')}
- className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'notes'
- ? 'bg-white text-slate-800 shadow-sm'
- : 'text-slate-500 hover:text-slate-700'
- }`}
- >
- {t('notesCountLabel', notes.length)}
- </button>
- </div>
- </div>
- {/* コンテンツアクション */}
- <div className="p-4 flex justify-end">
- {activeTab === 'notes' && (
- <button
- onClick={() => {
- setCurrentNote({})
- setIsEditingNote(true)
- }}
- className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
- >
- <Plus size={16} />
- <span>{t('newNote')}</span>
- </button>
- )}
- {activeTab === 'files' && (
- <>
- {isAdmin && (
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- onChange={(e) => {
- if (e.target.files) handleFileUpload(e.target.files)
- }}
- multiple
- disabled={isUploading}
- accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
- />
- )}
- {isAdmin && (
- <button
- 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"
- onClick={() => setIsImportDrawerOpen(true)}
- >
- <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>
- <span>{t('importFolder')}</span>
- </button>
- )}
- {isAdmin && (
- <div className="flex items-center gap-2">
- <div className="group relative">
- <Info className="w-5 h-5 text-slate-400 cursor-help hover:text-blue-500 transition-colors" />
- <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">
- {t('supportedFormatsInfo')}
- </div>
- </div>
- <button
- 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"
- onClick={() => fileInputRef.current?.click()}
- disabled={isUploading}
- >
- <Plus size={16} />
- <span>{isUploading ? t('uploading') : t('addFile')}</span>
- </button>
- </div>
- )}
- </>
- )}
- </div>
- {/* ノートブック全域ドラッグアップロードオーバーレイ */}
- <NotebookGlobalDragDropOverlay
- onFilesSelected={handleFileUpload}
- isAdmin={isAdmin}
- />
- {/* リスト表示 */}
- <div className="flex-1 overflow-y-auto px-6 pb-6">
- {isLoading ? (
- <div className="text-center py-10 text-slate-400">Loading...</div>
- ) : (
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {activeTab === 'files' && files.map(file => (
- <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">
- <div className="p-2 bg-slate-100 rounded-lg text-slate-500">
- <FileIcon size={24} />
- </div>
- <div className="flex-1 min-w-0">
- <h3 className="font-medium text-slate-800 truncate" title={file.title || file.name}>{file.title || file.name}</h3>
- <p className="text-xs text-slate-400 mt-1">{formatBytes(file.size)}</p>
- </div>
- <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
- <PDFPreviewButton
- fileId={file.id}
- fileName={file.name}
- onPreview={() => setPreviewFile({ id: file.id, name: file.name })}
- />
- <button
- onClick={(e) => {
- e.stopPropagation();
- onChatWithContext?.({ selectedGroups: [notebook.id], selectedFiles: [file.id] });
- }}
- className="p-1.5 text-slate-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
- title={t('chatWithFile')}
- >
- <MessageSquare size={16} />
- </button>
- {isAdmin && (
- <button
- onClick={(e) => {
- e.stopPropagation();
- handleRemoveFile(file.id, file.name);
- }}
- className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
- title={t('confirmRemoveFileFromGroup', '')}
- >
- <Trash2 size={16} />
- </button>
- )}
- </div>
- </div>
- ))}
- {activeTab === 'notes' && notes.map(note => (
- <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={() => {
- setCurrentNote(note)
- setIsEditingNote(true)
- }}>
- <div className="flex justify-between items-start mb-2 flex-shrink-0">
- <h3 className="font-bold text-slate-800 line-clamp-1">{note.title}</h3>
- <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
- {isAdmin && (
- <button onClick={() => handleIndexNote(note)} className="p-1 hover:bg-blue-50 text-slate-400 hover:text-blue-500 rounded" title={t('indexIntoKB')}>
- <Database size={16} />
- </button>
- )}
- <button onClick={() => handleDeleteNote(note.id)} className="p-1 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded">
- <Trash2 size={16} />
- </button>
- </div>
- </div>
- <p className="text-slate-500 text-sm line-clamp-3 mb-2 whitespace-pre-line flex-shrink-0">
- {note.content}
- </p>
- {note.screenshotPath && (
- <div className="mt-2 mb-2 rounded overflow-hidden border border-slate-100 flex-shrink-0 h-32">
- <img
- src={`/uploads/${note.screenshotPath}`}
- alt="Screenshot"
- className="w-full h-full object-cover"
- />
- </div>
- )}
- <div className="text-xs text-slate-400 mt-auto pt-3 border-t border-slate-100 flex-shrink-0">
- {new Date(note.updatedAt).toLocaleDateString()}
- {note.user && (
- <span className="ml-2 px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-medium">
- {note.user.username}
- </span>
- )}
- </div>
- </div>
- ))}
- {((activeTab === 'files' && files.length === 0) || (activeTab === 'notes' && notes.length === 0)) && (
- <div className="col-span-full">
- {activeTab === 'files' ? (
- <NotebookDragDropUpload
- onFilesSelected={handleFileUpload}
- isAdmin={isAdmin}
- />
- ) : (
- <div className="text-center py-20 text-slate-400">
- {t('noFilesOrNotes', t('notes'))}
- </div>
- )}
- </div>
- )}
- </div>
- )}
- </div>
- {/* インデックスモーダル */}
- <IndexingModalWithMode
- isOpen={isIndexingModalOpen}
- onClose={() => {
- setIsIndexingModalOpen(false)
- setPendingFiles([])
- }}
- onConfirm={handleConfirmIndexing}
- files={pendingFiles}
- embeddingModels={models.filter(m => m.type === ModelType.EMBEDDING)}
- defaultEmbeddingId={models.find(m => m.type === ModelType.EMBEDDING)?.id || ''}
- />
- {/* PDFプレビューモーダル */}
- {
- previewFile && (
- <PDFPreview
- fileId={previewFile.id}
- fileName={previewFile.name}
- authToken={authToken}
- groupId={notebook.id}
- onClose={() => setPreviewFile(null)}
- />
- )
- }
- {/* AIコマンドドロワー */}
- <AICommandDrawer
- isOpen={isAIModalOpen}
- onClose={() => setIsAIModalOpen(false)}
- context={currentNote.content || ''}
- authToken={authToken}
- onApply={(newContent) => {
- setCurrentNote(prev => ({ ...prev, content: newContent })) // 内容を置換(必要に応じて将来的に追加オプションを検討)
- }}
- />
- <ImportFolderDrawer
- isOpen={isImportDrawerOpen}
- onClose={() => setIsImportDrawerOpen(false)}
- authToken={authToken}
- initialGroupId={notebook.id}
- initialGroupName={notebook.name}
- onImportSuccess={loadData}
- />
- </div >
- )
- }
|