bigwolfe
Add demo token endpoint and read-only frontend handling
ffcd038
raw
history blame
23.1 kB
/**
* 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>
);
}