import { useCallback, useEffect, useMemo, useState } from "react"; import * as sync from "./syncEngine"; import { useNoteData } from "./hooks/useNoteData"; import { useSearchIndex } from "./hooks/useSearchIndex"; import { useResponsive } from "./hooks/useResponsive"; import { usePwaInstall } from "./hooks/usePwaInstall"; import { useNotebookDnd } from "./hooks/useNotebookDnd"; import { useNavigationHistory } from "./hooks/useNavigationHistory"; import { COVERS, DEFAULT_FOLDERS } from "./ui/constants.jsx"; import { I } from "./ui/icons.jsx"; import { makeTheme, FONT_UI } from "./ui/theme"; import { ConfirmDialog } from "./ui/ConfirmDialog.jsx"; import { ContextMenu } from "./ui/ContextMenu.jsx"; import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx"; import { AccountMenu } from "./ui/AccountMenu.jsx"; import { AuthScreen } from "./ui/AuthScreen.jsx"; import { AdminPanel } from "./ui/AdminPanel.jsx"; import { ExportDialog } from "./ui/ExportDialog.jsx"; import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx"; import { ImportDialog } from "./ui/ImportDialog.jsx"; import { Sidebar } from "./ui/Sidebar.jsx"; import { Dashboard } from "./features/notebooks/Dashboard.jsx"; import { NoteList } from "./features/notes/NoteList.jsx"; import { DEFAULT_SORT, canReorder, sortModesFor, sortNotes, withNewSeparator, } from "./features/notes/noteSort"; import { EditorPane } from "./features/notes/EditorPane.jsx"; import { HistoryModal } from "./features/notes/HistoryModal.jsx"; import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx"; import { CANON_W, CANON_H, PREVIEW_DRAWING_W, drawnSheets, sheetStrokes, } from "./features/drawing/strokes.js"; /** Views that are not a notebook and therefore cannot hold a new note. */ const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"]; const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null }; /** * Root of the application. * * Holds the things that describe *where the user is* — the open notebook, the * open note, the search box, whichever dialog is up — and hands the data itself * to `useNoteData`. Rendering is delegated to the three panes below. */ export default function App() { const [dark, setDark] = useState(true); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [syncStatus, setSyncStatus] = useState("synced"); const [activeFolder, setActiveFolder] = useState("dashboard"); const [activeNote, setActiveNote] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showNewNb, setShowNewNb] = useState(false); const [editingNb, setEditingNb] = useState(null); const [showAccount, setShowAccount] = useState(false); const [showAdmin, setShowAdmin] = useState(false); // { notes, label } — set while the export dialog is up. const [exporting, setExporting] = useState(null); // { title, run } — set while the app is asking which notebook to use. const [pickingNotebook, setPickingNotebook] = useState(null); // Set while files are being imported; holds the progress and then the result. const [importing, setImporting] = useState(null); /* Sort order for the views that are not notebooks. Those have nowhere to store a choice — "All Notes" is not a thing that can carry a setting — so it lasts for the session and no longer. */ const [virtualSort, setVirtualSort] = useState({}); // Which separator is being renamed. Held here because the context menu that // starts it lives here too. const [renamingSeparator, setRenamingSeparator] = useState(null); const [historyFor, setHistoryFor] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); // Bumped on a version restore to force a fresh editor on the restored body. const [restoreEpoch, setRestoreEpoch] = useState(0); const T = useMemo(() => makeTheme(dark), [dark]); const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive(); const { canInstall, promptInstall } = usePwaInstall(); // Presentation only, and it lives on the account so it follows to the phone. const noteFont = user?.preferences?.noteFont || "serif"; const data = useNoteData(user, activeNote); const { notes, notebooks, noteContents, backlinks } = data; const search = useSearchIndex(user, notes, searchQuery); const dnd = useNotebookDnd(data.reorderNotebooks); const noteDnd = useNotebookDnd((dragId, targetId) => data.rearrange(activeFolder, dragId, targetId)); useEffect(() => sync.onSyncStatus(setSyncStatus), []); /* ── Session ── */ useEffect(() => { (async () => { try { const u = await sync.restoreSession(); if (u) { setUser(u); await data.reload(); try { await sync.fullSync(); await data.reload(); } catch (e) { if (e.message === "AUTH") setUser(null); } } } catch { /* a broken session must not block the app from starting */ } setLoading(false); })(); }, []); const onLogin = async (u) => { setUser(u); try { await sync.fullSync(); } catch { /* first sync can fail offline; local data still loads */ } await data.reload(); }; const onLogout = async () => { await sync.logout(); setUser(null); data.reset(); setActiveNote(null); setActiveFolder("dashboard"); }; /* ── Navigation ── */ const openFolder = useCallback( (id) => { setActiveFolder(id); setActiveNote(null); if (isMobile) { setSidebarOpen(false); setMobileView("list"); } }, [isMobile, setSidebarOpen, setMobileView] ); const openNote = useCallback( (id) => { setActiveNote(id); if (isMobile) setMobileView("editor"); }, [isMobile, setMobileView] ); const closeNote = useCallback(() => { setActiveNote(null); if (isMobile) setMobileView("list"); }, [isMobile, setMobileView]); // Adopts a location the user reached with the browser's back button. const applyNavState = useCallback( (s) => { setActiveFolder(s.folder); setActiveNote(s.note); setMobileView(s.view); }, [setMobileView] ); useNavigationHistory({ folder: activeFolder, note: activeNote, view: mobileView }, isMobile, applyNavState); /* ── Derived note lists ── */ const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]); const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]); const isTrashView = activeFolder === "trash"; const activeNotebook = notebooks.find((nb) => nb.id === activeFolder) || null; /* A real notebook remembers its order on the server; a view keeps one for the session. Either way the note list is told a single mode. */ const rawSortMode = activeNotebook ? activeNotebook.sortMode || DEFAULT_SORT : virtualSort[activeFolder] || DEFAULT_SORT; // A view has nowhere to keep a hand-made arrangement, so it cannot be in one — // even if an older version of the app left "manual" behind on a notebook that // has since been looked at through "All Notes". const sortMode = !activeNotebook && rawSortMode === "manual" ? DEFAULT_SORT : rawSortMode; const setSortMode = useCallback( (mode) => { if (activeNotebook) void data.updateNotebook(activeNotebook.id, { sortMode: mode }); else setVirtualSort((p) => ({ ...p, [activeFolder]: mode })); }, [activeNotebook, activeFolder, data] ); /* ── Separators ── * Lines the user draws between notes. They belong to the notebook's own * arrangement, so they are stored on it and only ever shown in manual order. */ const separators = activeNotebook?.separators || []; const writeSeparators = (next) => activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next }); const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`)); const renameSeparator = (id, label) => writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s))); const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id)); const separatorContextMenu = (e, separator) => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY, items: [ { label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) }, { divider: true }, { label: "Remove separator", icon: I.trash, danger: true, action: () => confirm({ title: "Remove separator", message: separator.label ? `Remove the separator "${separator.label}"? The notes stay.` : "Remove this separator? The notes stay.", confirmLabel: "Remove", onConfirm: () => removeSeparator(separator.id), }), }, ], }); }; const isSearching = searchQuery.trim().length > 0; const filteredNotes = useMemo(() => { let list; if (activeFolder === "trash") list = trashedNotes; else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred); else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes; else list = activeNotes.filter((n) => n.folderId === activeFolder); if (searchQuery.trim()) { // A search reads every note's text, so results keep the index's relevance // order instead of being re-sorted by date. if (search.results) { const rank = new Map(search.results.map((h, i) => [h.id, i])); return list.filter((n) => rank.has(n.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id)); } // Until the index answers, fall back to matching titles only. const q = searchQuery.toLowerCase(); return list.filter((n) => n.title.toLowerCase().includes(q)); } return sortNotes(list, sortMode); }, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]); const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null; // The editor opens as soon as the metadata is known; the body fills in when // it arrives, rather than blocking on a fetch. const openNoteData = noteMeta ? { ...noteMeta, ...EMPTY_BODY, ...noteContents[activeNote] } : null; const folderName = useMemo(() => { const builtIn = DEFAULT_FOLDERS.find((f) => f.id === activeFolder); if (builtIn) return builtIn.name; return notebooks.find((n) => n.id === activeFolder)?.name || "Notes"; }, [activeFolder, notebooks]); /* ── Note links ── */ // Candidates for the @-menu. Notes rank above notebooks, trashed notes never // appear, and the open note cannot link to itself. const searchLinkTargets = useCallback( (query) => { const q = (query || "").toLowerCase().trim(); const match = (name) => !q || name.toLowerCase().includes(q); return [ ...notes .filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled")) .slice(0, 8) .map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })), ...notebooks .filter((nb) => match(nb.name)) .slice(0, 4) .map((nb) => ({ id: nb.id, label: nb.name, kind: "notebook" })), ]; }, [notes, notebooks, activeNote] ); // A link stores the label it had when inserted; this keeps it showing the // target's current title, and returns null once the target is gone. const resolveLinkLabel = useCallback( (id) => { const n = notes.find((x) => x.id === id && !x.trashed); if (n) return n.title || "Untitled"; return notebooks.find((x) => x.id === id)?.name ?? null; }, [notes, notebooks] ); const openLinkTarget = useCallback( (id, kind) => { if (kind === "notebook") return openFolder(id); const target = notes.find((n) => n.id === id); if (!target) return; if (target.folderId) setActiveFolder(target.folderId); openNote(id); }, [notes, openFolder, openNote] ); const linkProps = useMemo( () => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }), [openLinkTarget, searchLinkTargets, resolveLinkLabel] ); /* ── Actions ── */ /** * Runs something that needs a notebook to put a note in. * * "All Notes", "Starred" and the dashboard are views, not places. The app used * to answer that silently with the first notebook, so a note or an imported * file landed somewhere the user had not chosen and could only find by knowing * that rule. Now it asks — once, remembering the answer as the default. */ const withNotebook = useCallback( (title, run) => { if (!VIRTUAL_FOLDERS.includes(activeFolder)) { rememberNotebook(activeFolder); return run(activeFolder); } if (notebooks.length === 0) return; // Nothing to ask about when there is only one place it could go. if (notebooks.length === 1) return run(notebooks[0].id); setPickingNotebook({ title, run }); }, [activeFolder, notebooks] ); /** * Opens a file chooser. * * The input is created and thrown away per use rather than living in the * markup: a hidden input that persists keeps its previous selection, and * choosing the same file twice in a row would then fire no change event. */ const chooseFiles = (accept, multiple, onChosen) => { const input = document.createElement("input"); input.type = "file"; input.accept = accept; input.multiple = multiple; input.onchange = () => { const files = [...(input.files || [])]; if (files.length) onChosen(files); }; input.click(); }; const reportFailure = (message) => setConfirmDialog({ title: "Import failed", message, confirmLabel: "OK", onConfirm: () => setConfirmDialog(null), }); const importPdf = () => withNotebook("Upload PDF", (folderId) => chooseFiles("application/pdf,.pdf", false, async ([file]) => { try { const note = await data.importPdf(folderId, file); if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); openNote(note.id); } catch (e) { reportFailure(e?.message || "The file could not be imported."); } }) ); /** Markdown files, or a ZIP written by this app's export. */ const importMarkdown = () => withNotebook("Import notes", (folderId) => chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => { if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); setImporting({ files, folderId }); }) ); const createNote = () => withNotebook("New note", async (folderId) => { const note = await data.createNote(folderId); if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); openNote(note.id); }); /** Runs a destructive action behind a confirmation prompt. */ const confirm = (dialog) => setConfirmDialog({ ...dialog, onConfirm: async () => { await dialog.onConfirm(); setConfirmDialog(null); }, }); const trashNote = (id) => confirm({ title: "Move to Trash", message: `Move "${notes.find((n) => n.id === id)?.title}" to trash?`, confirmLabel: "Move to Trash", onConfirm: async () => { await data.trashNote(id); if (activeNote === id) closeNote(); }, }); const restoreNote = async (id) => { await data.restoreNote(id); if (activeNote === id) closeNote(); }; const permanentDeleteNote = (id) => confirm({ title: "Delete Permanently", message: `Permanently delete "${notes.find((n) => n.id === id)?.title}"? This cannot be undone.`, onConfirm: async () => { await data.permanentDeleteNote(id); if (activeNote === id) closeNote(); }, }); const emptyTrash = () => confirm({ title: "Empty Trash", message: `Permanently delete all ${trashedNotes.length} trashed notes?`, onConfirm: async () => { await data.emptyTrash(); setActiveNote(null); }, }); const createNotebook = async (fields) => { const nb = await data.createNotebook(fields); setShowNewNb(false); setActiveFolder(nb.id); }; const updateNotebook = async (id, updates) => { await data.updateNotebook(id, updates); setEditingNb(null); }; const deleteNotebook = (id) => { const nb = notebooks.find((n) => n.id === id); const count = activeNotes.filter((n) => n.folderId === id).length; confirm({ title: "Delete Notebook", message: `Delete "${nb?.name}"${count > 0 ? ` and trash its ${count} notes` : ""} permanently?`, onConfirm: async () => { await data.deleteNotebook(id); if (activeFolder === id) setActiveFolder("dashboard"); }, }); }; /* ── Context menus ── */ const notebookContextMenu = (e, nb) => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY, items: [ { label: "Edit", icon: I.edit, action: () => setEditingNb(nb) }, { label: "Export...", icon: I.export, action: () => setExporting({ label: nb.name, notes: activeNotes.filter((n) => n.folderId === nb.id), }), }, { divider: true }, { label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) }, ], }); }; /** "Move to..." lists every notebook except the one the note is already in. */ const moveSubmenu = (note) => { const targets = notebooks .filter((nb) => nb.id !== note.folderId) .map((nb) => ({ label: nb.name, colorDot: COVERS[nb.cover % COVERS.length].bg, action: () => data.moveNote(note.id, nb.id), })); return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : []; }; const noteContextMenu = (e, note) => { e.preventDefault(); e.stopPropagation(); const items = note.trashed ? [ { label: "Restore", icon: I.restore, action: () => restoreNote(note.id) }, { divider: true }, { label: "Delete Permanently", icon: I.trash, danger: true, action: () => permanentDeleteNote(note.id), }, ] : [ { label: note.starred ? "Unstar" : "Star", icon: note.starred ? I.starFill : I.star, action: () => data.toggleStar(note.id), }, ...moveSubmenu(note), { label: "Export...", icon: I.export, action: () => setExporting({ label: note.title, notes: [note] }), }, { divider: true }, { label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) }, ]; setCtxMenu({ x: e.clientX, y: e.clientY, items }); }; /* ── Render ── */ if (loading) return (