aki-008 commited on
Commit
35d0ce7
·
1 Parent(s): 23c3caf

chore: clean up done

Browse files
Backend/app/api/v1/endpoints/notes.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Response
2
  from sqlalchemy.ext.asyncio import AsyncSession
3
  from app.models import User
4
  from app.models.tables import PDFData
@@ -345,4 +345,66 @@ async def get_pdf_content(
345
  raise HTTPException(status_code=404, detail="Note not found")
346
 
347
  # Return raw bytes with PDF mime type so browser/frontend can render it
348
- return Response(content=pdf.pdf_blob, media_type="application/pdf")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Response, Body
2
  from sqlalchemy.ext.asyncio import AsyncSession
3
  from app.models import User
4
  from app.models.tables import PDFData
 
345
  raise HTTPException(status_code=404, detail="Note not found")
346
 
347
  # Return raw bytes with PDF mime type so browser/frontend can render it
348
+ return Response(content=pdf.pdf_blob, media_type="application/pdf")
349
+
350
+
351
+ # -------------------------
352
+ # NEW: Delete Note
353
+ # -------------------------
354
+ @router.delete("/{note_id}")
355
+ async def delete_note(
356
+ note_id: int,
357
+ db: AsyncSession = Depends(get_db),
358
+ current_user: User = Depends(get_current_user),
359
+ collection: Collection = Depends(get_chroma_collection)
360
+ ):
361
+ # 1. Check ownership
362
+ result = await db.execute(
363
+ select(PDFData).where(PDFData.id == note_id, PDFData.user_id == current_user.id)
364
+ )
365
+ note = result.scalar_one_or_none()
366
+
367
+ if not note:
368
+ raise HTTPException(status_code=404, detail="Note not found")
369
+
370
+ # 2. Delete from ChromaDB (using metadata filter)
371
+ try:
372
+ # This deletes all chunks where metadata field 'pdf_id' matches
373
+ await collection.delete(where={"pdf_id": note_id})
374
+ except Exception as e:
375
+ print(f"Error deleting from Chroma: {e}")
376
+ # Proceed to delete from DB even if Chroma fails to avoid sync issues
377
+
378
+ # 3. Delete from Database (Cascades to Sessions/Messages)
379
+ await db.delete(note)
380
+ await db.commit()
381
+
382
+ return {"status": "success", "message": "Note deleted"}
383
+
384
+ # -------------------------
385
+ # NEW: Rename Note
386
+ # -------------------------
387
+ @router.put("/{note_id}")
388
+ async def rename_note(
389
+ note_id: int,
390
+ new_filename: str = Body(..., embed=True), # Expects JSON: { "new_filename": "foo.pdf" }
391
+ db: AsyncSession = Depends(get_db),
392
+ current_user: User = Depends(get_current_user)
393
+ ):
394
+ result = await db.execute(
395
+ select(PDFData).where(PDFData.id == note_id, PDFData.user_id == current_user.id)
396
+ )
397
+ note = result.scalar_one_or_none()
398
+
399
+ if not note:
400
+ raise HTTPException(status_code=404, detail="Note not found")
401
+
402
+ note.filename = new_filename
403
+ await db.commit()
404
+ await db.refresh(note)
405
+
406
+ return {
407
+ "id": note.id,
408
+ "filename": note.filename,
409
+ "created_at": note.created_at
410
+ }
Frontend/src/App.tsx CHANGED
@@ -1,5 +1,11 @@
1
  import React from "react";
2
- import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
 
 
 
 
 
 
3
 
4
  import Home from "./pages/home";
5
  import Dashboard from "./pages/dashboard";
@@ -10,15 +16,13 @@ import Sidebar from "./components/dashboard/Sidebar";
10
 
11
  import { AuthProvider, useAuth } from "./components/context/AuthContext";
12
  import ProtectedRoute from "./routes/ProtectedRoute";
 
13
 
 
14
  const DashboardLayout = () => {
15
- // 1. Retrieve both logout and username from the AuthContext
16
-
17
  return (
18
  <div className="flex h-screen bg-gray-100">
19
- {/* 2. Pass the retrieved username prop to the Sidebar */}
20
- <Sidebar/>
21
-
22
  <main className="flex-1 overflow-y-auto">
23
  <Routes>
24
  <Route path="/dashboard" element={<Dashboard />} />
@@ -32,36 +36,53 @@ const DashboardLayout = () => {
32
  );
33
  };
34
 
35
- const App: React.FC = () => {
36
- return (
37
- <AuthProvider>
38
- <Router>
39
- <Routes>
40
- {/* Public Home */}
41
- <Route path="/" element={<HomeWrapper />} />
42
 
43
- {/* Protected all dashboard routes */}
44
- <Route
45
- path="/*"
46
- element={
47
- <ProtectedRoute>
48
- <DashboardLayout />
49
- </ProtectedRoute>
50
- }
51
- />
52
 
53
- </Routes>
54
- </Router>
55
- </AuthProvider>
56
- );
57
- };
 
 
 
 
58
 
59
- const HomeWrapper = () => {
60
- const { isAuthenticated, login } = useAuth();
 
 
 
61
 
62
- if (isAuthenticated) return <Navigate to="/dashboard" replace />;
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- return <Home onLogin={login} />;
 
 
 
 
 
65
  };
66
 
67
- export default App;
 
1
  import React from "react";
2
+
3
+ import {
4
+ BrowserRouter as Router,
5
+ Routes,
6
+ Route,
7
+ Navigate,
8
+ } from "react-router-dom";
9
 
10
  import Home from "./pages/home";
11
  import Dashboard from "./pages/dashboard";
 
16
 
17
  import { AuthProvider, useAuth } from "./components/context/AuthContext";
18
  import ProtectedRoute from "./routes/ProtectedRoute";
19
+ import { Loader2 } from "lucide-react";
20
 
21
+ // Layout for authenticated users
22
  const DashboardLayout = () => {
 
 
23
  return (
24
  <div className="flex h-screen bg-gray-100">
25
+ <Sidebar />
 
 
26
  <main className="flex-1 overflow-y-auto">
27
  <Routes>
28
  <Route path="/dashboard" element={<Dashboard />} />
 
36
  );
37
  };
38
 
39
+ // Wrapper for Home to handle "If logged in, go to dashboard" logic
40
+ const HomeWrapper = () => {
41
+ const { isAuthenticated, login } = useAuth();
42
+ if (isAuthenticated) return <Navigate to="/dashboard" replace />;
43
+ return <Home onLogin={login} />;
44
+ };
 
45
 
46
+ // Main Routing Logic extracted to use AuthContext
47
+ const AppRoutes = () => {
48
+ const { isLoading } = useAuth();
 
 
 
 
 
 
49
 
50
+ // 1. Show a loading spinner while checking auth state
51
+ // This prevents the "flash" of redirecting to home/dashboard on reload
52
+ if (isLoading) {
53
+ return (
54
+ <div className="flex h-screen w-full items-center justify-center bg-gray-900 text-white">
55
+ <Loader2 className="h-10 w-10 animate-spin text-blue-500" />
56
+ </div>
57
+ );
58
+ }
59
 
60
+ return (
61
+ <Router>
62
+ <Routes>
63
+ {/* Public Home */}
64
+ <Route path="/" element={<HomeWrapper />} />
65
 
66
+ {/* Protected Dashboard Routes */}
67
+ <Route
68
+ path="/*"
69
+ element={
70
+ <ProtectedRoute>
71
+ <DashboardLayout />
72
+ </ProtectedRoute>
73
+ }
74
+ />
75
+ </Routes>
76
+ </Router>
77
+ );
78
+ };
79
 
80
+ const App: React.FC = () => {
81
+ return (
82
+ <AuthProvider>
83
+ <AppRoutes />
84
+ </AuthProvider>
85
+ );
86
  };
87
 
88
+ export default App;
Frontend/src/api/notesService.ts CHANGED
@@ -1,4 +1,3 @@
1
- // Frontend/src/api/notesService.ts
2
  import API from "./api"; // Your existing Axios instance
3
  import { type AxiosResponse } from "axios";
4
 
@@ -30,7 +29,7 @@ export const fetchNotes = async (): Promise<Note[]> => {
30
  // 2. Upload a new PDF
31
  export const uploadNote = async (file: File): Promise<Note> => {
32
  const formData = new FormData();
33
- formData.append("file", file); // Must match backend: file: UploadFile
34
 
35
  const response = await API.post("/notes/upload_notes", formData, {
36
  headers: {
@@ -38,28 +37,31 @@ export const uploadNote = async (file: File): Promise<Note> => {
38
  },
39
  });
40
 
41
- // The backend returns complex data, but we just need the basics for the list update
42
- // Mapping the response to our Note interface structure locally if needed,
43
- // or you can adjust the backend response.
44
- // For now, we assume the backend returns the created doc info or we construct it.
45
  return {
46
  id: response.data.doc_id,
47
  filename: response.data.filename,
48
- created_at: new Date().toISOString(), // Optimistic timestamp
49
  };
50
  };
51
 
52
- // 3. Get the URL for the PDF content (for the viewer)
53
- // We don't use Axios here because we want a direct URL for the iframe/object
 
 
 
 
 
 
 
 
 
 
 
54
  export const getNoteContentUrl = (noteId: number): string => {
55
- const token = localStorage.getItem("token");
56
- // We append the token as a query param or handle auth differently for iframes.
57
- // Since standard Bearer auth is hard with simple <iframe src="...">,
58
- // we will fetch the blob via JS and create an ObjectURL in the component.
59
  return `/api/v1/notes/${noteId}/content`;
60
  };
61
 
62
- // 4. Fetch the Blob directly (Better for Auth)
63
  export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
64
  const response = await API.get(`/notes/${noteId}/content`, {
65
  responseType: "blob",
@@ -67,7 +69,7 @@ export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
67
  return response.data;
68
  };
69
 
70
- // 5. Create or Get Chat Session
71
  export const createChatSession = async (
72
  pdfId: number,
73
  name: string = "New Chat"
@@ -76,7 +78,7 @@ export const createChatSession = async (
76
  return response.data;
77
  };
78
 
79
- // 6. Get Chat History
80
  export const fetchChatHistory = async (sessionId: string) => {
81
  const response = await API.get(`/notes/history/${sessionId}`);
82
  return response.data;
@@ -87,7 +89,7 @@ export const fetchSessions = async (pdfId: number): Promise<Session[]> => {
87
  return response.data;
88
  };
89
 
90
- // 7. Stream Chat (Special Handling using fetch API)
91
  export const streamChatRequest = async (
92
  sessionId: string,
93
  userMessage: string,
@@ -107,9 +109,6 @@ export const streamChatRequest = async (
107
  Authorization: `Bearer ${token}`,
108
  "Content-Type": "application/json",
109
  },
110
- // Note: Your backend expects query params for user_prompt based on the route signature:
111
- // @router.post("/chat/{session_id}") async def chat_session(..., user_prompt: str, ...)
112
- // It is NOT a JSON body in your current backend code (check notes.py:207).
113
  }
114
  );
115
 
 
 
1
  import API from "./api"; // Your existing Axios instance
2
  import { type AxiosResponse } from "axios";
3
 
 
29
  // 2. Upload a new PDF
30
  export const uploadNote = async (file: File): Promise<Note> => {
31
  const formData = new FormData();
32
+ formData.append("file", file);
33
 
34
  const response = await API.post("/notes/upload_notes", formData, {
35
  headers: {
 
37
  },
38
  });
39
 
 
 
 
 
40
  return {
41
  id: response.data.doc_id,
42
  filename: response.data.filename,
43
+ created_at: new Date().toISOString(),
44
  };
45
  };
46
 
47
+ // 3. NEW: Delete a note
48
+ export const deleteNote = async (noteId: number) => {
49
+ const response = await API.delete(`/notes/${noteId}`);
50
+ return response.data;
51
+ };
52
+
53
+ // 4. NEW: Rename a note
54
+ export const renameNote = async (noteId: number, newName: string) => {
55
+ const response = await API.put(`/notes/${noteId}`, { new_filename: newName });
56
+ return response.data;
57
+ };
58
+
59
+ // 5. Get the URL for the PDF content (for the viewer)
60
  export const getNoteContentUrl = (noteId: number): string => {
 
 
 
 
61
  return `/api/v1/notes/${noteId}/content`;
62
  };
63
 
64
+ // 6. Fetch the Blob directly
65
  export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
66
  const response = await API.get(`/notes/${noteId}/content`, {
67
  responseType: "blob",
 
69
  return response.data;
70
  };
71
 
72
+ // 7. Create or Get Chat Session
73
  export const createChatSession = async (
74
  pdfId: number,
75
  name: string = "New Chat"
 
78
  return response.data;
79
  };
80
 
81
+ // 8. Get Chat History
82
  export const fetchChatHistory = async (sessionId: string) => {
83
  const response = await API.get(`/notes/history/${sessionId}`);
84
  return response.data;
 
89
  return response.data;
90
  };
91
 
92
+ // 9. Stream Chat (Special Handling using fetch API)
93
  export const streamChatRequest = async (
94
  sessionId: string,
95
  userMessage: string,
 
109
  Authorization: `Bearer ${token}`,
110
  "Content-Type": "application/json",
111
  },
 
 
 
112
  }
113
  );
114
 
Frontend/src/components/context/AuthContext.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from "react";
2
 
3
  interface AuthContextType {
4
  isAuthenticated: boolean;
 
5
  username: string;
6
  login: (name: string, token: string) => void;
7
  logout: () => void;
@@ -12,15 +13,20 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
12
  export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
13
  const [isAuthenticated, setIsAuthenticated] = useState(false);
14
  const [username, setUsername] = useState("Guest");
 
15
 
16
- // ✅ FIX: Check localStorage on mount to persist session
17
  useEffect(() => {
 
18
  const token = localStorage.getItem("token");
19
  const storedUser = localStorage.getItem("username");
 
20
  if (token) {
21
  setIsAuthenticated(true);
22
  if (storedUser) setUsername(storedUser);
23
  }
 
 
 
24
  }, []);
25
 
26
  const login = (name: string, token: string) => {
@@ -38,7 +44,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
38
  };
39
 
40
  return (
41
- <AuthContext.Provider value={{ isAuthenticated, username, login, logout }}>
 
 
42
  {children}
43
  </AuthContext.Provider>
44
  );
 
2
 
3
  interface AuthContextType {
4
  isAuthenticated: boolean;
5
+ isLoading: boolean; // <--- Added: Tracks if we are still checking local storage
6
  username: string;
7
  login: (name: string, token: string) => void;
8
  logout: () => void;
 
13
  export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
14
  const [isAuthenticated, setIsAuthenticated] = useState(false);
15
  const [username, setUsername] = useState("Guest");
16
+ const [isLoading, setIsLoading] = useState(true); // <--- Start loading immediately
17
 
 
18
  useEffect(() => {
19
+ // Check local storage on initial load
20
  const token = localStorage.getItem("token");
21
  const storedUser = localStorage.getItem("username");
22
+
23
  if (token) {
24
  setIsAuthenticated(true);
25
  if (storedUser) setUsername(storedUser);
26
  }
27
+
28
+ // Done checking, stop loading
29
+ setIsLoading(false);
30
  }, []);
31
 
32
  const login = (name: string, token: string) => {
 
44
  };
45
 
46
  return (
47
+ <AuthContext.Provider
48
+ value={{ isAuthenticated, isLoading, username, login, logout }}
49
+ >
50
  {children}
51
  </AuthContext.Provider>
52
  );
Frontend/src/pages/note.tsx CHANGED
@@ -9,6 +9,10 @@ import {
9
  Loader2,
10
  FileText,
11
  MessageSquare,
 
 
 
 
12
  } from "lucide-react";
13
  import {
14
  fetchNotes,
@@ -17,7 +21,9 @@ import {
17
  createChatSession,
18
  streamChatRequest,
19
  fetchChatHistory,
20
- fetchSessions, // ✅ Import this
 
 
21
  type Note,
22
  type ChatMessage,
23
  } from "../api/notesService";
@@ -29,8 +35,8 @@ const Notes: React.FC = () => {
29
  const [isUploading, setIsUploading] = useState(false);
30
  const fileInputRef = useRef<HTMLInputElement>(null);
31
 
32
- // Resizable Chat State
33
- const [chatWidth, setChatWidth] = useState(450); // Default width
34
  const [isResizing, setIsResizing] = useState(false);
35
 
36
  // --- Data State ---
@@ -38,29 +44,45 @@ const Notes: React.FC = () => {
38
  const [currentNote, setCurrentNote] = useState<Note | null>(null);
39
  const [pdfUrl, setPdfUrl] = useState<string | null>(null);
40
 
 
 
 
 
41
  // --- Chat State ---
42
  const [messages, setMessages] = useState<ChatMessage[]>([]);
43
  const [inputMessage, setInputMessage] = useState("");
44
  const [sessionId, setSessionId] = useState<string | null>(null);
45
  const [isChatLoading, setIsChatLoading] = useState(false);
46
 
47
- // 1. Load Notes on Mount
 
 
48
  useEffect(() => {
49
  loadNotes();
50
  }, []);
51
 
52
- // Handle Resizing Logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const startResizing = useCallback(() => setIsResizing(true), []);
54
  const stopResizing = useCallback(() => setIsResizing(false), []);
55
-
56
  const resize = useCallback(
57
  (mouseMoveEvent: MouseEvent) => {
58
  if (isResizing) {
59
- // Calculate new width based on mouse position from the right edge
60
  const newWidth = document.body.clientWidth - mouseMoveEvent.clientX;
61
- if (newWidth > 300 && newWidth < 800) {
62
- setChatWidth(newWidth);
63
- }
64
  }
65
  },
66
  [isResizing]
@@ -85,11 +107,9 @@ const Notes: React.FC = () => {
85
  };
86
 
87
  const handleUploadClick = () => fileInputRef.current?.click();
88
-
89
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
90
  const file = e.target.files?.[0];
91
  if (!file) return;
92
-
93
  setIsUploading(true);
94
  try {
95
  const newNote = await uploadNote(file);
@@ -103,42 +123,93 @@ const Notes: React.FC = () => {
103
  }
104
  };
105
 
106
- // FIX: Load History Logic (Issue 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  const handleNoteSelect = async (note: Note) => {
 
108
  setCurrentNote(note);
109
  setPdfUrl(null);
110
  setMessages([]);
111
  setSessionId(null);
112
- setIsChatOpen(true); // Auto open chat on select
113
 
114
- // A. Fetch PDF Blob
115
  try {
116
  const blob = await fetchNoteBlob(note.id);
117
  const url = URL.createObjectURL(blob);
118
  setPdfUrl(url);
119
  } catch (error) {
120
- console.error("Failed to load PDF content", error);
121
  }
122
 
123
- // B. Check for existing sessions -> Get History OR Create New
124
  try {
125
  const existingSessions = await fetchSessions(note.id);
126
-
127
  if (existingSessions.length > 0) {
128
- // Load the most recent session
129
  const lastSession = existingSessions[0];
130
  setSessionId(lastSession.id);
131
-
132
- // Fetch actual messages
133
  const history = await fetchChatHistory(lastSession.id);
134
- // Map backend history to frontend format
135
  const formattedHistory: ChatMessage[] = history.map((msg: any) => ({
136
  role: msg.role,
137
  content: msg.content,
138
  }));
139
  setMessages(formattedHistory);
140
  } else {
141
- // No session exists, create one
142
  const session = await createChatSession(
143
  note.id,
144
  `Chat - ${note.filename}`
@@ -152,23 +223,17 @@ const Notes: React.FC = () => {
152
  ]);
153
  }
154
  } catch (error) {
155
- console.error("Failed to init chat session", error);
156
  }
157
  };
158
 
159
- // ✅ FIX: Loading State Bug (Issue 4)
160
  const handleSendMessage = async () => {
161
  if (!inputMessage.trim() || !sessionId) return;
162
-
163
  const userMsg = inputMessage;
164
  setInputMessage("");
165
-
166
  setMessages((prev) => [...prev, { role: "user", content: userMsg }]);
167
- setIsChatLoading(true); // Start loading
168
-
169
- // Placeholder
170
  setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
171
-
172
  try {
173
  await streamChatRequest(
174
  sessionId,
@@ -184,22 +249,18 @@ const Notes: React.FC = () => {
184
  return newArr;
185
  });
186
  },
187
- (err) => {
188
- console.error("Stream error", err);
189
- // Don't set loading false here, let finally handle it
190
- }
191
  );
192
  } catch (e) {
193
  console.error("Chat Request Error", e);
194
  } finally {
195
- // ✅ Ensure loading stops regardless of success/fail so button enables
196
  setIsChatLoading(false);
197
  }
198
  };
199
 
200
  return (
201
  <div className="flex bg-black h-screen overflow-hidden">
202
- {/* --- Left Sidebar: My Notes --- */}
203
  <div
204
  className={`h-screen shrink-0 transition-all duration-300 bg-gray-900 border-r border-gray-700 flex flex-col gap-4 ${
205
  isSidebarOpen ? "w-64 p-4" : "w-0 p-0 overflow-hidden"
@@ -229,22 +290,74 @@ const Notes: React.FC = () => {
229
  )}
230
  {isUploading ? "Uploading..." : "Upload New PDF"}
231
  </button>
 
232
  <div className="mt-4 pt-4 border-t border-gray-700 space-y-2 overflow-y-auto">
233
  <p className="text-sm text-gray-400 uppercase tracking-wider">
234
  History
235
  </p>
 
236
  {notes.map((note) => (
237
  <div
238
  key={note.id}
239
  onClick={() => handleNoteSelect(note)}
240
- className={`text-gray-200 p-3 rounded-md cursor-pointer flex items-center gap-2 hover:bg-gray-700 transition ${
241
  currentNote?.id === note.id
242
  ? "bg-gray-800 border border-blue-500"
243
  : ""
244
  }`}
245
  >
246
- <FileText size={16} className="text-blue-400" />
247
- <span className="truncate text-sm">{note.filename}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  </div>
249
  ))}
250
  </div>
@@ -253,135 +366,141 @@ const Notes: React.FC = () => {
253
  </div>
254
 
255
  {/* --- Center: PDF Viewer --- */}
256
- <div className="flex flex-1 overflow-hidden relative">
257
- <div className="flex flex-col flex-1 p-0 bg-gray-800">
258
- <header className="flex justify-between items-center p-4 bg-black/40 backdrop-blur-sm absolute top-0 w-full z-10">
259
  <button
260
  onClick={() => setIsSidebarOpen(!isSidebarOpen)}
261
- className="p-2 rounded-full bg-gray-700 hover:bg-gray-600 text-white"
262
  >
263
- {isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
264
  </button>
265
  <h2 className="text-lg font-semibold text-white truncate max-w-md">
266
  {currentNote ? currentNote.filename : "Select a Note"}
267
  </h2>
268
- {!isChatOpen && (
269
- <button
270
- onClick={() => setIsChatOpen(true)}
271
- className="flex items-center gap-2 bg-blue-600 text-white px-3 py-2 rounded-lg text-sm"
272
- >
273
- <MessageSquare size={16} /> Chat
274
- </button>
275
- )}
276
- </header>
277
-
278
- <div className="flex-1 w-full h-full pt-16">
279
- {pdfUrl ? (
280
- <iframe
281
- src={pdfUrl}
282
- className="w-full h-full border-none"
283
- title="PDF Viewer"
284
- />
285
- ) : (
286
- <div className="flex flex-col items-center justify-center h-full text-gray-400">
287
- <FileText size={64} className="mb-4 opacity-50" />
288
- <p>Select a PDF from the sidebar to view</p>
289
- </div>
290
- )}
291
  </div>
292
- </div>
 
 
 
 
 
 
 
 
293
 
294
- {/* --- Resizable Chat Panel (Issue 3) --- */}
295
- {isChatOpen && (
296
- // Drag Handle
297
- <div
298
- className="w-1.5 cursor-col-resize bg-gray-800 hover:bg-blue-500 transition-colors z-20 flex items-center justify-center"
299
- onMouseDown={startResizing}
300
- >
301
- {/* Tiny indicator for grip */}
302
- <div className="h-8 w-0.5 bg-gray-600 rounded"></div>
303
- </div>
304
- )}
 
 
 
 
 
 
305
 
 
 
306
  <div
307
- style={{ width: isChatOpen ? chatWidth : 0 }}
308
- className={`flex flex-col bg-gray-900 border-l border-gray-700 flex-shrink-0 transition-all duration-75 ease-out`}
309
  >
310
- {isChatOpen && (
311
- <>
312
- <header className="flex justify-between items-center p-4 border-b border-gray-700">
313
- <h3 className="text-lg font-bold text-white">AI Chat</h3>
314
- {/* Close Button is here */}
315
- <button
316
- onClick={() => setIsChatOpen(false)}
317
- className="text-gray-400 hover:text-white"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  >
319
- <X size={20} />
320
- </button>
321
- </header>
322
-
323
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
324
- {messages.length === 0 && (
325
- <p className="text-gray-500 text-center text-sm mt-10">
326
- Ask a question about this document...
327
- </p>
328
- )}
329
- {messages.map((msg, i) => (
330
  <div
331
- key={i}
332
- className={`flex ${
333
- msg.role === "user" ? "justify-end" : "justify-start"
 
334
  }`}
335
  >
336
- <div
337
- className={`max-w-[85%] p-3 rounded-lg text-sm ${
338
- msg.role === "user"
339
- ? "bg-blue-600 text-white"
340
- : "bg-gray-700 text-gray-200 prose prose-invert max-w-none"
341
- }`}
342
- >
343
- {msg.role === "assistant" ? (
344
- <ReactMarkdown remarkPlugins={[remarkGfm]}>
345
- {msg.content}
346
- </ReactMarkdown>
347
- ) : (
348
- msg.content
349
- )}
350
- </div>
351
  </div>
352
- ))}
353
- {isChatLoading && (
354
- <div className="flex justify-start">
355
- <div className="bg-gray-700 p-3 rounded-lg">
356
- <Loader2 className="animate-spin w-4 h-4 text-blue-400" />
357
- </div>
358
  </div>
359
- )}
360
- </div>
361
-
362
- <div className="p-4 border-t border-gray-700">
363
- <div className="flex gap-2">
364
- <input
365
- type="text"
366
- value={inputMessage}
367
- onChange={(e) => setInputMessage(e.target.value)}
368
- onKeyDown={(e) => e.key === "Enter" && handleSendMessage()}
369
- placeholder="Type your question..."
370
- className="flex-1 bg-gray-800 text-white rounded-lg px-4 py-2 border border-gray-700 focus:outline-none focus:border-blue-500"
371
- disabled={!sessionId || isChatLoading}
372
- />
373
- <button
374
- onClick={handleSendMessage}
375
- disabled={!sessionId || isChatLoading}
376
- className="bg-blue-600 p-2 rounded-lg text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
377
- >
378
- <Send size={20} />
379
- </button>
380
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </div>
382
- </>
383
- )}
384
- </div>
385
  </div>
386
  </div>
387
  );
 
9
  Loader2,
10
  FileText,
11
  MessageSquare,
12
+ GripVertical,
13
+ Trash2,
14
+ Edit2,
15
+ Check,
16
  } from "lucide-react";
17
  import {
18
  fetchNotes,
 
21
  createChatSession,
22
  streamChatRequest,
23
  fetchChatHistory,
24
+ fetchSessions,
25
+ deleteNote,
26
+ renameNote,
27
  type Note,
28
  type ChatMessage,
29
  } from "../api/notesService";
 
35
  const [isUploading, setIsUploading] = useState(false);
36
  const fileInputRef = useRef<HTMLInputElement>(null);
37
 
38
+ // --- Resizable Chat State ---
39
+ const [chatWidth, setChatWidth] = useState(450);
40
  const [isResizing, setIsResizing] = useState(false);
41
 
42
  // --- Data State ---
 
44
  const [currentNote, setCurrentNote] = useState<Note | null>(null);
45
  const [pdfUrl, setPdfUrl] = useState<string | null>(null);
46
 
47
+ // --- Edit/Rename State ---
48
+ const [editingNoteId, setEditingNoteId] = useState<number | null>(null);
49
+ const [editName, setEditName] = useState("");
50
+
51
  // --- Chat State ---
52
  const [messages, setMessages] = useState<ChatMessage[]>([]);
53
  const [inputMessage, setInputMessage] = useState("");
54
  const [sessionId, setSessionId] = useState<string | null>(null);
55
  const [isChatLoading, setIsChatLoading] = useState(false);
56
 
57
+ // --- Auto Scroll Ref ---
58
+ const messagesEndRef = useRef<HTMLDivElement>(null);
59
+
60
  useEffect(() => {
61
  loadNotes();
62
  }, []);
63
 
64
+ // --- FIX: Robust Auto-Scroll ---
65
+ const scrollToBottom = () => {
66
+ // "smooth" gets interrupted by rapid streaming updates.
67
+ // "instant" ensures it snaps to bottom every time a token arrives.
68
+ messagesEndRef.current?.scrollIntoView({
69
+ behavior: "instant",
70
+ block: "end",
71
+ });
72
+ };
73
+
74
+ useEffect(() => {
75
+ scrollToBottom();
76
+ }, [messages, isChatLoading]);
77
+
78
+ // --- Resizing Logic ---
79
  const startResizing = useCallback(() => setIsResizing(true), []);
80
  const stopResizing = useCallback(() => setIsResizing(false), []);
 
81
  const resize = useCallback(
82
  (mouseMoveEvent: MouseEvent) => {
83
  if (isResizing) {
 
84
  const newWidth = document.body.clientWidth - mouseMoveEvent.clientX;
85
+ if (newWidth > 300 && newWidth < 800) setChatWidth(newWidth);
 
 
86
  }
87
  },
88
  [isResizing]
 
107
  };
108
 
109
  const handleUploadClick = () => fileInputRef.current?.click();
 
110
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
111
  const file = e.target.files?.[0];
112
  if (!file) return;
 
113
  setIsUploading(true);
114
  try {
115
  const newNote = await uploadNote(file);
 
123
  }
124
  };
125
 
126
+ // --- Handle Delete ---
127
+ const handleDeleteNote = async (e: React.MouseEvent, noteId: number) => {
128
+ e.stopPropagation();
129
+ if (
130
+ !window.confirm(
131
+ "Are you sure you want to delete this note and its chat history?"
132
+ )
133
+ )
134
+ return;
135
+
136
+ try {
137
+ await deleteNote(noteId);
138
+ setNotes((prev) => prev.filter((n) => n.id !== noteId));
139
+ if (currentNote?.id === noteId) {
140
+ setCurrentNote(null);
141
+ setPdfUrl(null);
142
+ setMessages([]);
143
+ setSessionId(null);
144
+ }
145
+ } catch (error) {
146
+ console.error("Failed to delete note", error);
147
+ alert("Error deleting note");
148
+ }
149
+ };
150
+
151
+ // --- Handle Rename ---
152
+ const startEditing = (e: React.MouseEvent, note: Note) => {
153
+ e.stopPropagation();
154
+ setEditingNoteId(note.id);
155
+ setEditName(note.filename);
156
+ };
157
+
158
+ const saveRename = async (e: React.MouseEvent) => {
159
+ e.stopPropagation();
160
+ if (!editingNoteId || !editName.trim()) return;
161
+ try {
162
+ await renameNote(editingNoteId, editName);
163
+ setNotes((prev) =>
164
+ prev.map((n) =>
165
+ n.id === editingNoteId ? { ...n, filename: editName } : n
166
+ )
167
+ );
168
+ if (currentNote?.id === editingNoteId) {
169
+ setCurrentNote((prev) =>
170
+ prev ? { ...prev, filename: editName } : null
171
+ );
172
+ }
173
+ setEditingNoteId(null);
174
+ } catch (error) {
175
+ console.error("Failed to rename", error);
176
+ alert("Error renaming note");
177
+ }
178
+ };
179
+
180
+ const cancelRename = (e: React.MouseEvent) => {
181
+ e.stopPropagation();
182
+ setEditingNoteId(null);
183
+ };
184
+
185
  const handleNoteSelect = async (note: Note) => {
186
+ if (editingNoteId === note.id) return;
187
  setCurrentNote(note);
188
  setPdfUrl(null);
189
  setMessages([]);
190
  setSessionId(null);
191
+ setIsChatOpen(true);
192
 
 
193
  try {
194
  const blob = await fetchNoteBlob(note.id);
195
  const url = URL.createObjectURL(blob);
196
  setPdfUrl(url);
197
  } catch (error) {
198
+ console.error("Failed to load PDF", error);
199
  }
200
 
 
201
  try {
202
  const existingSessions = await fetchSessions(note.id);
 
203
  if (existingSessions.length > 0) {
 
204
  const lastSession = existingSessions[0];
205
  setSessionId(lastSession.id);
 
 
206
  const history = await fetchChatHistory(lastSession.id);
 
207
  const formattedHistory: ChatMessage[] = history.map((msg: any) => ({
208
  role: msg.role,
209
  content: msg.content,
210
  }));
211
  setMessages(formattedHistory);
212
  } else {
 
213
  const session = await createChatSession(
214
  note.id,
215
  `Chat - ${note.filename}`
 
223
  ]);
224
  }
225
  } catch (error) {
226
+ console.error("Failed to init chat", error);
227
  }
228
  };
229
 
 
230
  const handleSendMessage = async () => {
231
  if (!inputMessage.trim() || !sessionId) return;
 
232
  const userMsg = inputMessage;
233
  setInputMessage("");
 
234
  setMessages((prev) => [...prev, { role: "user", content: userMsg }]);
235
+ setIsChatLoading(true);
 
 
236
  setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
 
237
  try {
238
  await streamChatRequest(
239
  sessionId,
 
249
  return newArr;
250
  });
251
  },
252
+ (err) => console.error("Stream error", err)
 
 
 
253
  );
254
  } catch (e) {
255
  console.error("Chat Request Error", e);
256
  } finally {
 
257
  setIsChatLoading(false);
258
  }
259
  };
260
 
261
  return (
262
  <div className="flex bg-black h-screen overflow-hidden">
263
+ {/* --- Left Sidebar --- */}
264
  <div
265
  className={`h-screen shrink-0 transition-all duration-300 bg-gray-900 border-r border-gray-700 flex flex-col gap-4 ${
266
  isSidebarOpen ? "w-64 p-4" : "w-0 p-0 overflow-hidden"
 
290
  )}
291
  {isUploading ? "Uploading..." : "Upload New PDF"}
292
  </button>
293
+
294
  <div className="mt-4 pt-4 border-t border-gray-700 space-y-2 overflow-y-auto">
295
  <p className="text-sm text-gray-400 uppercase tracking-wider">
296
  History
297
  </p>
298
+
299
  {notes.map((note) => (
300
  <div
301
  key={note.id}
302
  onClick={() => handleNoteSelect(note)}
303
+ className={`group relative text-gray-200 p-3 rounded-md cursor-pointer flex items-center justify-between hover:bg-gray-700 transition ${
304
  currentNote?.id === note.id
305
  ? "bg-gray-800 border border-blue-500"
306
  : ""
307
  }`}
308
  >
309
+ {editingNoteId === note.id ? (
310
+ <div className="flex items-center gap-2 w-full">
311
+ <input
312
+ type="text"
313
+ value={editName}
314
+ onChange={(e) => setEditName(e.target.value)}
315
+ onClick={(e) => e.stopPropagation()}
316
+ className="flex-1 bg-gray-900 text-white text-xs px-2 py-1 rounded border border-blue-500 outline-none"
317
+ autoFocus
318
+ />
319
+ <button
320
+ onClick={saveRename}
321
+ className="text-green-400 hover:text-green-300 p-1"
322
+ >
323
+ <Check size={14} />
324
+ </button>
325
+ <button
326
+ onClick={cancelRename}
327
+ className="text-red-400 hover:text-red-300 p-1"
328
+ >
329
+ <X size={14} />
330
+ </button>
331
+ </div>
332
+ ) : (
333
+ <>
334
+ <div className="flex items-center gap-2 overflow-hidden">
335
+ <FileText
336
+ size={16}
337
+ className="text-blue-400 shrink-0"
338
+ />
339
+ <span className="truncate text-sm">
340
+ {note.filename}
341
+ </span>
342
+ </div>
343
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
344
+ <button
345
+ onClick={(e) => startEditing(e, note)}
346
+ className="p-1.5 hover:bg-gray-600 rounded text-gray-400 hover:text-white"
347
+ title="Rename"
348
+ >
349
+ <Edit2 size={14} />
350
+ </button>
351
+ <button
352
+ onClick={(e) => handleDeleteNote(e, note.id)}
353
+ className="p-1.5 hover:bg-red-900/50 rounded text-gray-400 hover:text-red-400"
354
+ title="Delete"
355
+ >
356
+ <Trash2 size={14} />
357
+ </button>
358
+ </div>
359
+ </>
360
+ )}
361
  </div>
362
  ))}
363
  </div>
 
366
  </div>
367
 
368
  {/* --- Center: PDF Viewer --- */}
369
+ <div className="flex flex-1 overflow-hidden relative flex-col">
370
+ <header className="flex justify-between items-center p-4 bg-gray-900 border-b border-gray-700 shrink-0 z-10">
371
+ <div className="flex items-center gap-3">
372
  <button
373
  onClick={() => setIsSidebarOpen(!isSidebarOpen)}
374
+ className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white transition"
375
  >
376
+ <Menu size={20} />
377
  </button>
378
  <h2 className="text-lg font-semibold text-white truncate max-w-md">
379
  {currentNote ? currentNote.filename : "Select a Note"}
380
  </h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </div>
382
+ {!isChatOpen && currentNote && (
383
+ <button
384
+ onClick={() => setIsChatOpen(true)}
385
+ className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition"
386
+ >
387
+ <MessageSquare size={18} /> Open Chat
388
+ </button>
389
+ )}
390
+ </header>
391
 
392
+ <div className="flex-1 w-full h-full bg-gray-800 relative">
393
+ {pdfUrl ? (
394
+ <iframe
395
+ src={pdfUrl}
396
+ className={`w-full h-full border-none ${
397
+ isResizing ? "pointer-events-none" : ""
398
+ }`}
399
+ title="PDF Viewer"
400
+ />
401
+ ) : (
402
+ <div className="flex flex-col items-center justify-center h-full text-gray-400">
403
+ <FileText size={64} className="mb-4 opacity-50" />
404
+ <p>Select a PDF from the sidebar to view</p>
405
+ </div>
406
+ )}
407
+ </div>
408
+ </div>
409
 
410
+ {/* --- Resizable Chat Panel --- */}
411
+ {isChatOpen && (
412
  <div
413
+ className="w-1.5 hover:w-2 cursor-col-resize bg-gray-800 hover:bg-blue-500 transition-all z-20 flex items-center justify-center shrink-0"
414
+ onMouseDown={startResizing}
415
  >
416
+ <GripVertical size={16} className="text-gray-500" />
417
+ </div>
418
+ )}
419
+
420
+ <div
421
+ style={{ width: isChatOpen ? chatWidth : 0 }}
422
+ className={`flex flex-col bg-gray-900 border-l border-gray-700 shrink-0 transition-all duration-100 ease-linear overflow-hidden`}
423
+ >
424
+ {isChatOpen && (
425
+ <>
426
+ <header className="flex justify-between items-center p-4 border-b border-gray-700 bg-gray-900 shrink-0">
427
+ <div className="flex items-center gap-2">
428
+ <MessageSquare size={18} className="text-blue-500" />
429
+ <h3 className="text-lg font-bold text-white whitespace-nowrap">
430
+ AI Chat
431
+ </h3>
432
+ </div>
433
+ <button
434
+ onClick={() => setIsChatOpen(false)}
435
+ className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
436
+ >
437
+ <X size={20} />
438
+ </button>
439
+ </header>
440
+
441
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
442
+ {messages.length === 0 && (
443
+ <div className="text-center text-gray-500 mt-10">
444
+ <p>Ask a question about this document...</p>
445
+ </div>
446
+ )}
447
+ {messages.map((msg, i) => (
448
+ <div
449
+ key={i}
450
+ className={`flex ${
451
+ msg.role === "user" ? "justify-end" : "justify-start"
452
+ }`}
453
  >
 
 
 
 
 
 
 
 
 
 
 
454
  <div
455
+ className={`max-w-[85%] p-3 rounded-lg text-sm ${
456
+ msg.role === "user"
457
+ ? "bg-blue-600 text-white"
458
+ : "bg-gray-700 text-gray-200 prose prose-invert prose-sm max-w-none"
459
  }`}
460
  >
461
+ {msg.role === "assistant" ? (
462
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
463
+ {msg.content}
464
+ </ReactMarkdown>
465
+ ) : (
466
+ msg.content
467
+ )}
 
 
 
 
 
 
 
 
468
  </div>
469
+ </div>
470
+ ))}
471
+ {isChatLoading && (
472
+ <div className="flex justify-start">
473
+ <div className="bg-gray-700 p-3 rounded-lg">
474
+ <Loader2 className="animate-spin w-4 h-4 text-blue-400" />
475
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  </div>
477
+ )}
478
+ {/* Auto Scroll Anchor */}
479
+ <div ref={messagesEndRef} />
480
+ </div>
481
+
482
+ <div className="p-4 border-t border-gray-700 shrink-0">
483
+ <div className="flex gap-2">
484
+ <input
485
+ type="text"
486
+ value={inputMessage}
487
+ onChange={(e) => setInputMessage(e.target.value)}
488
+ onKeyDown={(e) => e.key === "Enter" && handleSendMessage()}
489
+ placeholder="Type your question..."
490
+ className="flex-1 bg-gray-800 text-white rounded-lg px-4 py-2 border border-gray-700 focus:outline-none focus:border-blue-500 placeholder-gray-500"
491
+ disabled={!sessionId || isChatLoading}
492
+ />
493
+ <button
494
+ onClick={handleSendMessage}
495
+ disabled={!sessionId || isChatLoading}
496
+ className="bg-blue-600 p-2 rounded-lg text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
497
+ >
498
+ <Send size={20} />
499
+ </button>
500
  </div>
501
+ </div>
502
+ </>
503
+ )}
504
  </div>
505
  </div>
506
  );