Helquin commited on
Commit
d212ba6
·
1 Parent(s): f6d96e2

added Skeleton Animations , regular animations, more toasts, folder support, moving files within UI, start script for windows for back end and front end

Browse files
backend/main.py CHANGED
@@ -1,5 +1,14 @@
 
 
 
 
 
 
 
1
  def main():
2
- print("Hello from backend!")
 
 
3
 
4
 
5
  if __name__ == "__main__":
 
1
+ """Main entry point for FastAPI application."""
2
+
3
+ from src.api.main import app
4
+
5
+ __all__ = ["app"]
6
+
7
+
8
  def main():
9
+ """Run the development server (for manual testing)."""
10
+ import uvicorn
11
+ uvicorn.run(app, host="0.0.0.0", port=8000)
12
 
13
 
14
  if __name__ == "__main__":
backend/src/api/routes/notes.py CHANGED
@@ -325,5 +325,99 @@ async def update_note(path: str, update: NoteUpdate):
325
  raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}")
326
 
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  __all__ = ["router", "ConflictError"]
329
 
 
325
  raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}")
326
 
327
 
328
+ from pydantic import BaseModel
329
+
330
+
331
+ class NoteMoveRequest(BaseModel):
332
+ """Request payload for moving/renaming a note."""
333
+ new_path: str
334
+
335
+
336
+ @router.patch("/api/notes/{path:path}", response_model=Note)
337
+ async def move_note(path: str, move_request: NoteMoveRequest):
338
+ """Move or rename a note to a new path."""
339
+ user_id = get_user_id()
340
+ vault_service = VaultService()
341
+ indexer_service = IndexerService()
342
+ db_service = DatabaseService()
343
+
344
+ try:
345
+ # URL decode the old path
346
+ old_path = unquote(path)
347
+ new_path = move_request.new_path
348
+
349
+ # Move the note in the vault
350
+ moved_note = vault_service.move_note(user_id, old_path, new_path)
351
+
352
+ # Delete old note index entries
353
+ conn = db_service.connect()
354
+ try:
355
+ with conn:
356
+ # Delete from all index tables
357
+ conn.execute("DELETE FROM note_metadata WHERE user_id = ? AND note_path = ?", (user_id, old_path))
358
+ conn.execute("DELETE FROM note_links WHERE user_id = ? AND source_path = ?", (user_id, old_path))
359
+ conn.execute("DELETE FROM note_tags WHERE user_id = ? AND note_path = ?", (user_id, old_path))
360
+ finally:
361
+ conn.close()
362
+
363
+ # Index the note at new location
364
+ new_version = indexer_service.index_note(user_id, moved_note)
365
+
366
+ # Update index health
367
+ conn = db_service.connect()
368
+ try:
369
+ with conn:
370
+ indexer_service.update_index_health(conn, user_id)
371
+ finally:
372
+ conn.close()
373
+
374
+ # Parse metadata
375
+ metadata = moved_note.get("metadata", {})
376
+ created = metadata.get("created")
377
+ updated = metadata.get("updated")
378
+
379
+ # Parse created timestamp
380
+ try:
381
+ if isinstance(created, str):
382
+ created = datetime.fromisoformat(created.replace("Z", "+00:00"))
383
+ elif isinstance(created, datetime):
384
+ pass # Already a datetime
385
+ else:
386
+ created = datetime.now()
387
+ except (ValueError, TypeError):
388
+ created = datetime.now()
389
+
390
+ # Parse updated timestamp
391
+ try:
392
+ if isinstance(updated, str):
393
+ updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
394
+ elif isinstance(updated, datetime):
395
+ pass # Already a datetime
396
+ else:
397
+ updated = created
398
+ except (ValueError, TypeError):
399
+ updated = created
400
+
401
+ return Note(
402
+ user_id=user_id,
403
+ note_path=new_path,
404
+ version=new_version,
405
+ title=moved_note["title"],
406
+ metadata=metadata,
407
+ body=moved_note["body"],
408
+ created=created,
409
+ updated=updated,
410
+ size_bytes=moved_note.get("size_bytes", len(moved_note["body"].encode("utf-8"))),
411
+ )
412
+ except FileNotFoundError:
413
+ raise HTTPException(status_code=404, detail=f"Note not found: {path}")
414
+ except FileExistsError as e:
415
+ raise HTTPException(status_code=409, detail=str(e))
416
+ except ValueError as e:
417
+ raise HTTPException(status_code=400, detail=str(e))
418
+ except Exception as e:
419
+ raise HTTPException(status_code=500, detail=f"Failed to move note: {str(e)}")
420
+
421
+
422
  __all__ = ["router", "ConflictError"]
423
 
backend/src/services/vault.py CHANGED
@@ -208,11 +208,11 @@ class VaultService:
208
  def delete_note(self, user_id: str, note_path: str) -> None:
209
  """Delete a note from the vault."""
210
  start_time = time.time()
211
-
212
  absolute_path = self.resolve_note_path(user_id, note_path)
213
  try:
214
  absolute_path.unlink()
215
-
216
  duration_ms = (time.time() - start_time) * 1000
217
  logger.info(
218
  "Note deleted successfully",
@@ -230,6 +230,54 @@ class VaultService:
230
  )
231
  raise FileNotFoundError(f"Note not found: {note_path}") from exc
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def list_notes(self, user_id: str, folder: str | None = None) -> List[Dict[str, Any]]:
234
  """List notes (optionally scoped to a folder) with titles and timestamps."""
235
  base = self.initialize_vault(user_id).resolve()
 
208
  def delete_note(self, user_id: str, note_path: str) -> None:
209
  """Delete a note from the vault."""
210
  start_time = time.time()
211
+
212
  absolute_path = self.resolve_note_path(user_id, note_path)
213
  try:
214
  absolute_path.unlink()
215
+
216
  duration_ms = (time.time() - start_time) * 1000
217
  logger.info(
218
  "Note deleted successfully",
 
230
  )
231
  raise FileNotFoundError(f"Note not found: {note_path}") from exc
232
 
233
+ def move_note(self, user_id: str, old_path: str, new_path: str) -> VaultNote:
234
+ """Move or rename a note to a new path."""
235
+ start_time = time.time()
236
+
237
+ # Validate both paths
238
+ is_valid_old, msg_old = validate_note_path(old_path)
239
+ if not is_valid_old:
240
+ raise ValueError(f"Invalid source path: {msg_old}")
241
+
242
+ is_valid_new, msg_new = validate_note_path(new_path)
243
+ if not is_valid_new:
244
+ raise ValueError(f"Invalid destination path: {msg_new}")
245
+
246
+ # Resolve absolute paths
247
+ old_absolute = self.resolve_note_path(user_id, old_path)
248
+ new_absolute = self.resolve_note_path(user_id, new_path)
249
+
250
+ # Check if source exists
251
+ if not old_absolute.exists():
252
+ raise FileNotFoundError(f"Source note not found: {old_path}")
253
+
254
+ # Check if destination already exists
255
+ if new_absolute.exists():
256
+ raise FileExistsError(f"Destination note already exists: {new_path}")
257
+
258
+ # Create destination directory if needed
259
+ new_absolute.parent.mkdir(parents=True, exist_ok=True)
260
+
261
+ # Move the file
262
+ old_absolute.rename(new_absolute)
263
+
264
+ # Read and return the note from new location
265
+ note = self.read_note(user_id, new_path)
266
+
267
+ duration_ms = (time.time() - start_time) * 1000
268
+ logger.info(
269
+ "Note moved successfully",
270
+ extra={
271
+ "user_id": user_id,
272
+ "old_path": old_path,
273
+ "new_path": new_path,
274
+ "operation": "move",
275
+ "duration_ms": f"{duration_ms:.2f}"
276
+ }
277
+ )
278
+
279
+ return note
280
+
281
  def list_notes(self, user_id: str, folder: str | None = None) -> List[Dict[str, Any]]:
282
  """List notes (optionally scoped to a folder) with titles and timestamps."""
283
  base = self.initialize_vault(user_id).resolve()
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import { MainApp } from './pages/MainApp';
4
  import { Login } from './pages/Login';
5
  import { Settings } from './pages/Settings';
6
  import { isAuthenticated, getCurrentUser } from './services/auth';
 
7
  import { Toaster } from './components/ui/toaster';
8
  import './App.css';
9
 
@@ -48,11 +49,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
48
  }
49
 
50
  if (isChecking) {
51
- return (
52
- <div className="h-screen flex items-center justify-center bg-background">
53
- <div className="text-muted-foreground">Loading...</div>
54
- </div>
55
- );
56
  }
57
 
58
  return <>{children}</>;
 
4
  import { Login } from './pages/Login';
5
  import { Settings } from './pages/Settings';
6
  import { isAuthenticated, getCurrentUser } from './services/auth';
7
+ import { AuthLoadingSkeleton } from './components/AuthLoadingSkeleton';
8
  import { Toaster } from './components/ui/toaster';
9
  import './App.css';
10
 
 
49
  }
50
 
51
  if (isChecking) {
52
+ return <AuthLoadingSkeleton />;
 
 
 
 
53
  }
54
 
55
  return <>{children}</>;
frontend/src/components/AuthLoadingSkeleton.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ export function AuthLoadingSkeleton() {
4
+ return (
5
+ <div className="h-screen flex flex-col items-center justify-center bg-background">
6
+ <div className="space-y-6 max-w-md w-full px-6">
7
+ {/* Logo/header area */}
8
+ <div className="flex flex-col items-center gap-4">
9
+ <Skeleton className="h-12 w-12 rounded-full" />
10
+ <Skeleton className="h-6 w-48" />
11
+ </div>
12
+
13
+ {/* Loading animation indicator */}
14
+ <div className="flex items-center justify-center gap-2">
15
+ <div className="h-2 w-2 bg-muted rounded-full animate-skeleton-pulse" />
16
+ <div className="h-2 w-2 bg-muted rounded-full animate-skeleton-pulse" style={{ animationDelay: '0.4s' }} />
17
+ <div className="h-2 w-2 bg-muted rounded-full animate-skeleton-pulse" style={{ animationDelay: '0.8s' }} />
18
+ </div>
19
+
20
+ {/* Subtle loading text */}
21
+ <div className="text-center">
22
+ <Skeleton className="h-4 w-32 mx-auto" />
23
+ </div>
24
+ </div>
25
+ </div>
26
+ );
27
+ }
frontend/src/components/DirectoryTree.tsx CHANGED
@@ -20,6 +20,7 @@ interface DirectoryTreeProps {
20
  notes: NoteSummary[];
21
  selectedPath?: string;
22
  onSelectNote: (path: string) => void;
 
23
  }
24
 
25
  /**
@@ -89,10 +90,46 @@ interface TreeNodeItemProps {
89
  depth: number;
90
  selectedPath?: string;
91
  onSelectNote: (path: string) => void;
 
92
  }
93
 
94
- function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemProps) {
95
  const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  if (node.type === 'folder') {
98
  return (
@@ -101,10 +138,14 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP
101
  variant="ghost"
102
  className={cn(
103
  "w-full justify-start font-normal px-2 h-8",
104
- "hover:bg-accent"
 
105
  )}
106
  style={{ paddingLeft: `${depth * 12 + 8}px` }}
107
  onClick={() => setIsOpen(!isOpen)}
 
 
 
108
  >
109
  {isOpen ? (
110
  <ChevronDown className="h-4 w-4 mr-1 shrink-0" />
@@ -123,6 +164,7 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP
123
  depth={depth + 1}
124
  selectedPath={selectedPath}
125
  onSelectNote={onSelectNote}
 
126
  />
127
  ))}
128
  </div>
@@ -141,11 +183,14 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP
141
  variant="ghost"
142
  className={cn(
143
  "w-full justify-start font-normal px-2 h-8",
144
- "hover:bg-accent",
145
- isSelected && "bg-accent"
 
146
  )}
147
  style={{ paddingLeft: `${depth * 12 + 8}px` }}
148
  onClick={() => onSelectNote(node.path)}
 
 
149
  >
150
  <File className="h-4 w-4 mr-2 shrink-0 text-muted-foreground" />
151
  <span className="truncate">{displayName}</span>
@@ -153,7 +198,7 @@ function TreeNodeItem({ node, depth, selectedPath, onSelectNote }: TreeNodeItemP
153
  );
154
  }
155
 
156
- export function DirectoryTree({ notes, selectedPath, onSelectNote }: DirectoryTreeProps) {
157
  const tree = useMemo(() => buildTree(notes), [notes]);
158
 
159
  if (notes.length === 0) {
@@ -174,6 +219,7 @@ export function DirectoryTree({ notes, selectedPath, onSelectNote }: DirectoryTr
174
  depth={0}
175
  selectedPath={selectedPath}
176
  onSelectNote={onSelectNote}
 
177
  />
178
  ))}
179
  </div>
 
20
  notes: NoteSummary[];
21
  selectedPath?: string;
22
  onSelectNote: (path: string) => void;
23
+ onMoveNote?: (oldPath: string, newFolderPath: string) => void;
24
  }
25
 
26
  /**
 
90
  depth: number;
91
  selectedPath?: string;
92
  onSelectNote: (path: string) => void;
93
+ onMoveNote?: (oldPath: string, newFolderPath: string) => void;
94
  }
95
 
96
+ function TreeNodeItem({ node, depth, selectedPath, onSelectNote, onMoveNote }: TreeNodeItemProps) {
97
  const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels
98
+ const [isDragOver, setIsDragOver] = useState(false);
99
+
100
+ const handleDragOver = (e: React.DragEvent) => {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ if (node.type === 'folder') {
104
+ setIsDragOver(true);
105
+ }
106
+ };
107
+
108
+ const handleDragLeave = (e: React.DragEvent) => {
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ setIsDragOver(false);
112
+ };
113
+
114
+ const handleDrop = (e: React.DragEvent) => {
115
+ e.preventDefault();
116
+ e.stopPropagation();
117
+ setIsDragOver(false);
118
+
119
+ if (node.type === 'folder') {
120
+ const draggedPath = e.dataTransfer.getData('application/note-path');
121
+ if (draggedPath && onMoveNote) {
122
+ onMoveNote(draggedPath, node.path);
123
+ }
124
+ }
125
+ };
126
+
127
+ const handleDragStart = (e: React.DragEvent) => {
128
+ if (node.type === 'file') {
129
+ e.dataTransfer.effectAllowed = 'move';
130
+ e.dataTransfer.setData('application/note-path', node.path);
131
+ }
132
+ };
133
 
134
  if (node.type === 'folder') {
135
  return (
 
138
  variant="ghost"
139
  className={cn(
140
  "w-full justify-start font-normal px-2 h-8",
141
+ "hover:bg-accent transition-colors duration-200",
142
+ isDragOver && "bg-accent ring-2 ring-primary"
143
  )}
144
  style={{ paddingLeft: `${depth * 12 + 8}px` }}
145
  onClick={() => setIsOpen(!isOpen)}
146
+ onDragOver={handleDragOver}
147
+ onDragLeave={handleDragLeave}
148
+ onDrop={handleDrop}
149
  >
150
  {isOpen ? (
151
  <ChevronDown className="h-4 w-4 mr-1 shrink-0" />
 
164
  depth={depth + 1}
165
  selectedPath={selectedPath}
166
  onSelectNote={onSelectNote}
167
+ onMoveNote={onMoveNote}
168
  />
169
  ))}
170
  </div>
 
183
  variant="ghost"
184
  className={cn(
185
  "w-full justify-start font-normal px-2 h-8",
186
+ "hover:bg-accent transition-colors duration-200",
187
+ isSelected && "bg-accent animate-highlight-pulse",
188
+ "cursor-move"
189
  )}
190
  style={{ paddingLeft: `${depth * 12 + 8}px` }}
191
  onClick={() => onSelectNote(node.path)}
192
+ draggable
193
+ onDragStart={handleDragStart}
194
  >
195
  <File className="h-4 w-4 mr-2 shrink-0 text-muted-foreground" />
196
  <span className="truncate">{displayName}</span>
 
198
  );
199
  }
200
 
201
+ export function DirectoryTree({ notes, selectedPath, onSelectNote, onMoveNote }: DirectoryTreeProps) {
202
  const tree = useMemo(() => buildTree(notes), [notes]);
203
 
204
  if (notes.length === 0) {
 
219
  depth={0}
220
  selectedPath={selectedPath}
221
  onSelectNote={onSelectNote}
222
+ onMoveNote={onMoveNote}
223
  />
224
  ))}
225
  </div>
frontend/src/components/DirectoryTreeSkeleton.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ export function DirectoryTreeSkeleton() {
4
+ return (
5
+ <div className="space-y-3 p-4">
6
+ {/* Main folders */}
7
+ {[1, 2, 3].map((i) => (
8
+ <div key={i} className="space-y-2">
9
+ {/* Folder item */}
10
+ <div className="flex items-center gap-2">
11
+ <Skeleton className="h-4 w-4" />
12
+ <Skeleton className="h-4 w-32" />
13
+ </div>
14
+
15
+ {/* Sub-items */}
16
+ {[1, 2].map((j) => (
17
+ <div key={j} className="ml-4 flex items-center gap-2">
18
+ <Skeleton className="h-4 w-4" />
19
+ <Skeleton className="h-4 w-28" />
20
+ </div>
21
+ ))}
22
+ </div>
23
+ ))}
24
+ </div>
25
+ );
26
+ }
frontend/src/components/NoteViewer.tsx CHANGED
@@ -50,13 +50,13 @@ export function NoteViewer({
50
  return (
51
  <div className="flex flex-col h-full">
52
  {/* Header */}
53
- <div className="border-b border-border p-4">
54
  <div className="flex items-start justify-between gap-4">
55
  <div className="flex-1 min-w-0">
56
- <h1 className="text-3xl font-bold truncate">{note.title}</h1>
57
- <p className="text-sm text-muted-foreground mt-1">{note.note_path}</p>
58
  </div>
59
- <div className="flex gap-2">
60
  {onEdit && (
61
  <Button variant="outline" size="sm" onClick={onEdit}>
62
  <Edit className="h-4 w-4 mr-2" />
@@ -74,7 +74,7 @@ export function NoteViewer({
74
 
75
  {/* Content */}
76
  <ScrollArea className="flex-1 p-6">
77
- <div className="prose prose-slate dark:prose-invert max-w-none">
78
  <ReactMarkdown
79
  remarkPlugins={[remarkGfm]}
80
  components={markdownComponents}
@@ -86,7 +86,7 @@ export function NoteViewer({
86
  <Separator className="my-8" />
87
 
88
  {/* Metadata Footer */}
89
- <div className="space-y-4 text-sm">
90
  {/* Tags */}
91
  {note.metadata.tags && note.metadata.tags.length > 0 && (
92
  <div className="flex items-start gap-2">
 
50
  return (
51
  <div className="flex flex-col h-full">
52
  {/* Header */}
53
+ <div className="border-b border-border p-4 animate-fade-in">
54
  <div className="flex items-start justify-between gap-4">
55
  <div className="flex-1 min-w-0">
56
+ <h1 className="text-3xl font-bold truncate animate-slide-in-up" style={{ animationDelay: '0.1s' }}>{note.title}</h1>
57
+ <p className="text-sm text-muted-foreground mt-1 animate-fade-in" style={{ animationDelay: '0.2s' }}>{note.note_path}</p>
58
  </div>
59
+ <div className="flex gap-2 animate-fade-in" style={{ animationDelay: '0.15s' }}>
60
  {onEdit && (
61
  <Button variant="outline" size="sm" onClick={onEdit}>
62
  <Edit className="h-4 w-4 mr-2" />
 
74
 
75
  {/* Content */}
76
  <ScrollArea className="flex-1 p-6">
77
+ <div className="prose prose-slate dark:prose-invert max-w-none animate-fade-in" style={{ animationDelay: '0.3s' }}>
78
  <ReactMarkdown
79
  remarkPlugins={[remarkGfm]}
80
  components={markdownComponents}
 
86
  <Separator className="my-8" />
87
 
88
  {/* Metadata Footer */}
89
+ <div className="space-y-4 text-sm animate-fade-in" style={{ animationDelay: '0.4s' }}>
90
  {/* Tags */}
91
  {note.metadata.tags && note.metadata.tags.length > 0 && (
92
  <div className="flex items-start gap-2">
frontend/src/components/NoteViewerSkeleton.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ export function NoteViewerSkeleton() {
4
+ return (
5
+ <div className="space-y-6 p-6">
6
+ {/* Title skeleton */}
7
+ <Skeleton className="h-8 w-3/4" />
8
+
9
+ {/* Metadata skeleton */}
10
+ <div className="flex gap-4 text-sm text-muted-foreground">
11
+ <div className="flex items-center gap-2">
12
+ <Skeleton className="h-4 w-16" />
13
+ <Skeleton className="h-4 w-24" />
14
+ </div>
15
+ <div className="flex items-center gap-2">
16
+ <Skeleton className="h-4 w-16" />
17
+ <Skeleton className="h-4 w-24" />
18
+ </div>
19
+ </div>
20
+
21
+ {/* Divider */}
22
+ <Skeleton className="h-px w-full" />
23
+
24
+ {/* Content skeleton - multiple lines */}
25
+ <div className="space-y-3">
26
+ {[1, 2, 3, 4, 5].map((i) => (
27
+ <div key={i} className="space-y-2">
28
+ <Skeleton className="h-4 w-full" />
29
+ <Skeleton className="h-4 w-5/6" />
30
+ </div>
31
+ ))}
32
+ </div>
33
+
34
+ {/* Backlinks section skeleton */}
35
+ <div className="border-t pt-6">
36
+ <Skeleton className="h-5 w-32 mb-4" />
37
+ <div className="space-y-2">
38
+ {[1, 2].map((i) => (
39
+ <Skeleton key={i} className="h-4 w-full" />
40
+ ))}
41
+ </div>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
frontend/src/components/SearchBar.tsx CHANGED
@@ -4,6 +4,7 @@
4
  import { useState, useEffect, useCallback } from 'react';
5
  import { Search, X } from 'lucide-react';
6
  import { Input } from '@/components/ui/input';
 
7
  import {
8
  Command,
9
  CommandEmpty,
@@ -13,6 +14,7 @@ import {
13
  } from '@/components/ui/command';
14
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
15
  import { Button } from '@/components/ui/button';
 
16
  import { searchNotes } from '@/services/api';
17
  import type { SearchResult } from '@/types/search';
18
 
@@ -107,16 +109,19 @@ export function SearchBar({ onSelectNote }: SearchBarProps) {
107
  </div>
108
 
109
  {isOpen && results.length > 0 && (
110
- <div className="absolute top-full left-0 right-0 mt-1 z-50">
111
  <div className="bg-popover border border-border rounded-md shadow-md max-h-[400px] overflow-auto">
112
  <Command>
113
  <CommandList>
114
  <CommandGroup heading={`${results.length} result${results.length !== 1 ? 's' : ''}`}>
115
- {results.map((result) => (
116
  <CommandItem
117
  key={result.note_path}
118
  onSelect={() => handleSelectResult(result.note_path)}
119
- className="cursor-pointer"
 
 
 
120
  >
121
  <div className="flex flex-col gap-1 w-full">
122
  <div className="font-medium">{result.title}</div>
@@ -138,8 +143,8 @@ export function SearchBar({ onSelectNote }: SearchBarProps) {
138
 
139
  {isLoading && (
140
  <div className="absolute top-full left-0 right-0 mt-1 z-50">
141
- <div className="bg-popover border border-border rounded-md shadow-md p-4 text-center text-sm text-muted-foreground">
142
- Searching...
143
  </div>
144
  </div>
145
  )}
 
4
  import { useState, useEffect, useCallback } from 'react';
5
  import { Search, X } from 'lucide-react';
6
  import { Input } from '@/components/ui/input';
7
+ import { cn } from '@/lib/utils';
8
  import {
9
  Command,
10
  CommandEmpty,
 
14
  } from '@/components/ui/command';
15
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
16
  import { Button } from '@/components/ui/button';
17
+ import { SearchResultSkeleton } from '@/components/SearchResultSkeleton';
18
  import { searchNotes } from '@/services/api';
19
  import type { SearchResult } from '@/types/search';
20
 
 
109
  </div>
110
 
111
  {isOpen && results.length > 0 && (
112
+ <div className="absolute top-full left-0 right-0 mt-1 z-50 animate-slide-in-down">
113
  <div className="bg-popover border border-border rounded-md shadow-md max-h-[400px] overflow-auto">
114
  <Command>
115
  <CommandList>
116
  <CommandGroup heading={`${results.length} result${results.length !== 1 ? 's' : ''}`}>
117
+ {results.map((result, index) => (
118
  <CommandItem
119
  key={result.note_path}
120
  onSelect={() => handleSelectResult(result.note_path)}
121
+ className={cn(
122
+ "cursor-pointer",
123
+ index < 5 && `animate-stagger-${index + 1}`
124
+ )}
125
  >
126
  <div className="flex flex-col gap-1 w-full">
127
  <div className="font-medium">{result.title}</div>
 
143
 
144
  {isLoading && (
145
  <div className="absolute top-full left-0 right-0 mt-1 z-50">
146
+ <div className="bg-popover border border-border rounded-md shadow-md p-3">
147
+ <SearchResultSkeleton />
148
  </div>
149
  </div>
150
  )}
frontend/src/components/SearchResultSkeleton.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ export function SearchResultSkeleton() {
4
+ return (
5
+ <div className="space-y-2">
6
+ {[1, 2, 3].map((i) => (
7
+ <div key={i} className="p-3 border border-border rounded-md space-y-2">
8
+ {/* Result title */}
9
+ <Skeleton className="h-4 w-2/3" />
10
+ {/* Result path */}
11
+ <Skeleton className="h-3 w-1/2" />
12
+ {/* Result snippet */}
13
+ <Skeleton className="h-3 w-full" />
14
+ <Skeleton className="h-3 w-4/5" />
15
+ </div>
16
+ ))}
17
+ </div>
18
+ );
19
+ }
frontend/src/components/SettingsSectionSkeleton.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2
+ import { Skeleton } from "@/components/ui/skeleton";
3
+
4
+ interface SettingsSectionSkeletonProps {
5
+ title: string;
6
+ description?: string;
7
+ }
8
+
9
+ export function SettingsSectionSkeleton({ title, description }: SettingsSectionSkeletonProps) {
10
+ return (
11
+ <Card>
12
+ <CardHeader>
13
+ <CardTitle>{title}</CardTitle>
14
+ {description && <CardDescription>{description}</CardDescription>}
15
+ </CardHeader>
16
+ <CardContent className="space-y-4">
17
+ {/* Profile skeleton */}
18
+ <div className="flex items-center gap-4">
19
+ <Skeleton className="h-16 w-16 rounded-full" />
20
+ <div className="flex-1 space-y-2">
21
+ <Skeleton className="h-5 w-48" />
22
+ <Skeleton className="h-4 w-40" />
23
+ <Skeleton className="h-3 w-36" />
24
+ </div>
25
+ <Skeleton className="h-9 w-20" />
26
+ </div>
27
+ </CardContent>
28
+ </Card>
29
+ );
30
+ }
frontend/src/components/ui/button.tsx CHANGED
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
5
  import { cn } from "@/lib/utils"
6
 
7
  const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
  {
10
  variants: {
11
  variant: {
 
5
  import { cn } from "@/lib/utils"
6
 
7
  const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
  {
10
  variants: {
11
  variant: {
frontend/src/components/ui/skeleton.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils"
2
+
3
+ function Skeleton({
4
+ className,
5
+ ...props
6
+ }: React.HTMLAttributes<HTMLDivElement>) {
7
+ return (
8
+ <div
9
+ className={cn("animate-skeleton-pulse rounded-md bg-muted", className)}
10
+ {...props}
11
+ />
12
+ )
13
+ }
14
+
15
+ export { Skeleton }
frontend/src/index.css CHANGED
@@ -58,3 +58,22 @@
58
  font-feature-settings: "rlig" 1, "calt" 1;
59
  }
60
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  font-feature-settings: "rlig" 1, "calt" 1;
59
  }
60
  }
61
+
62
+ @layer utilities {
63
+ /* Stagger animations for list items */
64
+ .animate-stagger-1 {
65
+ animation: fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
66
+ }
67
+ .animate-stagger-2 {
68
+ animation: fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s backwards;
69
+ }
70
+ .animate-stagger-3 {
71
+ animation: fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.4s backwards;
72
+ }
73
+ .animate-stagger-4 {
74
+ animation: fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s backwards;
75
+ }
76
+ .animate-stagger-5 {
77
+ animation: fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.8s backwards;
78
+ }
79
+ }
frontend/src/pages/MainApp.tsx CHANGED
@@ -4,14 +4,16 @@
4
  */
5
  import { useState, useEffect } from 'react';
6
  import { useNavigate } from 'react-router-dom';
7
- import { Plus, Settings as SettingsIcon } from 'lucide-react';
8
  import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
9
  import { Button } from '@/components/ui/button';
10
  import { Separator } from '@/components/ui/separator';
11
  import { Alert, AlertDescription } from '@/components/ui/alert';
12
  import { DirectoryTree } from '@/components/DirectoryTree';
 
13
  import { SearchBar } from '@/components/SearchBar';
14
  import { NoteViewer } from '@/components/NoteViewer';
 
15
  import { NoteEditor } from '@/components/NoteEditor';
16
  import { useToast } from '@/hooks/useToast';
17
  import {
@@ -20,6 +22,7 @@ import {
20
  getBacklinks,
21
  getIndexHealth,
22
  createNote,
 
23
  type BacklinkResult,
24
  APIException,
25
  } from '@/services/api';
@@ -53,6 +56,9 @@ export function MainApp() {
53
  const [isNewNoteDialogOpen, setIsNewNoteDialogOpen] = useState(false);
54
  const [newNoteName, setNewNoteName] = useState('');
55
  const [isCreatingNote, setIsCreatingNote] = useState(false);
 
 
 
56
 
57
  // T083: Load directory tree on mount
58
  // T119: Load index health
@@ -175,7 +181,7 @@ export function MainApp() {
175
  setIsEditMode(false);
176
  };
177
 
178
- // Handle dialog open change
179
  const handleDialogOpenChange = (open: boolean) => {
180
  setIsNewNoteDialogOpen(open);
181
  if (!open) {
@@ -184,6 +190,15 @@ export function MainApp() {
184
  }
185
  };
186
 
 
 
 
 
 
 
 
 
 
187
  // Handle create new note
188
  const handleCreateNote = async () => {
189
  if (!newNoteName.trim() || isCreatingNote) return;
@@ -248,10 +263,126 @@ export function MainApp() {
248
  }
249
  };
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  return (
252
  <div className="h-screen flex flex-col">
253
  {/* Top bar */}
254
- <div className="border-b border-border p-4">
255
  <div className="flex items-center justify-between">
256
  <h1 className="text-xl font-semibold">📚 Document Viewer</h1>
257
  <Button variant="ghost" size="sm" onClick={() => navigate('/settings')}>
@@ -261,7 +392,7 @@ export function MainApp() {
261
  </div>
262
 
263
  {/* Main content */}
264
- <div className="flex-1 overflow-hidden">
265
  <ResizablePanelGroup direction="horizontal">
266
  {/* Left sidebar */}
267
  <ResizablePanel defaultSize={25} minSize={15} maxSize={40}>
@@ -317,19 +448,68 @@ export function MainApp() {
317
  </DialogFooter>
318
  </DialogContent>
319
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  <SearchBar onSelectNote={handleSelectNote} />
321
  <Separator />
322
  </div>
323
  <div className="flex-1 overflow-hidden">
324
  {isLoadingNotes ? (
325
- <div className="p-4 text-center text-sm text-muted-foreground">
326
- Loading notes...
327
- </div>
328
  ) : (
329
  <DirectoryTree
330
  notes={notes}
331
  selectedPath={selectedPath || undefined}
332
  onSelectNote={handleSelectNote}
 
333
  />
334
  )}
335
  </div>
@@ -350,9 +530,7 @@ export function MainApp() {
350
  )}
351
 
352
  {isLoadingNote ? (
353
- <div className="flex items-center justify-center h-full">
354
- <div className="text-muted-foreground">Loading note...</div>
355
- </div>
356
  ) : currentNote ? (
357
  isEditMode ? (
358
  <NoteEditor
@@ -387,7 +565,7 @@ export function MainApp() {
387
  </div>
388
 
389
  {/* Footer with Index Health */}
390
- <div className="border-t border-border px-4 py-2 text-xs text-muted-foreground">
391
  <div className="flex items-center justify-between">
392
  <div>
393
  {indexHealth ? (
 
4
  */
5
  import { useState, useEffect } from 'react';
6
  import { useNavigate } from 'react-router-dom';
7
+ import { Plus, Settings as SettingsIcon, FolderPlus } from 'lucide-react';
8
  import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
9
  import { Button } from '@/components/ui/button';
10
  import { Separator } from '@/components/ui/separator';
11
  import { Alert, AlertDescription } from '@/components/ui/alert';
12
  import { DirectoryTree } from '@/components/DirectoryTree';
13
+ import { DirectoryTreeSkeleton } from '@/components/DirectoryTreeSkeleton';
14
  import { SearchBar } from '@/components/SearchBar';
15
  import { NoteViewer } from '@/components/NoteViewer';
16
+ import { NoteViewerSkeleton } from '@/components/NoteViewerSkeleton';
17
  import { NoteEditor } from '@/components/NoteEditor';
18
  import { useToast } from '@/hooks/useToast';
19
  import {
 
22
  getBacklinks,
23
  getIndexHealth,
24
  createNote,
25
+ moveNote,
26
  type BacklinkResult,
27
  APIException,
28
  } from '@/services/api';
 
56
  const [isNewNoteDialogOpen, setIsNewNoteDialogOpen] = useState(false);
57
  const [newNoteName, setNewNoteName] = useState('');
58
  const [isCreatingNote, setIsCreatingNote] = useState(false);
59
+ const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false);
60
+ const [newFolderName, setNewFolderName] = useState('');
61
+ const [isCreatingFolder, setIsCreatingFolder] = useState(false);
62
 
63
  // T083: Load directory tree on mount
64
  // T119: Load index health
 
181
  setIsEditMode(false);
182
  };
183
 
184
+ // Handle note dialog open change
185
  const handleDialogOpenChange = (open: boolean) => {
186
  setIsNewNoteDialogOpen(open);
187
  if (!open) {
 
190
  }
191
  };
192
 
193
+ // Handle folder dialog open change
194
+ const handleFolderDialogOpenChange = (open: boolean) => {
195
+ setIsNewFolderDialogOpen(open);
196
+ if (!open) {
197
+ // Clear input when dialog closes
198
+ setNewFolderName('');
199
+ }
200
+ };
201
+
202
  // Handle create new note
203
  const handleCreateNote = async () => {
204
  if (!newNoteName.trim() || isCreatingNote) return;
 
263
  }
264
  };
265
 
266
+ // Handle create new folder
267
+ const handleCreateFolder = async () => {
268
+ if (!newFolderName.trim() || isCreatingFolder) return;
269
+
270
+ setIsCreatingFolder(true);
271
+ setError(null);
272
+
273
+ try {
274
+ // Create a placeholder note in the folder
275
+ const folderPath = newFolderName.replace(/\/$/, ''); // Remove trailing slash if present
276
+ const placeholderPath = `${folderPath}/.placeholder.md`;
277
+
278
+ const note = await createNote({
279
+ note_path: placeholderPath,
280
+ title: 'Folder',
281
+ body: `# ${folderPath}\n\nThis folder was created.`,
282
+ });
283
+
284
+ // Refresh notes list
285
+ const notesList = await listNotes();
286
+ setNotes(notesList);
287
+
288
+ toast.success(`Folder "${folderPath}" created successfully`);
289
+ } catch (err) {
290
+ let errorMessage = 'Failed to create folder';
291
+ if (err instanceof APIException) {
292
+ errorMessage = err.message || err.error;
293
+ } else if (err instanceof Error) {
294
+ errorMessage = err.message;
295
+ }
296
+ toast.error(errorMessage);
297
+ console.error('Error creating folder:', err);
298
+ } finally {
299
+ setIsCreatingFolder(false);
300
+ // Always close dialog, regardless of success or failure
301
+ handleFolderDialogOpenChange(false);
302
+ }
303
+ };
304
+
305
+ // Handle rename note
306
+ const handleRenameNote = async (oldPath: string, newPath: string) => {
307
+ if (!newPath.trim()) {
308
+ toast.error('New path cannot be empty');
309
+ return;
310
+ }
311
+
312
+ try {
313
+ // Ensure new path has .md extension
314
+ const finalNewPath = newPath.endsWith('.md') ? newPath : `${newPath}.md`;
315
+
316
+ await moveNote(oldPath, finalNewPath);
317
+
318
+ // Refresh notes list
319
+ const notesList = await listNotes();
320
+ setNotes(notesList);
321
+
322
+ // If renaming currently selected note, update selection
323
+ if (selectedPath === oldPath) {
324
+ setSelectedPath(finalNewPath);
325
+ }
326
+
327
+ toast.success(`Note renamed successfully`);
328
+ } catch (err) {
329
+ let errorMessage = 'Failed to rename note';
330
+ if (err instanceof APIException) {
331
+ errorMessage = err.message || err.error;
332
+ } else if (err instanceof Error) {
333
+ errorMessage = err.message;
334
+ }
335
+ toast.error(errorMessage);
336
+ console.error('Error renaming note:', err);
337
+ }
338
+ };
339
+
340
+ // Handle dragging file to folder
341
+ const handleMoveNoteToFolder = async (oldPath: string, targetFolderPath: string) => {
342
+ try {
343
+ // Get the filename from the old path
344
+ const fileName = oldPath.split('/').pop();
345
+ if (!fileName) {
346
+ toast.error('Invalid file path');
347
+ return;
348
+ }
349
+
350
+ // Construct new path: targetFolder/fileName
351
+ const newPath = targetFolderPath ? `${targetFolderPath}/${fileName}` : fileName;
352
+
353
+ // Don't move if source and destination are the same
354
+ if (newPath === oldPath) {
355
+ return;
356
+ }
357
+
358
+ await moveNote(oldPath, newPath);
359
+
360
+ // Refresh notes list
361
+ const notesList = await listNotes();
362
+ setNotes(notesList);
363
+
364
+ // If moving currently selected note, update selection
365
+ if (selectedPath === oldPath) {
366
+ setSelectedPath(newPath);
367
+ }
368
+
369
+ toast.success(`Note moved successfully`);
370
+ } catch (err) {
371
+ let errorMessage = 'Failed to move note';
372
+ if (err instanceof APIException) {
373
+ errorMessage = err.message || err.error;
374
+ } else if (err instanceof Error) {
375
+ errorMessage = err.message;
376
+ }
377
+ toast.error(errorMessage);
378
+ console.error('Error moving note:', err);
379
+ }
380
+ };
381
+
382
  return (
383
  <div className="h-screen flex flex-col">
384
  {/* Top bar */}
385
+ <div className="border-b border-border p-4 animate-fade-in">
386
  <div className="flex items-center justify-between">
387
  <h1 className="text-xl font-semibold">📚 Document Viewer</h1>
388
  <Button variant="ghost" size="sm" onClick={() => navigate('/settings')}>
 
392
  </div>
393
 
394
  {/* Main content */}
395
+ <div className="flex-1 overflow-hidden animate-fade-in" style={{ animationDelay: '0.1s' }}>
396
  <ResizablePanelGroup direction="horizontal">
397
  {/* Left sidebar */}
398
  <ResizablePanel defaultSize={25} minSize={15} maxSize={40}>
 
448
  </DialogFooter>
449
  </DialogContent>
450
  </Dialog>
451
+ <Dialog
452
+ open={isNewFolderDialogOpen}
453
+ onOpenChange={handleFolderDialogOpenChange}
454
+ >
455
+ <DialogTrigger asChild>
456
+ <Button variant="outline" size="sm" className="w-full">
457
+ <FolderPlus className="h-4 w-4 mr-1" />
458
+ New Folder
459
+ </Button>
460
+ </DialogTrigger>
461
+ <DialogContent>
462
+ <DialogHeader>
463
+ <DialogTitle>Create New Folder</DialogTitle>
464
+ <DialogDescription>
465
+ Enter a name for your new folder. You can use forward slashes for nested folders (e.g., "Projects/Work").
466
+ </DialogDescription>
467
+ </DialogHeader>
468
+ <div className="grid gap-4 py-4">
469
+ <div className="grid gap-2">
470
+ <label htmlFor="folder-name" className="text-sm font-medium">Folder Name</label>
471
+ <Input
472
+ id="folder-name"
473
+ placeholder="my-folder"
474
+ value={newFolderName}
475
+ onChange={(e) => setNewFolderName(e.target.value)}
476
+ onKeyDown={(e) => {
477
+ if (e.key === 'Enter') {
478
+ handleCreateFolder();
479
+ }
480
+ }}
481
+ />
482
+ </div>
483
+ </div>
484
+ <DialogFooter>
485
+ <Button
486
+ variant="outline"
487
+ onClick={() => setIsNewFolderDialogOpen(false)}
488
+ disabled={isCreatingFolder}
489
+ >
490
+ Cancel
491
+ </Button>
492
+ <Button
493
+ onClick={handleCreateFolder}
494
+ disabled={!newFolderName.trim() || isCreatingFolder}
495
+ >
496
+ {isCreatingFolder ? 'Creating...' : 'Create Folder'}
497
+ </Button>
498
+ </DialogFooter>
499
+ </DialogContent>
500
+ </Dialog>
501
  <SearchBar onSelectNote={handleSelectNote} />
502
  <Separator />
503
  </div>
504
  <div className="flex-1 overflow-hidden">
505
  {isLoadingNotes ? (
506
+ <DirectoryTreeSkeleton />
 
 
507
  ) : (
508
  <DirectoryTree
509
  notes={notes}
510
  selectedPath={selectedPath || undefined}
511
  onSelectNote={handleSelectNote}
512
+ onMoveNote={handleMoveNoteToFolder}
513
  />
514
  )}
515
  </div>
 
530
  )}
531
 
532
  {isLoadingNote ? (
533
+ <NoteViewerSkeleton />
 
 
534
  ) : currentNote ? (
535
  isEditMode ? (
536
  <NoteEditor
 
565
  </div>
566
 
567
  {/* Footer with Index Health */}
568
+ <div className="border-t border-border px-4 py-2 text-xs text-muted-foreground animate-fade-in" style={{ animationDelay: '0.2s' }}>
569
  <div className="flex items-center justify-between">
570
  <div>
571
  {indexHealth ? (
frontend/src/pages/Settings.tsx CHANGED
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
10
  import { Alert, AlertDescription } from '@/components/ui/alert';
11
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
12
  import { Separator } from '@/components/ui/separator';
 
13
  import { getCurrentUser, getToken, logout, getStoredToken } from '@/services/auth';
14
  import { getIndexHealth, rebuildIndex, type RebuildResponse } from '@/services/api';
15
  import type { User } from '@/types/user';
@@ -122,13 +123,13 @@ export function Settings() {
122
  )}
123
 
124
  {/* Profile */}
125
- <Card>
126
- <CardHeader>
127
- <CardTitle>Profile</CardTitle>
128
- <CardDescription>Your account information</CardDescription>
129
- </CardHeader>
130
- <CardContent>
131
- {user ? (
132
  <div className="flex items-center gap-4">
133
  <Avatar className="h-16 w-16">
134
  <AvatarImage src={user.hf_profile?.avatar_url} />
@@ -149,11 +150,14 @@ export function Settings() {
149
  Sign Out
150
  </Button>
151
  </div>
152
- ) : (
153
- <div className="text-muted-foreground">Loading user data...</div>
154
- )}
155
- </CardContent>
156
- </Card>
 
 
 
157
 
158
  {/* API Token */}
159
  <Card>
@@ -221,61 +225,62 @@ export function Settings() {
221
  </Card>
222
 
223
  {/* Index Health */}
224
- <Card>
225
- <CardHeader>
226
- <CardTitle>Index Health</CardTitle>
227
- <CardDescription>
228
- Full-text search index status and maintenance
229
- </CardDescription>
230
- </CardHeader>
231
- <CardContent className="space-y-4">
232
- {indexHealth ? (
233
- <>
234
- <div className="grid grid-cols-2 gap-4">
235
- <div>
236
- <div className="text-sm text-muted-foreground">Notes Indexed</div>
237
- <div className="text-2xl font-bold">{indexHealth.note_count}</div>
238
- </div>
239
- <div>
240
- <div className="text-sm text-muted-foreground">Last Updated</div>
241
- <div className="text-sm">{formatDate(indexHealth.last_incremental_update)}</div>
242
- </div>
243
  </div>
244
-
245
- <Separator />
246
-
247
  <div>
248
- <div className="text-sm text-muted-foreground mb-1">Last Full Rebuild</div>
249
- <div className="text-sm">{formatDate(indexHealth.last_full_rebuild)}</div>
250
  </div>
 
251
 
252
- {rebuildResult && (
253
- <Alert>
254
- <AlertDescription>
255
- ✅ Index rebuilt successfully! Indexed {rebuildResult.notes_indexed} notes in {rebuildResult.duration_ms}ms
256
- </AlertDescription>
257
- </Alert>
258
- )}
259
 
260
- <Button
261
- onClick={handleRebuildIndex}
262
- disabled={isRebuilding}
263
- variant="outline"
264
- >
265
- <RefreshCw className={`h-4 w-4 mr-2 ${isRebuilding ? 'animate-spin' : ''}`} />
266
- {isRebuilding ? 'Rebuilding...' : 'Rebuild Index'}
267
- </Button>
268
 
269
- <div className="text-xs text-muted-foreground">
270
- Rebuilding the index will re-scan all notes and update the full-text search database.
271
- This may take a few seconds for large vaults.
272
- </div>
273
- </>
274
- ) : (
275
- <div className="text-muted-foreground">Loading index health...</div>
276
- )}
277
- </CardContent>
278
- </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  </div>
280
  </div>
281
  );
 
10
  import { Alert, AlertDescription } from '@/components/ui/alert';
11
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
12
  import { Separator } from '@/components/ui/separator';
13
+ import { SettingsSectionSkeleton } from '@/components/SettingsSectionSkeleton';
14
  import { getCurrentUser, getToken, logout, getStoredToken } from '@/services/auth';
15
  import { getIndexHealth, rebuildIndex, type RebuildResponse } from '@/services/api';
16
  import type { User } from '@/types/user';
 
123
  )}
124
 
125
  {/* Profile */}
126
+ {user ? (
127
+ <Card>
128
+ <CardHeader>
129
+ <CardTitle>Profile</CardTitle>
130
+ <CardDescription>Your account information</CardDescription>
131
+ </CardHeader>
132
+ <CardContent>
133
  <div className="flex items-center gap-4">
134
  <Avatar className="h-16 w-16">
135
  <AvatarImage src={user.hf_profile?.avatar_url} />
 
150
  Sign Out
151
  </Button>
152
  </div>
153
+ </CardContent>
154
+ </Card>
155
+ ) : (
156
+ <SettingsSectionSkeleton
157
+ title="Profile"
158
+ description="Your account information"
159
+ />
160
+ )}
161
 
162
  {/* API Token */}
163
  <Card>
 
225
  </Card>
226
 
227
  {/* Index Health */}
228
+ {indexHealth ? (
229
+ <Card>
230
+ <CardHeader>
231
+ <CardTitle>Index Health</CardTitle>
232
+ <CardDescription>
233
+ Full-text search index status and maintenance
234
+ </CardDescription>
235
+ </CardHeader>
236
+ <CardContent className="space-y-4">
237
+ <div className="grid grid-cols-2 gap-4">
238
+ <div>
239
+ <div className="text-sm text-muted-foreground">Notes Indexed</div>
240
+ <div className="text-2xl font-bold">{indexHealth.note_count}</div>
 
 
 
 
 
 
241
  </div>
 
 
 
242
  <div>
243
+ <div className="text-sm text-muted-foreground">Last Updated</div>
244
+ <div className="text-sm">{formatDate(indexHealth.last_incremental_update)}</div>
245
  </div>
246
+ </div>
247
 
248
+ <Separator />
 
 
 
 
 
 
249
 
250
+ <div>
251
+ <div className="text-sm text-muted-foreground mb-1">Last Full Rebuild</div>
252
+ <div className="text-sm">{formatDate(indexHealth.last_full_rebuild)}</div>
253
+ </div>
 
 
 
 
254
 
255
+ {rebuildResult && (
256
+ <Alert>
257
+ <AlertDescription>
258
+ ✅ Index rebuilt successfully! Indexed {rebuildResult.notes_indexed} notes in {rebuildResult.duration_ms}ms
259
+ </AlertDescription>
260
+ </Alert>
261
+ )}
262
+
263
+ <Button
264
+ onClick={handleRebuildIndex}
265
+ disabled={isRebuilding}
266
+ variant="outline"
267
+ >
268
+ <RefreshCw className={`h-4 w-4 mr-2 ${isRebuilding ? 'animate-spin' : ''}`} />
269
+ {isRebuilding ? 'Rebuilding...' : 'Rebuild Index'}
270
+ </Button>
271
+
272
+ <div className="text-xs text-muted-foreground">
273
+ Rebuilding the index will re-scan all notes and update the full-text search database.
274
+ This may take a few seconds for large vaults.
275
+ </div>
276
+ </CardContent>
277
+ </Card>
278
+ ) : (
279
+ <SettingsSectionSkeleton
280
+ title="Index Health"
281
+ description="Full-text search index status and maintenance"
282
+ />
283
+ )}
284
  </div>
285
  </div>
286
  );
frontend/src/services/api.ts CHANGED
@@ -184,3 +184,14 @@ export async function rebuildIndex(): Promise<RebuildResponse> {
184
  });
185
  }
186
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  });
185
  }
186
 
187
+ /**
188
+ * Move or rename a note to a new path
189
+ */
190
+ export async function moveNote(oldPath: string, newPath: string): Promise<Note> {
191
+ const encodedPath = encodeURIComponent(oldPath);
192
+ return apiFetch<Note>(`/api/notes/${encodedPath}`, {
193
+ method: 'PATCH',
194
+ body: JSON.stringify({ new_path: newPath }),
195
+ });
196
+ }
197
+
frontend/tailwind.config.js CHANGED
@@ -63,10 +63,37 @@ export default {
63
  from: { height: "var(--radix-accordion-content-height)" },
64
  to: { height: "0" },
65
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  },
67
  animation: {
68
- "accordion-down": "accordion-down 0.2s ease-out",
69
- "accordion-up": "accordion-up 0.2s ease-out",
 
 
 
 
 
70
  },
71
  },
72
  },
 
63
  from: { height: "var(--radix-accordion-content-height)" },
64
  to: { height: "0" },
65
  },
66
+ "fade-in": {
67
+ from: { opacity: "0" },
68
+ to: { opacity: "1" },
69
+ },
70
+ "slide-in-up": {
71
+ from: { opacity: "0", transform: "translateY(10px)" },
72
+ to: { opacity: "1", transform: "translateY(0)" },
73
+ },
74
+ "slide-in-down": {
75
+ from: { opacity: "0", transform: "translateY(-10px)" },
76
+ to: { opacity: "1", transform: "translateY(0)" },
77
+ },
78
+ "highlight-pulse": {
79
+ "0%": { backgroundColor: "transparent" },
80
+ "50%": { backgroundColor: "hsl(var(--accent))" },
81
+ "100%": { backgroundColor: "transparent" },
82
+ },
83
+ "skeleton-pulse": {
84
+ "0%": { opacity: "1" },
85
+ "50%": { opacity: "0.5" },
86
+ "100%": { opacity: "1" },
87
+ },
88
  },
89
  animation: {
90
+ "accordion-down": "accordion-down 0.3s ease-out",
91
+ "accordion-up": "accordion-up 0.3s ease-out",
92
+ "fade-in": "fade-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)",
93
+ "slide-in-up": "slide-in-up 1s cubic-bezier(0.34, 1.56, 0.64, 1)",
94
+ "slide-in-down": "slide-in-down 1s cubic-bezier(0.34, 1.56, 0.64, 1)",
95
+ "highlight-pulse": "highlight-pulse 1s ease-in-out",
96
+ "skeleton-pulse": "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
97
  },
98
  },
99
  },
start-project-windows.bat ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Document-MCP Start Script for Windows
3
+ REM This script opens two terminal windows - one for backend and one for frontend
4
+
5
+ echo Starting Document-MCP...
6
+ echo.
7
+
8
+ REM Get the project root directory
9
+ set PROJECT_ROOT=%~dp0
10
+
11
+ REM Start Backend in a new terminal window
12
+ echo Starting Backend (FastAPI on port 8000)...
13
+ start "Document-MCP Backend" cmd /k "cd /d "%PROJECT_ROOT%backend" && .venv\Scripts\activate && uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000"
14
+
15
+ REM Wait a moment before starting frontend
16
+ timeout /t 3 /nobreak
17
+
18
+ REM Start Frontend in a new terminal window
19
+ echo Starting Frontend (Vite on port 5173)...
20
+ start "Document-MCP Frontend" cmd /k "cd /d "%PROJECT_ROOT%frontend" && npm run dev"
21
+
22
+ echo.
23
+ echo ============================================
24
+ echo Document-MCP is starting!
25
+ echo ============================================
26
+ echo.
27
+ echo Backend: http://localhost:8000
28
+ echo Frontend: http://localhost:5173
29
+ echo.
30
+ echo Both services should open in separate terminal windows.
31
+ echo Press Ctrl+C in each window to stop the services.
32
+ echo.
33
+ pause