bigwolfeman
font
f2ee9ec
/**
* 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<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());
const [isChatOpen, setIsChatOpen] = useState(false);
const [graphRefreshTrigger, setGraphRefreshTrigger] = useState(0);
const [isSynthesizingTts, setIsSynthesizingTts] = useState(false);
const ttsUrlRef = useRef<string | null>(null);
const ttsAbortRef = useRef<AbortController | null>(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 (
<GlowParticleEffect config="vibrant" triggerSelector="button">
<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-2 animate-fade-in">
<div className="relative flex items-center justify-center">
<h1
className="text-2xl tracking-[0.15em] uppercase select-none"
style={{
fontFamily: '"Press Start 2P", monospace',
fontWeight: 400,
color: "#3b82f6",
textShadow: `
0px 0px 1px #000000,
1px 1px 0 #111827,
2px 2px 0 #020617,
3px 3px 0 #020617,
4px 4px 0 #020617,
5px 5px 0 #020617,
0px 0px 4px rgba(15,23,42,0.9),
0px 0px 10px rgba(59,130,246,0.7)
`,
textRendering: "geometricPrecision",
WebkitFontSmoothing: "none",
transform: "perspective(700px) rotateX(18deg)",
transformOrigin: "bottom center",
}}
>
Vault.MCP
</h1>
<div className="absolute right-0 flex gap-2">
{isDemoMode && (
<Button
variant="default"
size="sm"
onClick={() => login()}
title="Sign in with Hugging Face"
>
Sign in
</Button>
)}
<Button
variant={isChatOpen ? "secondary" : "ghost"}
size="sm"
onClick={() => setIsChatOpen(!isChatOpen)}
title={isChatOpen ? "Close Chat" : "Open AI Planning Agent"}
>
<MessageCircle className="h-4 w-4" />
</Button>
<Button
variant={isGraphView ? "secondary" : "ghost"}
size="sm"
onClick={() => setIsGraphView(!isGraphView)}
title={isGraphView ? "Switch to Note View" : "Switch to Graph View"}
className="transition-all duration-250 ease-out"
>
<Network className="h-4 w-4 transition-transform duration-250" />
</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' }}>
<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);
}}
refreshTrigger={graphRefreshTrigger}
/>
) : (
isLoadingNote || !isFontReady ? (
<NoteViewerSkeleton />
) : currentNote ? (
isEditMode ? (
<NoteEditor
note={currentNote}
onSave={handleNoteSave}
onCancel={handleEditCancel}
onWikilinkClick={handleWikilinkClick}
/>
) : (
<NoteViewer
note={currentNote}
backlinks={backlinks}
onEdit={isDemoMode ? undefined : handleEdit}
onWikilinkClick={handleWikilinkClick}
ttsStatus={ttsStatus}
onTtsToggle={handleTtsToggle}
onTtsStop={handleTtsStop}
ttsDisabledReason={ttsDisabledReason}
ttsVolume={ttsVolume}
onTtsVolumeChange={setTtsVolume}
fontSize={fontSize}
onFontSizeChange={setFontSize}
/>
)
) : (
<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>
{isChatOpen && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="animate-slide-in">
<ChatPanel
onNavigateToNote={handleSelectNote}
onNotesChanged={refreshAll}
/>
</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>
</GlowParticleEffect>
);
}