notes/src/App.tsx

468 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, createContext, useContext, lazy, Suspense } from "react";
import { Routes, Route, useNavigate, useParams } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { Editor } from "./components/Editor";
import { Backlinks } from "./components/Backlinks";
import { GraphView } from "./components/GraphView";
import { CommandPalette } from "./components/CommandPalette";
import { SplitView } from "./components/SplitView";
import { LinkPreview } from "./components/LinkPreview";
import { CalendarView } from "./components/CalendarView";
import { ThemePicker, useThemeInit } from "./components/ThemePicker";
import { KanbanView } from "./components/KanbanView";
import { SearchReplace } from "./components/SearchReplace";
import { FlashcardView } from "./components/FlashcardView";
import { CSSEditor, useCustomCssInit } from "./components/CSSEditor";
import { TabBar } from "./components/TabBar";
const WhiteboardView = lazy(() => import("./components/WhiteboardView").then(m => ({ default: m.WhiteboardView })));
import { DatabaseView } from "./components/DatabaseView";
import { GitPanel } from "./components/GitPanel";
import { TimelineView } from "./components/TimelineView";
import { GraphAnalytics } from "./components/GraphAnalytics";
import IntegrityReport from "./components/IntegrityReport";
import AuditLog from "./components/AuditLog";
import {
listNotes,
readNote,
writeNote,
getVaultPath,
setVaultPath,
ensureVault,
getOrCreateDaily,
addVault,
getFavorites,
getBacklinkContext,
setFavorites as setFavoritesCmd,
type NoteEntry,
} from "./lib/commands";
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
/* ── Vault Context ──────────────────────────────────────────── */
interface VaultContextType {
vaultPath: string;
notes: NoteEntry[];
refreshNotes: () => Promise<void>;
currentNote: string | null;
setCurrentNote: (path: string | null) => void;
noteContent: string;
setNoteContent: (content: string) => void;
backlinks: BacklinkEntry[];
navigateToNote: (name: string) => void;
sidebarOpen: boolean;
toggleSidebar: () => void;
editMode: boolean;
toggleEditMode: () => void;
splitNote: string | null;
setSplitNote: (path: string | null) => void;
favorites: string[];
toggleFavorite: (path: string) => void;
recentNotes: string[];
switchVault: (path: string) => Promise<void>;
focusMode: boolean;
toggleFocusMode: () => void;
}
const VaultContext = createContext<VaultContextType>(null!);
export const useVault = () => useContext(VaultContext);
/* ── Main App ───────────────────────────────────────────────── */
export default function App() {
const [vaultPath, setVaultPathState] = useState<string>("");
const [notes, setNotes] = useState<NoteEntry[]>([]);
const [currentNote, setCurrentNote] = useState<string | null>(null);
const [noteContent, setNoteContent] = useState<string>("");
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [editMode, setEditMode] = useState(true);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [splitNote, setSplitNote] = useState<string | null>(null);
const [favorites, setFavorites] = useState<string[]>([]);
const [recentNotes, setRecentNotes] = useState<string[]>([]);
const [themePickerOpen, setThemePickerOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [searchReplaceOpen, setSearchReplaceOpen] = useState(false);
const [cssEditorOpen, setCssEditorOpen] = useState(false);
const navigate = useNavigate();
// Apply saved theme + custom CSS on mount
useThemeInit();
useCustomCssInit();
const toggleSidebar = useCallback(() => setSidebarOpen(v => !v), []);
const toggleEditMode = useCallback(() => setEditMode(v => !v), []);
const toggleFocusMode = useCallback(() => setFocusMode(v => !v), []);
// Initialize vault
useEffect(() => {
(async () => {
try {
console.log("[GraphNotes] Initializing vault...");
let path: string | null = null;
try {
path = await getVaultPath();
console.log("[GraphNotes] Stored vault path:", path);
} catch (e) {
console.warn("[GraphNotes] getVaultPath failed:", e);
}
if (!path) {
// No stored vault path — use a sensible default
path = "./vault";
console.log("[GraphNotes] Using default vault path:", path);
try {
await setVaultPath(path);
} catch (e) {
console.warn("[GraphNotes] setVaultPath failed:", e);
}
}
try {
await ensureVault(path);
} catch (e) {
console.warn("[GraphNotes] ensureVault failed:", e);
}
setVaultPathState(path);
console.log("[GraphNotes] Vault ready at:", path);
setLoading(false);
} catch (e) {
console.error("[GraphNotes] Init failed:", e);
setError(String(e));
setLoading(false);
}
})();
}, []);
// Load notes when vault is ready
const refreshNotes = useCallback(async () => {
if (!vaultPath) return;
const entries = await listNotes(vaultPath);
setNotes(entries);
}, [vaultPath]);
useEffect(() => {
if (vaultPath) refreshNotes();
// Load favorites
if (vaultPath) {
getFavorites(vaultPath).then(setFavorites).catch(() => setFavorites([]));
}
}, [vaultPath, refreshNotes]);
// Track recent notes
const trackRecent = useCallback((notePath: string) => {
setRecentNotes(prev => {
const next = [notePath, ...prev.filter(p => p !== notePath)].slice(0, 10);
return next;
});
}, []);
// Watch currentNote changes to track recents
useEffect(() => {
if (currentNote) trackRecent(currentNote);
}, [currentNote, trackRecent]);
// Toggle favorite
const toggleFavorite = useCallback((path: string) => {
setFavorites(prev => {
const next = prev.includes(path)
? prev.filter(p => p !== path)
: [...prev, path];
// Persist
if (vaultPath) setFavoritesCmd(vaultPath, next).catch(() => { });
return next;
});
}, [vaultPath]);
// Switch vault
const switchVault = useCallback(async (newPath: string) => {
try {
await ensureVault(newPath);
await setVaultPath(newPath);
await addVault(newPath);
setVaultPathState(newPath);
setCurrentNote(null);
setNoteContent("");
setSplitNote(null);
setRecentNotes([]);
navigate("/");
} catch (e) {
console.error("Switch vault failed:", e);
}
}, [navigate, setCurrentNote, setNoteContent]);
// Build backlinks for current note using backend context
useEffect(() => {
if (!vaultPath || !currentNote || !notes.length) {
setBacklinks([]);
return;
}
(async () => {
const currentName = currentNote
.replace(/\.md$/, "")
.split("/")
.pop() || "";
if (!currentName) return;
try {
const contexts = await getBacklinkContext(vaultPath, currentName);
const entries: BacklinkEntry[] = contexts.map(c => ({
sourcePath: c.source_path,
sourceName: c.source_name,
context: c.excerpt,
}));
setBacklinks(entries);
} catch {
// Fallback: simple wikilink scan
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName.toLowerCase()) {
const lines = content.split("\n");
const contextLine = lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
}
}
} catch { }
}
setBacklinks(entries);
}
})();
}, [vaultPath, currentNote, notes]);
const navigateToNote = useCallback(
(name: string) => {
// Find the note by name (case-insensitive)
const allPaths = flattenNotes(notes);
const match = allPaths.find(
(p) =>
p
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase() === name.toLowerCase()
);
if (match) {
navigate(`/note/${encodeURIComponent(match)}`);
} else {
// Create new note
const newPath = `${name}.md`;
writeNote(vaultPath, newPath, `# ${name}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(newPath)}`);
});
}
},
[notes, vaultPath, navigate, refreshNotes]
);
// ── Global keyboard shortcuts ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
// ⌘⇧F — Focus mode
if (e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
setFocusMode(v => !v);
return;
}
switch (e.key.toLowerCase()) {
case "k":
e.preventDefault();
setCmdPaletteOpen(v => !v);
break;
case "n":
e.preventDefault();
{
const name = prompt("Note name:");
if (name?.trim()) navigateToNote(name.trim());
}
break;
case "g":
e.preventDefault();
navigate("/graph");
break;
case "d":
e.preventDefault();
navigate("/daily");
break;
case "e":
e.preventDefault();
toggleEditMode();
break;
case "\\":
e.preventDefault();
toggleSidebar();
break;
case "t":
e.preventDefault();
setThemePickerOpen(v => !v);
break;
case "h":
e.preventDefault();
setSearchReplaceOpen(v => !v);
break;
case "u":
e.preventDefault();
setCssEditorOpen(v => !v);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [navigate, navigateToNote, toggleEditMode, toggleSidebar]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in">
<div className="text-4xl mb-4">📝</div>
<p className="text-[var(--text-secondary)]">Loading vault...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in max-w-md">
<div className="text-4xl mb-4"></div>
<p className="text-[var(--text-primary)] font-semibold mb-2">Failed to load vault</p>
<p className="text-[var(--text-muted)] text-sm">{error}</p>
</div>
</div>
);
}
return (
<VaultContext.Provider
value={{
vaultPath,
notes,
refreshNotes,
currentNote,
setCurrentNote,
noteContent,
setNoteContent,
backlinks,
navigateToNote,
sidebarOpen,
toggleSidebar,
editMode,
toggleEditMode,
splitNote,
setSplitNote,
favorites,
toggleFavorite,
recentNotes,
switchVault,
focusMode,
toggleFocusMode,
}}
>
<div className={`flex h-screen w-screen overflow-hidden ${focusMode ? "focus-mode" : ""}`}>
{sidebarOpen && !focusMode && <Sidebar />}
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/note/:path" element={<SplitView />} />
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
<Route path="/calendar" element={<CalendarView />} />
<Route path="/kanban" element={<KanbanView />} />
<Route path="/flashcards" element={<FlashcardView />} />
<Route path="/whiteboard/:name" element={<Suspense fallback={<div className="flex-1 flex items-center justify-center"><p className="text-[var(--text-muted)]">Loading whiteboard...</p></div>}><WhiteboardView /></Suspense>} />
<Route path="/database" element={<DatabaseView />} />
<Route path="/timeline" element={<TimelineView />} />
<Route path="/analytics" element={<GraphAnalytics />} />
<Route path="/integrity" element={<IntegrityReport onClose={() => navigate('/')} />} />
<Route path="/audit-log" element={<AuditLog onClose={() => navigate('/')} />} />
</Routes>
</div>
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
<LinkPreview />
<ThemePicker open={themePickerOpen} onClose={() => setThemePickerOpen(false)} />
<SearchReplace open={searchReplaceOpen} onClose={() => setSearchReplaceOpen(false)} />
<CSSEditor open={cssEditorOpen} onClose={() => setCssEditorOpen(false)} />
</VaultContext.Provider>
);
}
/* ── Daily View ─────────────────────────────────────────────── */
function DailyView() {
const { vaultPath, refreshNotes } = useVault();
const navigate = useNavigate();
useEffect(() => {
if (!vaultPath) return;
getOrCreateDaily(vaultPath).then((dailyPath) => {
refreshNotes();
navigate(`/note/${encodeURIComponent(dailyPath)}`, { replace: true });
}).catch((e) => {
console.error("[GraphNotes] Failed to create daily note:", e);
navigate("/", { replace: true });
});
}, [vaultPath, navigate, refreshNotes]);
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)]">Creating daily note...</p>
</div>
);
}
/* ── Welcome Screen ─────────────────────────────────────────── */
function WelcomeScreen() {
const navigate = useNavigate();
return (
<div className="welcome">
<div className="welcome-card">
<div className="welcome-icon">📝</div>
<h1 className="welcome-title">Graph Notes</h1>
<p className="welcome-subtitle">
A local-first knowledge base with bidirectional linking
and graph visualization. Your notes, your data.
</p>
<div className="welcome-actions">
<button className="btn btn-primary" onClick={() => navigate("/daily")}>
📅 Today's Note
</button>
<button className="btn" onClick={() => navigate("/graph")}>
🔮 Graph View
</button>
</div>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNotes(entries: NoteEntry[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}