Document-MCP / frontend /src /pages /MainApp.tsx
Wothmag07's picture
Doc-MCP Application
1e6a9db
/**
* 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 } 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 { SearchBar } from '@/components/SearchBar';
import { NoteViewer } from '@/components/NoteViewer';
import { NoteEditor } from '@/components/NoteEditor';
import {
listNotes,
getNote,
getBacklinks,
getIndexHealth,
createNote,
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';
export function MainApp() {
const navigate = useNavigate();
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 [indexHealth, setIndexHealth] = useState<IndexHealth | null>(null);
const [isNewNoteDialogOpen, setIsNewNoteDialogOpen] = useState(false);
const [newNoteName, setNewNoteName] = useState('');
// 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);
// 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$/, ''));
return pathSlug.endsWith(slug);
});
}
if (targetNote) {
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 = () => {
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 create new note
const handleCreateNote = async () => {
if (!newNoteName.trim()) return;
try {
const notePath = newNoteName.endsWith('.md') ? newNoteName : `${newNoteName}.md`;
const note = await createNote({
note_path: notePath,
title: newNoteName.replace(/\.md$/, ''),
body: `# ${newNoteName.replace(/\.md$/, '')}\n\nStart writing your note here...`,
});
// Refresh notes list
const notesList = await listNotes();
setNotes(notesList);
// Select the new note
setSelectedPath(note.note_path);
setIsNewNoteDialogOpen(false);
setNewNoteName('');
// Switch to edit mode for the new note
setIsEditMode(true);
} catch (err) {
if (err instanceof APIException) {
setError(err.error);
} else {
setError('Failed to create note');
}
console.error('Error creating 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">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">📚 Document Viewer</h1>
<div className="flex gap-2">
<Dialog open={isNewNoteDialogOpen} onOpenChange={setIsNewNoteDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-2" />
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)}>
Cancel
</Button>
<Button onClick={handleCreateNote} disabled={!newNoteName.trim()}>
Create Note
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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">
<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">
<SearchBar onSelectNote={handleSelectNote} />
<Separator />
</div>
<div className="flex-1 overflow-hidden">
{isLoadingNotes ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Loading notes...
</div>
) : (
<DirectoryTree
notes={notes}
selectedPath={selectedPath || undefined}
onSelectNote={handleSelectNote}
/>
)}
</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>
)}
{isLoadingNote ? (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading note...</div>
</div>
) : currentNote ? (
isEditMode ? (
<NoteEditor
note={currentNote}
onSave={handleNoteSave}
onCancel={handleEditCancel}
onWikilinkClick={handleWikilinkClick}
/>
) : (
<NoteViewer
note={currentNote}
backlinks={backlinks}
onEdit={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">
<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>
);
}