|
|
import ReactMarkdown from "react-markdown"; |
|
|
import remarkGfm from "remark-gfm"; |
|
|
import React, { useEffect, useState, useRef, useCallback } from "react"; |
|
|
import { getNoteContentUrl } from "../api/notesService"; |
|
|
import SecurePdfViewer from "../components/SecurePdfViewer"; |
|
|
import { |
|
|
Upload, |
|
|
Menu, |
|
|
X, |
|
|
Send, |
|
|
Loader2, |
|
|
FileText, |
|
|
MessageSquare, |
|
|
GripVertical, |
|
|
Trash2, |
|
|
Edit2, |
|
|
Check, |
|
|
Brain, |
|
|
} from "lucide-react"; |
|
|
import { |
|
|
fetchNotes, |
|
|
uploadNote, |
|
|
|
|
|
createChatSession, |
|
|
streamChatRequest, |
|
|
fetchChatHistory, |
|
|
fetchSessions, |
|
|
deleteNote, |
|
|
renameNote, |
|
|
type Note, |
|
|
type ChatMessage, |
|
|
} from "../api/notesService"; |
|
|
|
|
|
const Notes: React.FC = () => { |
|
|
|
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true); |
|
|
const [isChatOpen, setIsChatOpen] = useState(true); |
|
|
const [isUploading, setIsUploading] = useState(false); |
|
|
const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
const [chatWidth, setChatWidth] = useState(450); |
|
|
const [isResizing, setIsResizing] = useState(false); |
|
|
|
|
|
|
|
|
const [notes, setNotes] = useState<Note[]>([]); |
|
|
const [currentNote, setCurrentNote] = useState<Note | null>(null); |
|
|
|
|
|
|
|
|
|
|
|
const [editingNoteId, setEditingNoteId] = useState<number | null>(null); |
|
|
const [editName, setEditName] = useState(""); |
|
|
|
|
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]); |
|
|
const [inputMessage, setInputMessage] = useState(""); |
|
|
const [sessionId, setSessionId] = useState<string | null>(null); |
|
|
const [isChatLoading, setIsChatLoading] = useState(false); |
|
|
|
|
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
useEffect(() => { |
|
|
loadNotes(); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const scrollToBottom = () => { |
|
|
messagesEndRef.current?.scrollIntoView({ |
|
|
behavior: "instant", |
|
|
block: "end", |
|
|
}); |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
scrollToBottom(); |
|
|
}, [messages, isChatLoading]); |
|
|
|
|
|
|
|
|
const startResizing = useCallback(() => setIsResizing(true), []); |
|
|
const stopResizing = useCallback(() => setIsResizing(false), []); |
|
|
|
|
|
const resize = useCallback( |
|
|
(mouseMoveEvent: MouseEvent) => { |
|
|
if (isResizing) { |
|
|
const newWidth = document.body.clientWidth - mouseMoveEvent.clientX; |
|
|
if (newWidth > 300 && newWidth < 800) setChatWidth(newWidth); |
|
|
} |
|
|
}, |
|
|
[isResizing] |
|
|
); |
|
|
|
|
|
useEffect(() => { |
|
|
window.addEventListener("mousemove", resize); |
|
|
window.addEventListener("mouseup", stopResizing); |
|
|
return () => { |
|
|
window.removeEventListener("mousemove", resize); |
|
|
window.removeEventListener("mouseup", stopResizing); |
|
|
}; |
|
|
}, [resize, stopResizing]); |
|
|
|
|
|
const loadNotes = async () => { |
|
|
try { |
|
|
const data = await fetchNotes(); |
|
|
setNotes(data); |
|
|
} catch (error) { |
|
|
console.error("Failed to load notes", error); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleUploadClick = () => fileInputRef.current?.click(); |
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
const file = e.target.files?.[0]; |
|
|
if (!file) return; |
|
|
setIsUploading(true); |
|
|
try { |
|
|
const newNote = await uploadNote(file); |
|
|
setNotes([newNote, ...notes]); |
|
|
handleNoteSelect(newNote); |
|
|
} catch (error) { |
|
|
console.error("Upload failed", error); |
|
|
alert("Failed to upload PDF"); |
|
|
} finally { |
|
|
setIsUploading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const handleDeleteNote = async (e: React.MouseEvent, noteId: number) => { |
|
|
e.stopPropagation(); |
|
|
if ( |
|
|
!window.confirm( |
|
|
"Are you sure you want to delete this note and its chat history?" |
|
|
) |
|
|
) |
|
|
return; |
|
|
|
|
|
try { |
|
|
await deleteNote(noteId); |
|
|
setNotes((prev) => prev.filter((n) => n.id !== noteId)); |
|
|
if (currentNote?.id === noteId) { |
|
|
setCurrentNote(null); |
|
|
|
|
|
setMessages([]); |
|
|
setSessionId(null); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Failed to delete note", error); |
|
|
alert("Error deleting note"); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const startEditing = (e: React.MouseEvent, note: Note) => { |
|
|
e.stopPropagation(); |
|
|
setEditingNoteId(note.id); |
|
|
setEditName(note.filename); |
|
|
}; |
|
|
|
|
|
const saveRename = async (e: React.MouseEvent) => { |
|
|
e.stopPropagation(); |
|
|
if (!editingNoteId || !editName.trim()) return; |
|
|
|
|
|
try { |
|
|
await renameNote(editingNoteId, editName); |
|
|
setNotes((prev) => |
|
|
prev.map((n) => |
|
|
n.id === editingNoteId ? { ...n, filename: editName } : n |
|
|
) |
|
|
); |
|
|
if (currentNote?.id === editingNoteId) { |
|
|
setCurrentNote((prev) => |
|
|
prev ? { ...prev, filename: editName } : null |
|
|
); |
|
|
} |
|
|
setEditingNoteId(null); |
|
|
} catch (error) { |
|
|
console.error("Failed to rename", error); |
|
|
alert("Error renaming note"); |
|
|
} |
|
|
}; |
|
|
|
|
|
const cancelRename = (e: React.MouseEvent) => { |
|
|
e.stopPropagation(); |
|
|
setEditingNoteId(null); |
|
|
}; |
|
|
|
|
|
const handleNoteSelect = async (note: Note) => { |
|
|
if (editingNoteId === note.id) return; |
|
|
setCurrentNote(note); |
|
|
setMessages([]); |
|
|
setSessionId(null); |
|
|
setIsChatOpen(true); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
const existingSessions = await fetchSessions(note.id); |
|
|
if (existingSessions.length > 0) { |
|
|
const lastSession = existingSessions[0]; |
|
|
setSessionId(lastSession.id); |
|
|
const history = await fetchChatHistory(lastSession.id); |
|
|
const formattedHistory: ChatMessage[] = history.map((msg: any) => ({ |
|
|
role: msg.role, |
|
|
content: msg.content, |
|
|
})); |
|
|
setMessages(formattedHistory); |
|
|
} else { |
|
|
const session = await createChatSession( |
|
|
note.id, |
|
|
`Chat - ${note.filename}` |
|
|
); |
|
|
setSessionId(session.id); |
|
|
setMessages([ |
|
|
{ |
|
|
role: "assistant", |
|
|
content: `Ready to chat about ${note.filename}!`, |
|
|
}, |
|
|
]); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Failed to init chat", error); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleSendMessage = async () => { |
|
|
if (!inputMessage.trim() || !sessionId) return; |
|
|
const userMsg = inputMessage; |
|
|
setInputMessage(""); |
|
|
setMessages((prev) => [...prev, { role: "user", content: userMsg }]); |
|
|
setIsChatLoading(true); |
|
|
setMessages((prev) => [...prev, { role: "assistant", content: "" }]); |
|
|
try { |
|
|
await streamChatRequest( |
|
|
sessionId, |
|
|
userMsg, |
|
|
(chunk) => { |
|
|
setMessages((prev) => { |
|
|
const newArr = [...prev]; |
|
|
const lastIndex = newArr.length - 1; |
|
|
newArr[lastIndex] = { |
|
|
...newArr[lastIndex], |
|
|
content: newArr[lastIndex].content + chunk, |
|
|
}; |
|
|
return newArr; |
|
|
}); |
|
|
}, |
|
|
(err) => console.error("Stream error", err) |
|
|
); |
|
|
} catch (e) { |
|
|
console.error("Chat Request Error", e); |
|
|
} finally { |
|
|
setIsChatLoading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
|
|
|
<div className="flex h-screen w-full overflow-hidden bg-[#434E78] font-sans text-white"> |
|
|
{/* --- Left Sidebar (#607B8F) --- */} |
|
|
<div |
|
|
className={`h-screen shrink-0 transition-all duration-300 bg-[#607B8F] border-r border-white/10 flex flex-col gap-4 ${ |
|
|
isSidebarOpen ? "w-72 p-4" : "w-0 p-0 overflow-hidden" |
|
|
}`} |
|
|
> |
|
|
{isSidebarOpen && ( |
|
|
<> |
|
|
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-4"> |
|
|
<FileText className="text-[#F7E396] w-6 h-6" /> |
|
|
<h3 className="text-xl font-bold text-white font-handwriting"> |
|
|
My Notes |
|
|
</h3> |
|
|
</div> |
|
|
|
|
|
<input |
|
|
type="file" |
|
|
ref={fileInputRef} |
|
|
className="hidden" |
|
|
accept="application/pdf" |
|
|
onChange={handleFileChange} |
|
|
/> |
|
|
|
|
|
{/* Upload Button: #F7E396 (Highlight) */} |
|
|
<button |
|
|
onClick={handleUploadClick} |
|
|
disabled={isUploading} |
|
|
className="flex items-center justify-center gap-2 w-full bg-[#F7E396] text-[#434E78] px-4 py-3 rounded-xl hover:bg-[#E97F4A] hover:text-white transition font-bold shadow-md" |
|
|
> |
|
|
{isUploading ? ( |
|
|
<Loader2 className="animate-spin" size={20} /> |
|
|
) : ( |
|
|
<Upload size={20} /> |
|
|
)} |
|
|
{isUploading ? "Uploading..." : "Upload New PDF"} |
|
|
</button> |
|
|
|
|
|
<div className="mt-2 flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar"> |
|
|
<p className="text-xs text-gray-200 uppercase tracking-widest font-semibold mb-2"> |
|
|
History |
|
|
</p> |
|
|
|
|
|
{notes.map((note) => ( |
|
|
<div |
|
|
key={note.id} |
|
|
onClick={() => handleNoteSelect(note)} |
|
|
className={`group relative p-3 rounded-lg cursor-pointer flex items-center justify-between transition-all border border-transparent ${ |
|
|
currentNote?.id === note.id |
|
|
? "bg-[#434E78] border-[#F7E396] shadow-md" |
|
|
: "hover:bg-[#434E78]/50 text-gray-100" |
|
|
}`} |
|
|
> |
|
|
{editingNoteId === note.id ? ( |
|
|
<div className="flex items-center gap-2 w-full"> |
|
|
<input |
|
|
type="text" |
|
|
value={editName} |
|
|
onChange={(e) => setEditName(e.target.value)} |
|
|
onClick={(e) => e.stopPropagation()} |
|
|
className="flex-1 bg-[#434E78] text-white text-xs px-2 py-1 rounded border border-[#F7E396] outline-none" |
|
|
autoFocus |
|
|
/> |
|
|
<button |
|
|
onClick={saveRename} |
|
|
className="text-green-300 hover:text-green-200 p-1" |
|
|
> |
|
|
<Check size={14} /> |
|
|
</button> |
|
|
<button |
|
|
onClick={cancelRename} |
|
|
className="text-red-300 hover:text-red-200 p-1" |
|
|
> |
|
|
<X size={14} /> |
|
|
</button> |
|
|
</div> |
|
|
) : ( |
|
|
<> |
|
|
<div className="flex items-center gap-3 overflow-hidden"> |
|
|
<FileText |
|
|
size={16} |
|
|
className={`${ |
|
|
currentNote?.id === note.id |
|
|
? "text-[#F7E396]" |
|
|
: "text-gray-300" |
|
|
} shrink-0`} |
|
|
/> |
|
|
<span |
|
|
className={`truncate text-sm font-medium ${ |
|
|
currentNote?.id === note.id |
|
|
? "text-white" |
|
|
: "text-gray-200" |
|
|
}`} |
|
|
> |
|
|
{note.filename} |
|
|
</span> |
|
|
</div> |
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> |
|
|
<button |
|
|
onClick={(e) => startEditing(e, note)} |
|
|
className="p-1.5 hover:bg-white/10 rounded text-gray-300 hover:text-[#F7E396]" |
|
|
title="Rename" |
|
|
> |
|
|
<Edit2 size={14} /> |
|
|
</button> |
|
|
<button |
|
|
onClick={(e) => handleDeleteNote(e, note.id)} |
|
|
className="p-1.5 hover:bg-red-500/20 rounded text-gray-300 hover:text-red-300" |
|
|
title="Delete" |
|
|
> |
|
|
<Trash2 size={14} /> |
|
|
</button> |
|
|
</div> |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{} |
|
|
<div className="flex flex-1 overflow-hidden relative flex-col bg-[#434E78]"> |
|
|
<header className="flex justify-between items-center p-4 border-b border-white/10 shrink-0 z-10 bg-[#434E78]"> |
|
|
<div className="flex items-center gap-3"> |
|
|
<button |
|
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)} |
|
|
className="p-2 rounded-lg hover:bg-white/10 text-white transition" |
|
|
> |
|
|
<Menu size={20} /> |
|
|
</button> |
|
|
<h2 className="text-lg font-bold text-white truncate max-w-md"> |
|
|
{currentNote ? currentNote.filename : "Select a Note"} |
|
|
</h2> |
|
|
</div> |
|
|
{!isChatOpen && currentNote && ( |
|
|
<button |
|
|
onClick={() => setIsChatOpen(true)} |
|
|
className="flex items-center gap-2 bg-[#F7E396] text-[#434E78] hover:bg-[#E97F4A] hover:text-white px-4 py-2 rounded-lg text-sm font-bold transition shadow-md" |
|
|
> |
|
|
<MessageSquare size={18} /> Open Chat |
|
|
</button> |
|
|
)} |
|
|
</header> |
|
|
|
|
|
<div className="flex-1 w-full h-full relative p-4"> |
|
|
{currentNote ? ( |
|
|
<div className="w-full h-full rounded-xl overflow-hidden shadow-2xl border border-white/10 bg-white/5 backdrop-blur-sm"> |
|
|
{/* FIX: Use the SecurePdfViewer component, passing the noteId */} |
|
|
<SecurePdfViewer noteId={currentNote.id} /> |
|
|
</div> |
|
|
) : ( |
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-400/50"> |
|
|
<FileText size={80} className="mb-4 opacity-30" /> |
|
|
<p className="text-lg font-medium"> |
|
|
Select a PDF from the sidebar to view |
|
|
</p> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{} |
|
|
{isChatOpen && ( |
|
|
<div |
|
|
className="w-1 hover:w-2 cursor-col-resize bg-[#434E78] hover:bg-[#F7E396] transition-all z-20 flex items-center justify-center shrink-0" |
|
|
onMouseDown={startResizing} |
|
|
> |
|
|
<GripVertical size={16} className="text-white/30" /> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div |
|
|
style={{ width: isChatOpen ? chatWidth : 0 }} |
|
|
className={`flex flex-col bg-[#607B8F] border-l border-white/10 shrink-0 transition-all duration-100 ease-linear overflow-hidden`} |
|
|
> |
|
|
{isChatOpen && ( |
|
|
<> |
|
|
<header className="flex justify-between items-center p-4 border-b border-white/10 bg-[#607B8F] shrink-0"> |
|
|
<div className="flex items-center gap-2"> |
|
|
<Brain size={20} className="text-[#F7E396]" /> |
|
|
<h3 className="text-lg font-bold text-white whitespace-nowrap font-handwriting"> |
|
|
AI Chat |
|
|
</h3> |
|
|
</div> |
|
|
<button |
|
|
onClick={() => setIsChatOpen(false)} |
|
|
className="p-2 rounded-lg text-gray-200 hover:bg-white/10 hover:text-white transition-colors" |
|
|
> |
|
|
<X size={20} /> |
|
|
</button> |
|
|
</header> |
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar"> |
|
|
{messages.length === 0 && ( |
|
|
<div className="text-center text-gray-200 mt-10 opacity-70"> |
|
|
<p>Ask a question about this document...</p> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{messages.map((msg, i) => ( |
|
|
<div |
|
|
key={i} |
|
|
className={`flex ${ |
|
|
msg.role === "user" ? "justify-end" : "justify-start" |
|
|
}`} |
|
|
> |
|
|
<div |
|
|
className={`max-w-[85%] p-4 rounded-xl text-sm shadow-sm ${ |
|
|
msg.role === "user" |
|
|
? "bg-[#F7E396] text-[#434E78] font-medium rounded-tr-none" |
|
|
: "bg-[#434E78] text-white border border-white/10 rounded-tl-none prose prose-invert prose-sm max-w-none" |
|
|
}`} |
|
|
> |
|
|
{msg.role === "assistant" ? ( |
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}> |
|
|
{msg.content} |
|
|
</ReactMarkdown> |
|
|
) : ( |
|
|
msg.content |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
{isChatLoading && ( |
|
|
<div className="flex justify-start"> |
|
|
<div className="bg-[#434E78] p-3 rounded-lg border border-white/10"> |
|
|
<Loader2 className="animate-spin w-4 h-4 text-[#F7E396]" /> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{/* Auto Scroll Anchor */} |
|
|
<div ref={messagesEndRef} /> |
|
|
</div> |
|
|
|
|
|
<div className="p-4 border-t border-white/10 shrink-0 bg-[#607B8F]"> |
|
|
<div className="flex gap-2"> |
|
|
<input |
|
|
type="text" |
|
|
value={inputMessage} |
|
|
onChange={(e) => setInputMessage(e.target.value)} |
|
|
onKeyDown={(e) => e.key === "Enter" && handleSendMessage()} |
|
|
placeholder="Type your question..." |
|
|
// Input: Dark Blue (#434E78) for contrast on Light Blue (#607B8F) panel |
|
|
className="flex-1 bg-[#434E78] text-white rounded-xl px-4 py-3 border border-transparent focus:border-[#F7E396] focus:ring-0 outline-none placeholder-gray-400 shadow-inner" |
|
|
disabled={!sessionId || isChatLoading} |
|
|
/> |
|
|
<button |
|
|
onClick={handleSendMessage} |
|
|
disabled={!sessionId || isChatLoading} |
|
|
className="bg-[#F7E396] p-3 rounded-xl text-[#434E78] hover:bg-[#E97F4A] hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-md" |
|
|
> |
|
|
<Send size={20} /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default Notes; |
|
|
|