notes/src/App.tsx

465 lines
16 KiB
TypeScript
Raw Normal View History

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 {
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 />} />
</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;
}