468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
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;
|
||
}
|