Files
notesnook/apps/monograph/app/components/monographpost/index.tsx

719 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2024-10-01 16:19:34 +05:00
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "@notesnook/editor/styles/styles.css";
import { Monograph } from "./types";
import {
Box,
Button,
Flex,
Image,
Input,
Link,
Text
} from "@theme-ui/components";
import { ClientOnly } from "remix-utils/client-only";
import { Editor } from "./editor.client";
import { formatDate } from "@notesnook/core";
import { slugify } from "../../utils/slugify";
import { useCallback, useEffect, useRef, useState } from "react";
import { NNCrypto } from "../../utils/nncrypto.client";
import { hashNavigate } from "../../utils/use-hash-location";
import { Icon } from "@notesnook/ui";
import {
mdiAlertCircleOutline,
mdiArrowUp,
mdiClose,
mdiListBoxOutline,
mdiLockOutline
} from "@mdi/js";
2024-10-01 16:19:34 +05:00
import { Footer } from "../footer";
import ReportDialog from "./report-modal";
type TableOfContent = {
title: string;
id: string;
level: number;
node: HTMLElement;
};
const headings = ["h1", "h2", "h3", "h4", "h5", "h6"];
const levelsMap: Record<string, number> = {
H1: 0,
H2: 1,
H3: 2,
H4: 3,
H5: 4,
H6: 5
};
const selector = headings.map((h) => `.ProseMirror ${h}`).join(",");
function generateTableOfContents() {
const toc: TableOfContent[] = [];
const headings = document.querySelectorAll<HTMLHeadingElement>(selector);
let level = -1;
let currentHeading = 0;
for (const heading of headings) {
const isCalloutHeading = heading.closest(".callout");
if (isCalloutHeading) {
continue;
}
2024-10-01 16:19:34 +05:00
const text = heading.textContent || "<empty>";
const nodeName = heading.nodeName;
const headingLevel = levelsMap[nodeName];
let id = "heading-" + slugify(text);
const count = toc.filter((n) => n.id === id).length;
if (count > 0) id = `${id}-${count}`;
heading.setAttribute("id", id);
level =
currentHeading < headingLevel
? level + 1
: currentHeading > headingLevel
? headingLevel
2024-10-01 16:19:34 +05:00
: level;
currentHeading = headingLevel;
toc.push({
title: text,
id,
level,
node: heading
});
}
return toc;
}
export const MonographPage = ({
monograph,
encodedKey,
apiHost
2024-10-01 16:19:34 +05:00
}: {
monograph: Monograph;
encodedKey?: string;
apiHost: string;
2024-10-01 16:19:34 +05:00
}) => {
const [reportDialogVisible, setReportDialogVisible] = useState(false);
const [tableOfContents, setTableOfContents] = useState<TableOfContent[]>([]);
const [content, setContent] = useState(monograph.content);
const [showTableOfContents, setShowTableOfContents] = useState(false);
2024-10-01 16:19:34 +05:00
const editorContainer = useRef<HTMLDivElement>(null);
if (!content && monograph.encryptedContent)
return (
<ClientOnly fallback={<div />}>
{() => (
<MonographLockscreen
monograph={monograph}
onUnlock={(content) => setContent(content)}
encodedKey={encodedKey}
/>
)}
</ClientOnly>
);
return (
<Flex
sx={{ flexDirection: "column", fontFamily: "body", bg: "background" }}
>
<Box
sx={{
bg: "background-secondary",
py: 4,
top: 0,
zIndex: 999,
position: "sticky"
2024-10-01 16:19:34 +05:00
}}
>
<Button
sx={{ position: "absolute", top: 20, right: 20, p: 0 }}
onClick={() => setShowTableOfContents((s) => !s)}
>
<Icon
path={mdiListBoxOutline}
size={24}
color={showTableOfContents ? "var(--accent)" : "var(--icon)"}
/>
</Button>
2024-10-01 16:19:34 +05:00
<Box
sx={{
px: "10%",
textAlign: "center"
2024-10-01 16:19:34 +05:00
}}
>
<ResponsiveTitle title={monograph.title} />
2024-10-01 16:19:34 +05:00
</Box>
</Box>
<Box
sx={{
bg: "background-secondary",
borderBottom: "1px solid var(--border)",
px: [10, 10, "15%"],
pb: 20,
pt: 100
}}
>
2024-10-01 16:19:34 +05:00
<Text as="h1" variant="heading" sx={{ fontSize: 32, fontWeight: 800 }}>
{monograph.title}
</Text>
<Flex sx={{ gap: 2 }}>
<Text
variant="subtitle"
sx={{
color: "paragraph-secondary"
}}
>
{formatDate(monograph.datePublished, {
type: "date-time",
dateFormat: "YYYY-MM-DD",
timeFormat: "24-hour"
})}
</Text>
{monograph.encryptedContent ? (
<Flex sx={{ gap: "2px" }}>
<Icon path={mdiLockOutline} size={14} color="var(--accent)" />
<Text
variant="subtitle"
sx={{
color: "accent"
}}
>
End-to-end encrypted
</Text>
</Flex>
) : (
2024-10-01 16:19:34 +05:00
<Button
title="Report"
onClick={() => {
setReportDialogVisible(true);
}}
variant="anchor"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
borderRadius: "default",
textDecoration: "none"
}}
>
<Icon
path={mdiAlertCircleOutline}
color="var(--paragraph-secondary)"
2024-10-01 16:19:34 +05:00
size={16}
/>
<Text variant="error" sx={{ color: "paragraph-secondary" }}>
Report
</Text>
2024-10-01 16:19:34 +05:00
</Button>
)}
<Image
sx={{ display: "none" }}
src={`${apiHost}/monographs/${monograph.id}/view`}
/>
2024-10-01 16:19:34 +05:00
</Flex>
</Box>
<Flex
sx={{ mt: 10, justifyContent: "space-between", minHeight: "100vh" }}
>
<Box
ref={editorContainer}
sx={{
ml: [10, 10, "15%"],
2024-10-01 16:19:34 +05:00
mr: [10, 20],
pr: [0, 0, "max(50px, calc(20% - 300px))"],
pb: 50,
2024-10-01 16:19:34 +05:00
flex: 1,
borderRight: [
"none",
"none",
showTableOfContents ? "1px solid var(--border)" : "none"
]
2024-10-01 16:19:34 +05:00
}}
>
<ScrollToTopButton />
2024-10-01 16:19:34 +05:00
<ClientOnly
fallback={
<Box
className="tiptap ProseMirror theme-scope-editor"
dangerouslySetInnerHTML={{
__html: content?.data || "<p></p>"
}}
sx={{ color: "paragraph", cursor: "text" }}
2024-10-01 16:19:34 +05:00
/>
}
>
{() => (
<Editor
content={content?.data || "<p></p>"}
onLoad={() => {
const toc = generateTableOfContents();
setTableOfContents(toc);
}}
/>
)}
</ClientOnly>
</Box>
<Flex
sx={{
display: ["none", "none", "flex"],
2024-10-01 16:19:34 +05:00
flexDirection: "column",
flexShrink: 0,
mt: 16,
pr: 20,
pb: 50,
2024-10-01 16:19:34 +05:00
width: 300,
alignSelf: "start",
gap: 2,
position: "sticky",
top: 80
2024-10-01 16:19:34 +05:00
}}
>
{showTableOfContents ? (
2024-10-01 16:19:34 +05:00
<>
<Text
variant="subtitle"
sx={{
color: "heading-secondary",
mb: 2
}}
>
CONTENTS
2024-10-01 16:19:34 +05:00
</Text>
<TableOfContents
container={editorContainer}
contents={tableOfContents}
/>
2024-10-01 16:19:34 +05:00
</>
) : null}
</Flex>
{showTableOfContents ? (
<Flex
sx={{
display: ["flex", "flex", "none"],
flexDirection: "column",
position: "fixed",
zIndex: 999,
bottom: 0,
right: 0,
borderTopLeftRadius: 15,
borderTopRightRadius: 15,
boxShadow: "0px 0px 15px 5px rgba(0,0,0,0.1)",
bg: "background-secondary",
px: 4,
gap: 2,
height: "60vh",
width: "100%",
overflowY: "auto",
border: "1px solid var(--theme-ui-colors-border)"
}}
>
<Flex
2024-10-01 16:19:34 +05:00
sx={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
mx: 0,
// px: "16px",
mt: 4
2024-10-01 16:19:34 +05:00
}}
onClick={() => {
setShowTableOfContents(false);
2024-10-01 16:19:34 +05:00
}}
>
<Text
variant="subtitle"
sx={{
color: "heading-secondary",
mb: 2
2024-10-01 16:19:34 +05:00
}}
>
CONTENTS
2024-10-01 16:19:34 +05:00
</Text>
<Button>
<Icon path={mdiClose} size={24} color="var(--icon)" />
</Button>
</Flex>
<TableOfContents
container={editorContainer}
contents={tableOfContents}
onNavigate={() => setShowTableOfContents(false)}
/>
</Flex>
) : null}
2024-10-01 16:19:34 +05:00
</Flex>
<Footer subtitle="Published via Notesnook" />
{reportDialogVisible ? (
<ReportDialog
monograph={monograph}
setVisible={(visible) => {
setReportDialogVisible(visible);
}}
/>
) : null}
</Flex>
);
};
function MonographLockscreen({
monograph,
encodedKey,
onUnlock
}: {
monograph: Monograph;
encodedKey?: string;
onUnlock: (content: Monograph["content"]) => void;
}) {
const [error, setError] = useState<string>();
const [loading, setLoading] = useState<boolean>();
const password = useRef<string>();
const unlock = useCallback(async () => {
if (!password.current) return;
setError(undefined);
setLoading(true);
try {
const result = await unlockMonograph(monograph, password.current).catch(
(e) => {
console.error(e);
setError(
`An error occurred while unlocking the monograph: ${e.message}`
);
}
);
if (result === false) {
setError("Incorrect password.");
return;
}
if (!result) return;
onUnlock(result);
hashNavigate(`key=${encode(password.current)}`, {
replace: true,
notify: false
});
} finally {
setLoading(false);
}
}, [monograph, onUnlock]);
useEffect(() => {
if (!encodedKey) return;
password.current = decode(encodedKey);
unlock();
}, [encodedKey, unlock]);
if (encodedKey && !error) return null;
return (
<Flex
sx={{
flexDirection: "column",
gap: 1,
alignItems: "center",
justifyContent: "center",
height: "100vh",
bg: "background"
}}
>
<Text
sx={{
fontFamily: "monospace",
textAlign: "center",
fontSize: 18
}}
>
<span style={{ color: "var(--accent)" }}>Mono</span>graph
</Text>
<Text
as="h1"
variant="heading"
sx={{ fontSize: 32, fontWeight: 800, mt: 20 }}
>
{monograph.title}
</Text>
{loading ? (
<>
<Text variant="subBody" sx={{ mt: 2 }}>
Unlocking...
</Text>
</>
) : (
<>
<Input
sx={{
backgroundColor: "background-secondary",
height: "35px",
fontSize: "body",
width: ["90%", 300],
mt: 2
}}
autoFocus={true}
autofillBackgroundColor="background-secondary"
onChange={(event) => (password.current = event.target.value)}
onKeyUp={(e) => (e.key === "Enter" ? unlock() : null)}
type="password"
placeholder="Enter password to continue"
/>
<Button
variant="accent"
title="Unlock"
sx={{ borderRadius: 100, px: 50, mt: 1 }}
onClick={() => unlock()}
>
Unlock
</Button>
{error ? (
<Text variant="error" sx={{ mt: 1 }}>
{error}
</Text>
) : null}
</>
)}
</Flex>
);
}
function ScrollToTopButton() {
const [showScrollToTop, setShowScrollToTop] = useState(false);
useEffect(() => {
function onScroll() {
const scrollTop = window.scrollY;
setShowScrollToTop(scrollTop > 120);
}
onScroll();
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
if (!showScrollToTop) return null;
2024-10-01 16:19:34 +05:00
return (
<Box
onClick={() => {
window.scrollTo({
top: 0,
behavior: "smooth"
});
}}
sx={{
position: "fixed",
bottom: 20,
right: 10,
zIndex: 1,
bg: "background-secondary",
borderRadius: 100,
width: [50, 60, 60],
height: [50, 60, 60],
display: "flex",
justifyContent: "center",
alignItems: "center",
boxShadow: "0px 0px 15px 5px rgba(0,0,0,0.1)"
}}
>
<Icon path={mdiArrowUp} size={24} color="var(--accent)" />
</Box>
);
}
function ResponsiveTitle({ title }: { title: string }) {
const [showTitle, setShowTitle] = useState(false);
useEffect(() => {
function onScroll() {
const scrollTop = window.scrollY;
setShowTitle(scrollTop > 120);
}
onScroll();
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
return (
<>
<Link
as="a"
variant="text.body"
href="/"
sx={{
fontSize: 18,
fontFamily: "monospace",
textDecoration: "none",
display: "block",
whiteSpace: "nowrap",
overflow: "hidden"
}}
>
{showTitle ? (
title
) : (
<>
<span style={{ color: "var(--accent)" }}>Mono</span>graph
</>
)}
</Link>
</>
);
}
function TableOfContents({
contents,
container,
onNavigate,
showActive
}: {
contents: TableOfContent[];
container: React.RefObject<HTMLDivElement>;
onNavigate?: (id: string) => void;
showActive?: boolean;
}) {
const [activeHeadings, setActiveHeadings] = useState<string[]>();
useEffect(() => {
if (!showActive) return;
function onScroll() {
const scrollTop = window.scrollY;
const height = window.innerHeight || 0;
const viewportHeight = scrollTop + height - 30;
const documentOffset = container.current?.offsetTop || 0;
const active = contents.filter((t, i, array) => {
const next = array.at(i + 1);
const headingOffset =
documentOffset + t.node.offsetTop + t.node.clientHeight;
const lessThanNext = next
? scrollTop <=
next.node.offsetTop + documentOffset + next.node.clientHeight
: true;
const isInViewport =
headingOffset > scrollTop && headingOffset < viewportHeight;
const isActive = scrollTop >= headingOffset && lessThanNext;
return isInViewport || isActive;
});
setActiveHeadings(active.map((a) => a.id));
}
onScroll();
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [contents, showActive]);
return (
<>
{contents.length === 0 ? (
<>
<Text variant="subBody">
This monograph has no table of contents.
</Text>
</>
) : null}
{contents.map((item) => (
<Button
variant="anchor"
key={item.id}
sx={{
textAlign: "left",
p: 0,
m: 0,
pl: item.level * 10,
textDecoration: "none",
flexShrink: 0
}}
onClick={() => {
const documentOffset = container.current?.offsetTop || 0;
const headingOffset =
documentOffset + item.node.offsetTop + item.node.clientHeight;
window.scrollTo({
top: headingOffset - 100,
behavior: "smooth"
});
onNavigate?.(item.id);
}}
>
<Text
variant="subtitle"
sx={{
color:
showActive && activeHeadings?.includes(item.id)
? "accent"
: "paragraph-secondary",
fontWeight: 600
}}
>
{item.title}
</Text>
</Button>
))}
</>
);
}
2024-10-01 16:19:34 +05:00
function encode(input: string) {
return toBase64Url(Buffer.from(input).toString("base64"));
}
function decode(input: string) {
return Buffer.from(fromBase64Url(input), "base64").toString("utf-8");
}
function toBase64Url(base64: string) {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+/g, "");
}
function fromBase64Url(base64url: string) {
return (
base64url.replace(/-/g, "+").replace(/_/g, "/") +
"===".slice(0, (4 - (base64url.length % 4)) % 4)
);
}
async function unlockMonograph(
monograph: Monograph,
password: string
): Promise<Monograph["content"] | false> {
if (password === "") return false;
const { encryptedContent } = monograph;
if (!encryptedContent) return false;
try {
const decrypted = await NNCrypto.decrypt(
{ password, salt: encryptedContent.salt },
{
...encryptedContent,
format: "base64"
},
"text"
);
return JSON.parse(decrypted) as Monograph["content"];
} catch (e) {
const error = e as Error;
if (
error.message === "ciphertext cannot be decrypted using that key" ||
error.message === "Invalid input."
) {
return false;
}
throw e;
}
}