300 lines
9.5 KiB
TypeScript
300 lines
9.5 KiB
TypeScript
|
|
import { useState, useEffect, useCallback, createContext, useContext } 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 {
|
|||
|
|
listNotes,
|
|||
|
|
readNote,
|
|||
|
|
writeNote,
|
|||
|
|
getVaultPath,
|
|||
|
|
setVaultPath,
|
|||
|
|
ensureVault,
|
|||
|
|
getOrCreateDaily,
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 navigate = useNavigate();
|
|||
|
|
|
|||
|
|
// 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) {
|
|||
|
|
// Default to the project's vault directory
|
|||
|
|
path = "/home/amir/code/notes/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();
|
|||
|
|
}, [vaultPath, refreshNotes]);
|
|||
|
|
|
|||
|
|
// Build backlinks for current note
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!vaultPath || !currentNote || !notes.length) {
|
|||
|
|
setBacklinks([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
const currentName = currentNote
|
|||
|
|
.replace(/\.md$/, "")
|
|||
|
|
.split("/")
|
|||
|
|
.pop()
|
|||
|
|
?.toLowerCase();
|
|||
|
|
if (!currentName) return;
|
|||
|
|
|
|||
|
|
// Read all notes and find backlinks
|
|||
|
|
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) {
|
|||
|
|
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 {
|
|||
|
|
// Skip unreadable notes
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div className="flex h-screen w-screen overflow-hidden">
|
|||
|
|
<Sidebar />
|
|||
|
|
<Routes>
|
|||
|
|
<Route path="/" element={<WelcomeScreen />} />
|
|||
|
|
<Route path="/note/:path" element={<NoteView />} />
|
|||
|
|
<Route path="/daily" element={<DailyView />} />
|
|||
|
|
<Route path="/graph" element={<GraphView />} />
|
|||
|
|
</Routes>
|
|||
|
|
</div>
|
|||
|
|
</VaultContext.Provider>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Note View ──────────────────────────────────────────────── */
|
|||
|
|
function NoteView() {
|
|||
|
|
const { path } = useParams<{ path: string }>();
|
|||
|
|
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
|
|||
|
|
const decodedPath = decodeURIComponent(path || "");
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!decodedPath || !vaultPath) return;
|
|||
|
|
setCurrentNote(decodedPath);
|
|||
|
|
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
|
|||
|
|
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-1 overflow-hidden">
|
|||
|
|
<main className="flex-1 overflow-y-auto">
|
|||
|
|
<Editor />
|
|||
|
|
</main>
|
|||
|
|
<Backlinks />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 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 });
|
|||
|
|
});
|
|||
|
|
}, [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;
|
|||
|
|
}
|