TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -0,0 +1,563 @@
|
||||
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 { Sidebar } from "./ui/Sidebar.jsx";
|
||||
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
|
||||
import { NoteList } from "./features/notes/NoteList.jsx";
|
||||
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 } 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 [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();
|
||||
|
||||
const data = useNoteData(user, activeNote);
|
||||
const { notes, notebooks, noteContents, backlinks } = data;
|
||||
const search = useSearchIndex(user, notes, searchQuery);
|
||||
const dnd = useNotebookDnd(data.reorderNotebooks);
|
||||
|
||||
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 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 [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]);
|
||||
|
||||
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 ── */
|
||||
const createNote = async () => {
|
||||
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
|
||||
if (!folderId) return;
|
||||
const note = await data.createNote(folderId);
|
||||
if (activeFolder === "dashboard") 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) },
|
||||
{ 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),
|
||||
{ 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)}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
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}
|
||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSelectNote={openNote}
|
||||
onNoteContextMenu={noteContextMenu}
|
||||
onCreateNote={createNote}
|
||||
onEmptyTrash={emptyTrash}
|
||||
/>
|
||||
)}
|
||||
{showEditor && (
|
||||
<EditorPane
|
||||
T={T}
|
||||
dark={dark}
|
||||
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)}
|
||||
onCreateNote={createNote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showNewNb && (
|
||||
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
|
||||
)}
|
||||
{editingNb && (
|
||||
<EditNotebookModal
|
||||
notebook={editingNb}
|
||||
onClose={() => setEditingNb(null)}
|
||||
onSave={(u) => updateNotebook(editingNb.id, u)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{showAccount && (
|
||||
<AccountMenu
|
||||
user={user}
|
||||
onClose={() => setShowAccount(false)}
|
||||
onLogout={() => {
|
||||
setShowAccount(false);
|
||||
onLogout();
|
||||
}}
|
||||
onUserUpdate={setUser}
|
||||
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);
|
||||
}}
|
||||
renderDrawing={(strokes) => (
|
||||
<DrawingLayer
|
||||
strokes={strokes.strokes || []}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user