Spaces:
Running
Running
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 +10 -1
- backend/src/api/routes/notes.py +94 -0
- backend/src/services/vault.py +50 -2
- frontend/src/App.tsx +2 -5
- frontend/src/components/AuthLoadingSkeleton.tsx +27 -0
- frontend/src/components/DirectoryTree.tsx +51 -5
- frontend/src/components/DirectoryTreeSkeleton.tsx +26 -0
- frontend/src/components/NoteViewer.tsx +6 -6
- frontend/src/components/NoteViewerSkeleton.tsx +45 -0
- frontend/src/components/SearchBar.tsx +10 -5
- frontend/src/components/SearchResultSkeleton.tsx +19 -0
- frontend/src/components/SettingsSectionSkeleton.tsx +30 -0
- frontend/src/components/ui/button.tsx +1 -1
- frontend/src/components/ui/skeleton.tsx +15 -0
- frontend/src/index.css +19 -0
- frontend/src/pages/MainApp.tsx +189 -11
- frontend/src/pages/Settings.tsx +66 -61
- frontend/src/services/api.ts +11 -0
- frontend/tailwind.config.js +29 -2
- start-project-windows.bat +33 -0
backend/main.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
def main():
|
| 2 |
-
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 142 |
-
|
| 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-
|
| 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 |
-
<
|
| 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 |
-
<
|
| 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 |
-
|
| 126 |
-
<
|
| 127 |
-
<
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 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 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
{/* API Token */}
|
| 159 |
<Card>
|
|
@@ -221,61 +225,62 @@ export function Settings() {
|
|
| 221 |
</Card>
|
| 222 |
|
| 223 |
{/* Index Health */}
|
| 224 |
-
|
| 225 |
-
<
|
| 226 |
-
<
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
<div
|
| 235 |
-
<div>
|
| 236 |
-
|
| 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
|
| 249 |
-
<div className="text-sm">{formatDate(indexHealth.
|
| 250 |
</div>
|
|
|
|
| 251 |
|
| 252 |
-
|
| 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 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
>
|
| 265 |
-
<RefreshCw className={`h-4 w-4 mr-2 ${isRebuilding ? 'animate-spin' : ''}`} />
|
| 266 |
-
{isRebuilding ? 'Rebuilding...' : 'Rebuild Index'}
|
| 267 |
-
</Button>
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 69 |
-
"accordion-up": "accordion-up 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|