/** * T080, T083-T084: Main application layout with two-pane design * Loads directory tree on mount and note + backlinks when path changes */ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Plus, Settings as SettingsIcon, FolderPlus, MessageCircle } from 'lucide-react'; import { useFontSize } from '@/hooks/useFontSize'; 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 { ChatPanel } from '@/components/ChatPanel'; import { useToast } from '@/hooks/useToast'; import { GraphView } from '@/components/GraphView'; import { GlowParticleEffect } from '@/components/GlowParticleEffect'; 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'; import { synthesizeTts } from '@/services/tts'; import { markdownToPlainText } from '@/lib/markdownToText'; import { useAudioPlayer } from '@/hooks/useAudioPlayer'; export function MainApp() { const navigate = useNavigate(); const toast = useToast(); const [notes, setNotes] = useState([]); const [selectedPath, setSelectedPath] = useState(null); const [currentNote, setCurrentNote] = useState(null); const [backlinks, setBacklinks] = useState([]); const [isLoadingNotes, setIsLoadingNotes] = useState(true); const [isLoadingNote, setIsLoadingNote] = useState(false); const [error, setError] = useState(null); const [isEditMode, setIsEditMode] = useState(false); const [isGraphView, setIsGraphView] = useState(false); const [indexHealth, setIndexHealth] = useState(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(isDemoSession()); const [isChatOpen, setIsChatOpen] = useState(false); const [graphRefreshTrigger, setGraphRefreshTrigger] = useState(0); const [isSynthesizingTts, setIsSynthesizingTts] = useState(false); const ttsUrlRef = useRef(null); const ttsAbortRef = useRef(null); // T007: Initialize font size state management const { fontSize, setFontSize, isFontReady } = useFontSize(); const { status: ttsPlayerStatus, error: ttsPlayerError, volume: ttsVolume, play: playAudio, pause: pauseAudio, resume: resumeAudio, stop: stopAudio, setVolume: setTtsVolume, } = useAudioPlayer(); const stopTts = () => { if (ttsAbortRef.current) { ttsAbortRef.current.abort(); ttsAbortRef.current = null; } stopAudio(); if (ttsUrlRef.current) { URL.revokeObjectURL(ttsUrlRef.current); ttsUrlRef.current = null; } setIsSynthesizingTts(false); }; 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); }; }, []); useEffect(() => { if (ttsPlayerError) { toast.error(ttsPlayerError); } }, [ttsPlayerError, toast]); // 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(); }, []); useEffect(() => { // Stop TTS when switching notes stopTts(); }, [selectedPath]); useEffect(() => { return () => { stopTts(); }; }, []); // 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 handleTtsToggle = async () => { if (!currentNote || isSynthesizingTts) { return; } if (ttsPlayerStatus === 'playing') { pauseAudio(); return; } if (ttsPlayerStatus === 'paused') { resumeAudio(); return; } const plainText = markdownToPlainText(currentNote.body); if (!plainText) { toast.error('No readable content in this note.'); return; } if (ttsAbortRef.current) { ttsAbortRef.current.abort(); } const controller = new AbortController(); ttsAbortRef.current = controller; setIsSynthesizingTts(true); try { const blob = await synthesizeTts(plainText, { signal: controller.signal }); if (ttsUrlRef.current) { URL.revokeObjectURL(ttsUrlRef.current); } const url = URL.createObjectURL(blob); ttsUrlRef.current = url; playAudio(url); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { return; } const message = err instanceof Error ? err.message : 'Failed to generate speech.'; toast.error(message); } finally { ttsAbortRef.current = null; setIsSynthesizingTts(false); } }; const handleTtsStop = () => { stopTts(); }; const handleSelectNote = (path: string) => { setSelectedPath(path); setError(null); setIsEditMode(false); // Exit edit mode when switching notes }; // Refresh all views when notes are changed const refreshAll = async () => { console.log('[MainApp] refreshAll called'); try { // Note: Backend automatically updates index when notes are created // We just need to fetch the fresh data // Small delay to ensure backend indexing completes await new Promise(resolve => setTimeout(resolve, 500)); // Fetch fresh data const [notesList, health] = await Promise.all([ listNotes(), getIndexHealth().catch(() => null) ]); console.log('[MainApp] Fetched', notesList.length, 'notes'); setNotes(notesList); setIndexHealth(health); setGraphRefreshTrigger(prev => { const newValue = prev + 1; console.log('[MainApp] Graph trigger:', prev, '→', newValue); return newValue; }); console.log('[MainApp] State updated'); } catch (err) { console.error('[MainApp] Error refreshing data:', err); } }; // 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); } }; const ttsStatus = isSynthesizingTts ? 'loading' : ttsPlayerStatus; const ttsDisabledReason = undefined; return (
{/* Demo warning banner */} DEMO ONLY - ALL DATA IS TEMPORARY AND MAY BE DELETED AT ANY TIME {/* Top bar */}

Vault.MCP

{isDemoMode && ( )}
{/* Main content */}
{/* Left sidebar */}
Create New Note Enter a name for your new note. The .md extension will be added automatically if not provided.
setNewNoteName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreateNote(); } }} />
Create New Folder Enter a name for your new folder. You can use forward slashes for nested folders (e.g., "Projects/Work").
setNewFolderName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreateFolder(); } }} />
{isLoadingNotes ? ( ) : ( )}
{/* Main content pane */}
{error && (
{error}
)} {isGraphView ? ( { handleSelectNote(path); setIsGraphView(false); }} refreshTrigger={graphRefreshTrigger} /> ) : ( isLoadingNote || !isFontReady ? ( ) : currentNote ? ( isEditMode ? ( ) : ( ) ) : (

Select a note to view

{notes.length === 0 ? 'No notes available. Create your first note to get started.' : 'Choose a note from the sidebar'}

) )}
{isChatOpen && ( <> )}
{/* Footer with Index Health */}
{indexHealth ? ( <> {indexHealth.note_count} note{indexHealth.note_count !== 1 ? 's' : ''} indexed ) : ( <> {notes.length} note{notes.length !== 1 ? 's' : ''} indexed )}
{indexHealth && indexHealth.last_incremental_update && (
Last updated: {new Date(indexHealth.last_incremental_update).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', })}
)}
); }