Files
InkNote/frontend/src/App.jsx
T
marcandClaude Opus 5 d74fea4905 Chapters as rows, in two views, in their own colours
Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:20:14 +02:00

1149 lines
41 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 { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
import { MoveNoteDialog } from "./ui/MoveNoteDialog.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,
separatorsFor,
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);
/* Which chapter of the open notebook is being looked into; null is the
notebook itself. A chapter is a folder inside a notebook, so this is a
second step of "where the user is", not a second notebook. */
const [activeChapter, setActiveChapter] = 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);
// { title, value, onSave } while a chapter is being named or renamed.
const [namingChapter, setNamingChapter] = useState(null);
// The note whose destination is being chosen, if any.
const [movingNote, setMovingNote] = 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, reason: installReason, promptInstall } = usePwaInstall();
// Presentation only, and it lives on the account so it follows to the phone.
const noteFont = user?.preferences?.noteFont || "serif";
/* How a chapter opens: a further column beside the list, or unfolded under
its own row. A phone has room for neither, so there it fills the screen —
which is what "column" already did before there was a choice. */
const chapterView = user?.preferences?.chapterView || "column";
const inlineChapters = chapterView === "inline" && !isMobile;
const columnChapters = chapterView === "column" && !isMobile;
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
/* Notes reorder through their own list, which is the only place that knows
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
const rearrangeNotes = useCallback(
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
[data, activeFolder, activeChapter]
);
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);
setActiveChapter(null);
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]);
/* Chapters of the notebook in hand, with how many notes each holds — the
count is what makes a chapter worth opening rather than a guess. */
const folderChapters = useMemo(() => {
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
return list.map((c) => ({
...c,
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
}));
}, [data.chapters, activeFolder, notes]);
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
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 allSeparators = activeNotebook?.separators || [];
/* Only the ones drawn in the list being looked at — the notebook itself, or
the chapter that is open. */
const separators = useMemo(
() => separatorsFor(allSeparators, activeChapter),
[allSeparators, activeChapter]
);
const writeSeparators = (next) =>
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
/* Written back whole: the notebook stores one list, and the ones belonging to
the other chapters have to survive a change made in this one. */
const writeVisible = (visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.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 if (activeChapter && !inlineChapters && !columnChapters)
list = activeNotes.filter((n) => n.chapterId === activeChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
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,
activeChapter,
inlineChapters,
columnChapters,
searchQuery,
search.results,
activeNotes,
trashedNotes,
sortMode,
]);
/* The notes of the chapter being looked into, in the same order they would
have in a list of their own. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, 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) => {
/* A note made while a chapter is open belongs to that chapter — but only
if it is a chapter of the notebook being written into, which it is not
when the notebook had to be asked for. */
const chapterId = folderId === activeFolder ? activeChapter : null;
const note = await data.createNote(folderId, chapterId);
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..." — the other notebooks, and the chapters of this one.
*
* Both in one list because both answer the same question: where should this
* note live. A chapter of the notebook it is already in is one step; another
* notebook is another, and takes it out of whatever chapter it was in, since
* a chapter belongs to the book it is in.
*/
const moveSubmenu = (note) => {
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
const targets = [
...(note.chapterId
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
: []),
...here.map((c) => ({
label: c.name,
icon: I.books,
action: () => data.moveNote(note.id, note.folderId, c.id),
})),
...(here.length || note.chapterId ? [{ divider: true }] : []),
...notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: coverBackground(nb.cover, nb.coverPattern),
action: () => data.moveNote(note.id, nb.id, null),
})),
];
// A lone divider is not a menu.
const usable = targets.filter((t) => !t.divider);
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
};
/* ── Chapters ──
* Folders inside a notebook. Named on creation, because a chapter with no name
* is a row nobody can tell from the next one — unlike a separator, which is a
* line and reads perfectly well unnamed. */
const addChapter = () => {
if (!activeNotebook) return;
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
};
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{
label: "Rename",
icon: I.edit,
action: () =>
setNamingChapter({
title: "Rename chapter",
value: chapter.name,
onSave: (name) => data.renameChapter(chapter.id, name),
}),
},
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => data.recolourChapter(chapter.id, c.value),
})),
],
},
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
setConfirmDialog({
title: "Remove chapter?",
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
chapter.noteCount === 1 ? "" : "s"
} stay — they go back to the notebook.`,
confirmLabel: "Remove",
danger: true,
onConfirm: () => {
if (activeChapter === chapter.id) setActiveChapter(null);
void data.deleteChapter(chapter.id);
},
}),
},
],
});
};
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.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
);
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}
installReason={installReason}
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}
chapters={folderChapters}
/* Only in the fallback: there the list becomes the chapter, and
its header is the way back. Beside or below, the notebook's own
list carries on and keeps its chapter rows. */
openChapter={inlineChapters || columnChapters ? null : openChapter}
onOpenChapter={setActiveChapter}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onMoveNote={setMovingNote}
onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
onImportPdf={importPdf}
onImportMarkdown={importMarkdown}
onEmptyTrash={emptyTrash}
/>
)}
{/* The chapter's own column, beside the list — the way OneNote lays
out its pages. Only where there is room: on a phone the chapter
fills the screen instead, with a way back. */}
{columnChapters && openChapter && (
<div
data-chapter-column
style={{
width: 268,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${T.borderLight}`,
background: T.panel || "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 14px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
{I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 14,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{openChapter.name}
</span>
<button
onClick={() => setActiveChapter(null)}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
style={{
border: "none",
background: "transparent",
color: T.muted,
cursor: "pointer",
display: "flex",
padding: 0,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
</div>
)}
{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}
/>
)}
/>
)}
{movingNote && (
<MoveNoteDialog
note={movingNote}
notebooks={notebooks}
chapters={data.chapters}
onMove={(id, folderId, chapterId) => data.moveNote(id, folderId, chapterId)}
onClose={() => setMovingNote(null)}
dark={dark}
/>
)}
{namingChapter && (
<NameDialog
title={namingChapter.title}
value={namingChapter.value}
label="Chapter name"
confirmLabel={namingChapter.value ? "Rename" : "Create"}
onSave={namingChapter.onSave}
onClose={() => setNamingChapter(null)}
dark={dark}
/>
)}
{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>
);
}