Four small things about the shape of the app. Covers can be patterns as well as colours — ruled paper, graph paper, pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn entirely with CSS gradient layers. No image files, nothing to upload, crisp at any size, and offline like everything else. A cover is stored as an index into that list, so the new ones are appended and never inserted: renumbering would silently repaint every notebook anyone already has. Two things the patterns taught. One idea per cover: two crossed diagonals meant as a chevron came out as noisy plaid at the size a cover is actually read, and were replaced with plain bands. And the grid has to scroll — with 27 covers the dialog ran past the bottom of a laptop screen and took the Create button with it, which a check now holds at 1440x800. The search box in a notebook is simply there now, and the button that used to open it is gone. A magnifying glass cost a click and having to remember that searching was possible at all; a search box is one line and says for itself what it does. Margin notes went from 208x74 to 288x112 — from three short lines to about five. Three lines is a caption, not a remark on a passage, and the space beside the page was sitting mostly empty. And the notebook edit dialog was still part German, and would not say what the server's default number of versions actually is. It names it now, read from the server rather than written into the interface: it is set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start lying the day that changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
805 lines
27 KiB
React
805 lines
27 KiB
React
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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: "100vh",
|
|
background: T.bg,
|
|
color: T.text,
|
|
fontFamily: FONT_UI,
|
|
fontSize: 16,
|
|
}}
|
|
>
|
|
Loading...
|
|
</div>
|
|
);
|
|
|
|
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
|
|
|
|
// On a phone the list and the editor take turns; on a desktop both are shown.
|
|
const showList = !(isMobile && mobileView === "editor" && activeNote);
|
|
const showEditor = !isMobile || mobileView === "editor";
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
height: "100vh",
|
|
width: "100vw",
|
|
overflow: "hidden",
|
|
background: T.bg,
|
|
color: T.text,
|
|
fontFamily: FONT_UI,
|
|
}}
|
|
>
|
|
<style>{`
|
|
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}
|
|
::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px}
|
|
button:hover{opacity:0.88}
|
|
input::placeholder{color:${T.faint}}
|
|
[contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none}
|
|
[draggable]{user-select:none}
|
|
@media(max-width:767px){
|
|
::-webkit-scrollbar{display:none}
|
|
body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
|
|
}
|
|
`}</style>
|
|
|
|
<Sidebar
|
|
T={T}
|
|
dark={dark}
|
|
onToggleDark={() => setDark(!dark)}
|
|
isMobile={isMobile}
|
|
open={sidebarOpen}
|
|
onClose={() => setSidebarOpen(false)}
|
|
activeFolder={activeFolder}
|
|
onSelectFolder={openFolder}
|
|
notebooks={notebooks}
|
|
activeNotes={activeNotes}
|
|
trashedCount={trashedNotes.length}
|
|
onNewNotebook={() => setShowNewNb(true)}
|
|
onOpenAccount={() => setShowAccount(true)}
|
|
onNotebookContextMenu={notebookContextMenu}
|
|
dnd={dnd}
|
|
canInstall={canInstall}
|
|
onInstall={promptInstall}
|
|
syncStatus={syncStatus}
|
|
online={sync.isOnline()}
|
|
/>
|
|
|
|
{activeFolder === "dashboard" ? (
|
|
<Dashboard
|
|
T={T}
|
|
isMobile={isMobile}
|
|
notebooks={notebooks}
|
|
activeNotes={activeNotes}
|
|
activeFolder={activeFolder}
|
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
|
onOpenNotebook={openFolder}
|
|
onOpenNote={(note) => {
|
|
setActiveFolder(note.folderId);
|
|
openNote(note.id);
|
|
}}
|
|
onNewNotebook={() => setShowNewNb(true)}
|
|
onNotebookContextMenu={notebookContextMenu}
|
|
dnd={dnd}
|
|
/>
|
|
) : (
|
|
<>
|
|
{showList && (
|
|
<NoteList
|
|
T={T}
|
|
isMobile={isMobile}
|
|
title={folderName}
|
|
notes={filteredNotes}
|
|
activeNote={activeNote}
|
|
isTrashView={isTrashView}
|
|
trashedCount={trashedNotes.length}
|
|
snippetFor={search.snippetFor}
|
|
query={searchQuery}
|
|
onQueryChange={setSearchQuery}
|
|
sortMode={sortMode}
|
|
sortModes={sortModesFor(!!activeNotebook)}
|
|
onSortModeChange={setSortMode}
|
|
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
|
|
separators={separators}
|
|
onAddSeparator={addSeparator}
|
|
renamingSeparator={renamingSeparator}
|
|
onStartRenameSeparator={setRenamingSeparator}
|
|
onRenameSeparator={renameSeparator}
|
|
onSeparatorContextMenu={separatorContextMenu}
|
|
dnd={noteDnd}
|
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
|
onSelectNote={openNote}
|
|
onNoteContextMenu={noteContextMenu}
|
|
onCreateNote={createNote}
|
|
onImportPdf={importPdf}
|
|
onImportMarkdown={importMarkdown}
|
|
onEmptyTrash={emptyTrash}
|
|
/>
|
|
)}
|
|
{showEditor && (
|
|
<EditorPane
|
|
T={T}
|
|
dark={dark}
|
|
noteFont={noteFont}
|
|
isMobile={isMobile}
|
|
note={openNoteData}
|
|
isTrashView={isTrashView}
|
|
restoreEpoch={restoreEpoch}
|
|
backlinks={backlinks}
|
|
linkProps={linkProps}
|
|
onBack={closeNote}
|
|
onUpdate={(u) => data.updateNote(activeNote, u)}
|
|
onToggleStar={() => data.toggleStar(activeNote)}
|
|
onTrash={() => trashNote(activeNote)}
|
|
onRestore={() => restoreNote(activeNote)}
|
|
onPermanentDelete={() => permanentDeleteNote(activeNote)}
|
|
onOpenHistory={() => setHistoryFor(activeNote)}
|
|
onExport={() => noteMeta && setExporting({ label: noteMeta.title, notes: [noteMeta] })}
|
|
onCreateNote={createNote}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{showNewNb && (
|
|
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
|
|
)}
|
|
{editingNb && (
|
|
<EditNotebookModal
|
|
notebook={editingNb}
|
|
onClose={() => setEditingNb(null)}
|
|
onSave={(u) => updateNotebook(editingNb.id, u)}
|
|
defaultMaxVersions={user?.defaultMaxVersions}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{showAccount && (
|
|
<AccountMenu
|
|
user={user}
|
|
onClose={() => setShowAccount(false)}
|
|
onLogout={() => {
|
|
setShowAccount(false);
|
|
onLogout();
|
|
}}
|
|
onUserUpdate={setUser}
|
|
onOpenAdmin={() => {
|
|
setShowAccount(false);
|
|
setShowAdmin(true);
|
|
}}
|
|
onExportAll={() => {
|
|
setShowAccount(false);
|
|
setExporting({ label: "All Notes", notes: activeNotes });
|
|
}}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{pickingNotebook && (
|
|
<NotebookPicker
|
|
notebooks={notebooks}
|
|
title={pickingNotebook.title}
|
|
dark={dark}
|
|
onClose={() => setPickingNotebook(null)}
|
|
onPick={(id) => {
|
|
const { run } = pickingNotebook;
|
|
setPickingNotebook(null);
|
|
run(id);
|
|
}}
|
|
/>
|
|
)}
|
|
{importing && (
|
|
<ImportDialog
|
|
files={importing.files}
|
|
folderId={importing.folderId}
|
|
dark={dark}
|
|
onDone={() => data.reload()}
|
|
onClose={() => setImporting(null)}
|
|
/>
|
|
)}
|
|
{exporting && (
|
|
<ExportDialog
|
|
notes={exporting.notes}
|
|
notebooks={notebooks}
|
|
label={exporting.label}
|
|
dark={dark}
|
|
onClose={() => setExporting(null)}
|
|
/>
|
|
)}
|
|
{showAdmin && user?.isAdmin && (
|
|
<AdminPanel currentUser={user} onClose={() => setShowAdmin(false)} dark={dark} />
|
|
)}
|
|
{historyFor && (
|
|
<HistoryModal
|
|
noteId={historyFor}
|
|
noteTitle={notes.find((n) => n.id === historyFor)?.title}
|
|
dark={dark}
|
|
onClose={() => setHistoryFor(null)}
|
|
onRestored={(content) => {
|
|
data.setNoteContent(historyFor, content);
|
|
setRestoreEpoch((e) => e + 1);
|
|
}}
|
|
/* The first drawn page stands for the drawing in a thumbnail this
|
|
small. Asking for page one would show a blank sheet for a note whose
|
|
ink starts on page three. */
|
|
renderDrawing={(doc) => (
|
|
<DrawingLayer
|
|
strokes={sheetStrokes(doc, drawnSheets(doc)[0] ?? 1)}
|
|
width={PREVIEW_DRAWING_W}
|
|
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
|
|
zIndex={1}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
{confirmDialog && (
|
|
<ConfirmDialog
|
|
title={confirmDialog.title}
|
|
message={confirmDialog.message}
|
|
confirmLabel={confirmDialog.confirmLabel}
|
|
onConfirm={confirmDialog.onConfirm}
|
|
onCancel={() => setConfirmDialog(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
items={ctxMenu.items}
|
|
onClose={() => setCtxMenu(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|