Stage three: the viewer. Drawing on pages comes next. pdf.js stays out of the bundle everyone downloads — it is larger than the rest of the application put together, so it is behind a dynamic import and a session that never opens a PDF never fetches it. Main bundle grew by 8 kB for the viewer shell; the 162 kB library arrives only when needed. The worker is bundled rather than pulled from a CDN, because a viewer that stops working during someone else's outage is not one you can rely on offline. Two things that only showed up once it ran in a browser: - pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers have. Anything older failed with "n.toHex is not a function", which says nothing about the cause. Now on the legacy build, which compiles those away — a little larger, and the difference between working everywhere and working on this year's browsers. - nginx has no MIME type for .mjs and served the worker as octet-stream, which a browser refuses to run as a module. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest immediately and a document mixing portrait and landscape keeps both — the first version scaled every page to the widest one, which the suite caught by checking the two aspect ratios differ. Bytes come from the authenticated attachment route and are cached in IndexedDB. Content-addressed storage means a cached copy can never be stale, so a document opened once needs no connection again. Verified with the network switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
638 lines
21 KiB
React
638 lines
21 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 { 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 [showAdmin, setShowAdmin] = useState(false);
|
|
// { notes, label } — set while the export dialog is up.
|
|
const [exporting, setExporting] = 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();
|
|
|
|
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 ── */
|
|
/**
|
|
* Picks a file and imports it as a PDF note.
|
|
*
|
|
* 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 importPdf = () => {
|
|
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
|
|
if (!folderId) return;
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "application/pdf,.pdf";
|
|
input.onchange = async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
const note = await data.importPdf(folderId, file);
|
|
if (activeFolder === "dashboard") setActiveFolder(folderId);
|
|
openNote(note.id);
|
|
} catch (e) {
|
|
setConfirmDialog({
|
|
title: "Import fehlgeschlagen",
|
|
message: e?.message || "Die Datei konnte nicht importiert werden.",
|
|
confirmLabel: "OK",
|
|
onConfirm: () => setConfirmDialog(null),
|
|
});
|
|
}
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
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) },
|
|
{
|
|
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)}
|
|
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}
|
|
onImportPdf={importPdf}
|
|
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)}
|
|
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)}
|
|
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: "Alle Notizen", notes: activeNotes });
|
|
}}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{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);
|
|
}}
|
|
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>
|
|
);
|
|
}
|