Spaces:
Running
Running
| /** | |
| * T080, T083-T084: Main application layout with two-pane design | |
| * Loads directory tree on mount and note + backlinks when path changes | |
| */ | |
| import { useState, useEffect } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { Plus, Settings as SettingsIcon, FolderPlus } from 'lucide-react'; | |
| import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Separator } from '@/components/ui/separator'; | |
| import { Alert, AlertDescription } from '@/components/ui/alert'; | |
| import { DirectoryTree } from '@/components/DirectoryTree'; | |
| import { DirectoryTreeSkeleton } from '@/components/DirectoryTreeSkeleton'; | |
| import { SearchBar } from '@/components/SearchBar'; | |
| import { NoteViewer } from '@/components/NoteViewer'; | |
| import { NoteViewerSkeleton } from '@/components/NoteViewerSkeleton'; | |
| import { NoteEditor } from '@/components/NoteEditor'; | |
| import { useToast } from '@/hooks/useToast'; | |
| import { GraphView } from '@/components/GraphView'; | |
| import { | |
| listNotes, | |
| getNote, | |
| getBacklinks, | |
| getIndexHealth, | |
| createNote, | |
| moveNote, | |
| type BacklinkResult, | |
| APIException, | |
| } from '@/services/api'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| } from '@/components/ui/dialog'; | |
| import { Input } from '@/components/ui/input'; | |
| import type { IndexHealth } from '@/types/search'; | |
| import type { Note, NoteSummary } from '@/types/note'; | |
| import { normalizeSlug } from '@/lib/wikilink'; | |
| import { Network } from 'lucide-react'; | |
| import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth'; | |
| export function MainApp() { | |
| const navigate = useNavigate(); | |
| const toast = useToast(); | |
| const [notes, setNotes] = useState<NoteSummary[]>([]); | |
| const [selectedPath, setSelectedPath] = useState<string | null>(null); | |
| const [currentNote, setCurrentNote] = useState<Note | null>(null); | |
| const [backlinks, setBacklinks] = useState<BacklinkResult[]>([]); | |
| const [isLoadingNotes, setIsLoadingNotes] = useState(true); | |
| const [isLoadingNote, setIsLoadingNote] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [isEditMode, setIsEditMode] = useState(false); | |
| const [isGraphView, setIsGraphView] = useState(false); | |
| const [indexHealth, setIndexHealth] = useState<IndexHealth | null>(null); | |
| const [isNewNoteDialogOpen, setIsNewNoteDialogOpen] = useState(false); | |
| const [newNoteName, setNewNoteName] = useState(''); | |
| const [isCreatingNote, setIsCreatingNote] = useState(false); | |
| const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false); | |
| const [newFolderName, setNewFolderName] = useState(''); | |
| const [isCreatingFolder, setIsCreatingFolder] = useState(false); | |
| const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession()); | |
| useEffect(() => { | |
| const handleAuthChange = () => { | |
| const demo = isDemoSession(); | |
| setIsDemoMode(demo); | |
| if (demo) { | |
| setIsEditMode(false); | |
| } | |
| }; | |
| window.addEventListener(AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); | |
| return () => { | |
| window.removeEventListener(AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); | |
| }; | |
| }, []); | |
| // T083: Load directory tree on mount | |
| // T119: Load index health | |
| useEffect(() => { | |
| const loadData = async () => { | |
| setIsLoadingNotes(true); | |
| setError(null); | |
| try { | |
| // Load notes and index health in parallel | |
| const [notesList, health] = await Promise.all([ | |
| listNotes(), | |
| getIndexHealth().catch(() => null), // Don't fail if health unavailable | |
| ]); | |
| setNotes(notesList); | |
| setIndexHealth(health); | |
| // Auto-select first note if available | |
| if (notesList.length > 0 && !selectedPath) { | |
| setSelectedPath(notesList[0].note_path); | |
| } | |
| } catch (err) { | |
| if (err instanceof APIException) { | |
| setError(err.error); | |
| } else { | |
| setError('Failed to load notes'); | |
| } | |
| console.error('Error loading notes:', err); | |
| } finally { | |
| setIsLoadingNotes(false); | |
| } | |
| }; | |
| loadData(); | |
| }, []); | |
| // T084: Load note and backlinks when path changes | |
| useEffect(() => { | |
| if (!selectedPath) { | |
| setCurrentNote(null); | |
| setBacklinks([]); | |
| return; | |
| } | |
| const loadNote = async () => { | |
| setIsLoadingNote(true); | |
| setError(null); | |
| try { | |
| const [note, noteBacklinks] = await Promise.all([ | |
| getNote(selectedPath), | |
| getBacklinks(selectedPath), | |
| ]); | |
| setCurrentNote(note); | |
| setBacklinks(noteBacklinks); | |
| } catch (err) { | |
| if (err instanceof APIException) { | |
| setError(err.error); | |
| } else { | |
| setError('Failed to load note'); | |
| } | |
| console.error('Error loading note:', err); | |
| setCurrentNote(null); | |
| setBacklinks([]); | |
| } finally { | |
| setIsLoadingNote(false); | |
| } | |
| }; | |
| loadNote(); | |
| }, [selectedPath]); | |
| // Handle wikilink clicks | |
| const handleWikilinkClick = async (linkText: string) => { | |
| const slug = normalizeSlug(linkText); | |
| console.log(`[Wikilink] Clicked: "${linkText}", Slug: "${slug}"`); | |
| // Try to find exact match first | |
| let targetNote = notes.find( | |
| (note) => normalizeSlug(note.title) === slug | |
| ); | |
| // If not found, try path-based matching | |
| if (!targetNote) { | |
| targetNote = notes.find((note) => { | |
| const pathSlug = normalizeSlug(note.note_path.replace(/\.md$/, '')); | |
| // console.log(`Checking path: ${note.note_path}, Slug: ${pathSlug}`); | |
| return pathSlug.endsWith(slug); | |
| }); | |
| } | |
| if (targetNote) { | |
| console.log(`[Wikilink] Found target: ${targetNote.note_path}`); | |
| setSelectedPath(targetNote.note_path); | |
| } else { | |
| // TODO: Show "Create note" dialog | |
| console.log('Note not found for wikilink:', linkText); | |
| setError(`Note not found: ${linkText}`); | |
| } | |
| }; | |
| const handleSelectNote = (path: string) => { | |
| setSelectedPath(path); | |
| setError(null); | |
| setIsEditMode(false); // Exit edit mode when switching notes | |
| }; | |
| // T093: Handle edit button click | |
| const handleEdit = () => { | |
| if (isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in with Hugging Face to edit notes.'); | |
| return; | |
| } | |
| setIsEditMode(true); | |
| }; | |
| // Handle note save from editor | |
| const handleNoteSave = (updatedNote: Note) => { | |
| setCurrentNote(updatedNote); | |
| setIsEditMode(false); | |
| setError(null); | |
| // Reload notes list to update modified timestamp | |
| listNotes().then(setNotes).catch(console.error); | |
| }; | |
| // Handle editor cancel | |
| const handleEditCancel = () => { | |
| setIsEditMode(false); | |
| }; | |
| // Handle note dialog open change | |
| const handleDialogOpenChange = (open: boolean) => { | |
| if (open && isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in with Hugging Face to create notes.'); | |
| return; | |
| } | |
| setIsNewNoteDialogOpen(open); | |
| if (!open) { | |
| // Clear input when dialog closes | |
| setNewNoteName(''); | |
| } | |
| }; | |
| // Handle folder dialog open change | |
| const handleFolderDialogOpenChange = (open: boolean) => { | |
| if (open && isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in with Hugging Face to create folders.'); | |
| return; | |
| } | |
| setIsNewFolderDialogOpen(open); | |
| if (!open) { | |
| // Clear input when dialog closes | |
| setNewFolderName(''); | |
| } | |
| }; | |
| // Handle create new note | |
| const handleCreateNote = async () => { | |
| if (isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in to create notes.'); | |
| return; | |
| } | |
| if (!newNoteName.trim() || isCreatingNote) return; | |
| setIsCreatingNote(true); | |
| setError(null); | |
| try { | |
| const baseName = newNoteName.replace(/\.md$/, ''); | |
| let notePath = newNoteName.endsWith('.md') ? newNoteName : `${newNoteName}.md`; | |
| let attempt = 1; | |
| const maxAttempts = 100; | |
| // Retry with number suffix if note already exists | |
| while (attempt <= maxAttempts) { | |
| try { | |
| const note = await createNote({ | |
| note_path: notePath, | |
| title: baseName, | |
| body: `# ${baseName}\n\nStart writing your note here...`, | |
| }); | |
| // Refresh notes list | |
| const notesList = await listNotes(); | |
| setNotes(notesList); | |
| // Select the new note | |
| setSelectedPath(note.note_path); | |
| setIsEditMode(true); | |
| const displayName = notePath.replace(/\.md$/, ''); | |
| toast.success(`Note "${displayName}" created successfully`); | |
| break; | |
| } catch (err) { | |
| if (err instanceof APIException && err.status === 409) { | |
| // Note already exists, try with number suffix | |
| attempt++; | |
| if (attempt <= maxAttempts) { | |
| notePath = `${baseName} ${attempt}.md`; | |
| continue; | |
| } else { | |
| throw err; | |
| } | |
| } else { | |
| throw err; | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| let errorMessage = 'Failed to create note'; | |
| if (err instanceof APIException) { | |
| // Use the message field which contains the actual error description | |
| errorMessage = err.message || err.error; | |
| } else if (err instanceof Error) { | |
| errorMessage = err.message; | |
| } | |
| toast.error(errorMessage); | |
| console.error('Error creating note:', err); | |
| } finally { | |
| setIsCreatingNote(false); | |
| // Always close dialog, regardless of success or failure | |
| handleDialogOpenChange(false); | |
| } | |
| }; | |
| // Handle create new folder | |
| const handleCreateFolder = async () => { | |
| if (isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in to create folders.'); | |
| return; | |
| } | |
| if (!newFolderName.trim() || isCreatingFolder) return; | |
| setIsCreatingFolder(true); | |
| setError(null); | |
| try { | |
| // Create a placeholder note in the folder | |
| const folderPath = newFolderName.replace(/\/$/, ''); // Remove trailing slash if present | |
| const placeholderPath = `${folderPath}/.placeholder.md`; | |
| await createNote({ | |
| note_path: placeholderPath, | |
| title: 'Folder', | |
| body: `# ${folderPath}\n\nThis folder was created.`, | |
| }); | |
| // Refresh notes list | |
| const notesList = await listNotes(); | |
| setNotes(notesList); | |
| toast.success(`Folder "${folderPath}" created successfully`); | |
| } catch (err) { | |
| let errorMessage = 'Failed to create folder'; | |
| if (err instanceof APIException) { | |
| errorMessage = err.message || err.error; | |
| } else if (err instanceof Error) { | |
| errorMessage = err.message; | |
| } | |
| toast.error(errorMessage); | |
| console.error('Error creating folder:', err); | |
| } finally { | |
| setIsCreatingFolder(false); | |
| // Always close dialog, regardless of success or failure | |
| handleFolderDialogOpenChange(false); | |
| } | |
| }; | |
| // Handle dragging file to folder | |
| const handleMoveNoteToFolder = async (oldPath: string, targetFolderPath: string) => { | |
| if (isDemoMode) { | |
| toast.error('Demo mode is read-only. Sign in to move notes.'); | |
| return; | |
| } | |
| try { | |
| // Get the filename from the old path | |
| const fileName = oldPath.split('/').pop(); | |
| if (!fileName) { | |
| toast.error('Invalid file path'); | |
| return; | |
| } | |
| // Construct new path: targetFolder/fileName | |
| const newPath = targetFolderPath ? `${targetFolderPath}/${fileName}` : fileName; | |
| // Don't move if source and destination are the same | |
| if (newPath === oldPath) { | |
| return; | |
| } | |
| await moveNote(oldPath, newPath); | |
| // Refresh notes list | |
| const notesList = await listNotes(); | |
| setNotes(notesList); | |
| // If moving currently selected note, update selection | |
| if (selectedPath === oldPath) { | |
| setSelectedPath(newPath); | |
| } | |
| toast.success(`Note moved successfully`); | |
| } catch (err) { | |
| let errorMessage = 'Failed to move note'; | |
| if (err instanceof APIException) { | |
| errorMessage = err.message || err.error; | |
| } else if (err instanceof Error) { | |
| errorMessage = err.message; | |
| } | |
| toast.error(errorMessage); | |
| console.error('Error moving note:', err); | |
| } | |
| }; | |
| return ( | |
| <div className="h-screen flex flex-col"> | |
| {/* Demo warning banner */} | |
| <Alert variant="destructive" className="rounded-none border-x-0 border-t-0"> | |
| <AlertDescription className="text-center"> | |
| DEMO ONLY - ALL DATA IS TEMPORARY AND MAY BE DELETED AT ANY TIME | |
| </AlertDescription> | |
| </Alert> | |
| {/* Top bar */} | |
| <div className="border-b border-border p-4 animate-fade-in"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <h1 className="text-xl font-semibold">📚 Document Viewer</h1> | |
| <div className="flex gap-2"> | |
| {isDemoMode && ( | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onClick={() => login()} | |
| title="Sign in with Hugging Face" | |
| > | |
| Sign in | |
| </Button> | |
| )} | |
| <Button | |
| variant={isGraphView ? "secondary" : "ghost"} | |
| size="sm" | |
| onClick={() => setIsGraphView(!isGraphView)} | |
| title={isGraphView ? "Switch to Note View" : "Switch to Graph View"} | |
| > | |
| <Network className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="ghost" size="sm" onClick={() => navigate('/settings')}> | |
| <SettingsIcon className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Main content */} | |
| <div className="flex-1 overflow-hidden animate-fade-in" style={{ animationDelay: '0.1s' }}> | |
| {isDemoMode && ( | |
| <div className="border-b border-border bg-muted/40 px-4 py-2 text-sm text-muted-foreground flex flex-wrap items-center justify-between gap-2"> | |
| <span> | |
| You are browsing the shared demo vault in read-only mode. Sign in with your Hugging Face account to create and edit notes. | |
| </span> | |
| <Button variant="outline" size="sm" onClick={() => login()}> | |
| Sign in | |
| </Button> | |
| </div> | |
| )} | |
| <ResizablePanelGroup direction="horizontal"> | |
| {/* Left sidebar */} | |
| <ResizablePanel defaultSize={25} minSize={15} maxSize={40}> | |
| <div className="h-full flex flex-col"> | |
| <div className="p-4 space-y-4"> | |
| <Dialog | |
| open={isNewNoteDialogOpen} | |
| onOpenChange={handleDialogOpenChange} | |
| > | |
| <DialogTrigger asChild> | |
| <Button variant="outline" size="sm" className="w-full" disabled={isDemoMode}> | |
| <Plus className="h-4 w-4 mr-1" /> | |
| New Note | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Create New Note</DialogTitle> | |
| <DialogDescription> | |
| Enter a name for your new note. The .md extension will be added automatically if not provided. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="grid gap-4 py-4"> | |
| <div className="grid gap-2"> | |
| <label htmlFor="note-name" className="text-sm font-medium">Note Name</label> | |
| <Input | |
| id="note-name" | |
| placeholder="my-note" | |
| value={newNoteName} | |
| onChange={(e) => setNewNoteName(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') { | |
| handleCreateNote(); | |
| } | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <DialogFooter> | |
| <Button | |
| variant="outline" | |
| onClick={() => setIsNewNoteDialogOpen(false)} | |
| disabled={isCreatingNote} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={handleCreateNote} | |
| disabled={!newNoteName.trim() || isCreatingNote} | |
| > | |
| {isCreatingNote ? 'Creating...' : 'Create Note'} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| <Dialog | |
| open={isNewFolderDialogOpen} | |
| onOpenChange={handleFolderDialogOpenChange} | |
| > | |
| <DialogTrigger asChild> | |
| <Button variant="outline" size="sm" className="w-full" disabled={isDemoMode}> | |
| <FolderPlus className="h-4 w-4 mr-1" /> | |
| New Folder | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Create New Folder</DialogTitle> | |
| <DialogDescription> | |
| Enter a name for your new folder. You can use forward slashes for nested folders (e.g., "Projects/Work"). | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="grid gap-4 py-4"> | |
| <div className="grid gap-2"> | |
| <label htmlFor="folder-name" className="text-sm font-medium">Folder Name</label> | |
| <Input | |
| id="folder-name" | |
| placeholder="my-folder" | |
| value={newFolderName} | |
| onChange={(e) => setNewFolderName(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') { | |
| handleCreateFolder(); | |
| } | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <DialogFooter> | |
| <Button | |
| variant="outline" | |
| onClick={() => setIsNewFolderDialogOpen(false)} | |
| disabled={isCreatingFolder} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={handleCreateFolder} | |
| disabled={!newFolderName.trim() || isCreatingFolder} | |
| > | |
| {isCreatingFolder ? 'Creating...' : 'Create Folder'} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| <SearchBar onSelectNote={handleSelectNote} /> | |
| <Separator /> | |
| </div> | |
| <div className="flex-1 overflow-hidden"> | |
| {isLoadingNotes ? ( | |
| <DirectoryTreeSkeleton /> | |
| ) : ( | |
| <DirectoryTree | |
| notes={notes} | |
| selectedPath={selectedPath || undefined} | |
| onSelectNote={handleSelectNote} | |
| onMoveNote={handleMoveNoteToFolder} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| </ResizablePanel> | |
| <ResizableHandle withHandle /> | |
| {/* Main content pane */} | |
| <ResizablePanel defaultSize={75}> | |
| <div className="h-full bg-background"> | |
| {error && ( | |
| <div className="p-4"> | |
| <Alert variant="destructive"> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| </div> | |
| )} | |
| {isGraphView ? ( | |
| <GraphView onSelectNote={(path) => { | |
| handleSelectNote(path); | |
| setIsGraphView(false); | |
| }} /> | |
| ) : ( | |
| isLoadingNote ? ( | |
| <NoteViewerSkeleton /> | |
| ) : currentNote ? ( | |
| isEditMode ? ( | |
| <NoteEditor | |
| note={currentNote} | |
| onSave={handleNoteSave} | |
| onCancel={handleEditCancel} | |
| onWikilinkClick={handleWikilinkClick} | |
| /> | |
| ) : ( | |
| <NoteViewer | |
| note={currentNote} | |
| backlinks={backlinks} | |
| onEdit={isDemoMode ? undefined : handleEdit} | |
| onWikilinkClick={handleWikilinkClick} | |
| /> | |
| ) | |
| ) : ( | |
| <div className="flex items-center justify-center h-full"> | |
| <div className="text-center text-muted-foreground"> | |
| <p className="text-lg mb-2">Select a note to view</p> | |
| <p className="text-sm"> | |
| {notes.length === 0 | |
| ? 'No notes available. Create your first note to get started.' | |
| : 'Choose a note from the sidebar'} | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| </ResizablePanel> | |
| </ResizablePanelGroup> | |
| </div> | |
| {/* Footer with Index Health */} | |
| <div className="border-t border-border px-4 py-2 text-xs text-muted-foreground animate-fade-in" style={{ animationDelay: '0.2s' }}> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| {indexHealth ? ( | |
| <> | |
| <span className="font-medium">{indexHealth.note_count}</span> note{indexHealth.note_count !== 1 ? 's' : ''} indexed | |
| </> | |
| ) : ( | |
| <> | |
| <span className="font-medium">{notes.length}</span> note{notes.length !== 1 ? 's' : ''} indexed | |
| </> | |
| )} | |
| </div> | |
| {indexHealth && indexHealth.last_incremental_update && ( | |
| <div className="flex items-center gap-2"> | |
| <span>Last updated:</span> | |
| <span className="font-medium"> | |
| {new Date(indexHealth.last_incremental_update).toLocaleString('en-US', { | |
| month: 'short', | |
| day: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| })} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |