diff --git a/apps/client/package.json b/apps/client/package.json index 4c4c7e5..b5559f6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "emoji-mart": "^5.6.0", + "file-saver": "^2.0.5", "jotai": "^2.8.3", "jotai-optics": "^0.4.0", "js-cookie": "^3.0.5", diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 2368425..aa87703 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -3,7 +3,7 @@ import React from "react"; import { TitleEditor } from "@/features/editor/title-editor"; import PageEditor from "@/features/editor/page-editor"; import { Container } from "@mantine/core"; -import { useAtom } from "jotai/index"; +import { useAtom } from "jotai"; import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; const MemoizedTitleEditor = React.memo(TitleEditor); diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 75b6f32..94af10a 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -163,3 +163,4 @@ .actionIconGroup { background: var(--mantine-color-body); } + diff --git a/apps/client/src/features/editor/styles/editor.module.css b/apps/client/src/features/editor/styles/editor.module.css index c281ba3..e5e471c 100644 --- a/apps/client/src/features/editor/styles/editor.module.css +++ b/apps/client/src/features/editor/styles/editor.module.css @@ -2,5 +2,10 @@ height: 100%; padding: 8px 20px; margin: 64px auto; + + @media print { + padding: 0; + margin: 0; + } } diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index d9b0fdb..894d3ee 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -8,5 +8,7 @@ @import "./youtube.css"; @import "./media.css"; @import "./code.css"; +@import "./print.css"; + diff --git a/apps/client/src/features/editor/styles/placeholder.css b/apps/client/src/features/editor/styles/placeholder.css index 67aec13..0ba3151 100644 --- a/apps/client/src/features/editor/styles/placeholder.css +++ b/apps/client/src/features/editor/styles/placeholder.css @@ -4,6 +4,10 @@ color: #adb5bd; pointer-events: none; height: 0; + + @media print { + display: none; + } } .ProseMirror .is-empty::before { @@ -12,9 +16,17 @@ color: #adb5bd; pointer-events: none; height: 0; + + @media print { + display: none; + } } .ProseMirror table .is-editor-empty:first-child::before, .ProseMirror table .is-empty::before { content: ''; + + @media print { + display: none; + } } diff --git a/apps/client/src/features/editor/styles/print.css b/apps/client/src/features/editor/styles/print.css new file mode 100644 index 0000000..ab105b4 --- /dev/null +++ b/apps/client/src/features/editor/styles/print.css @@ -0,0 +1,11 @@ +@media print { + .mantine-AppShell-header, + .mantine-AppShell-navbar, + .mantine-AppShell-aside{ + display: none !important; + } + + .mantine-AppShell-main { + padding-top: 0 !important; + } +} diff --git a/apps/client/src/features/editor/styles/youtube.css b/apps/client/src/features/editor/styles/youtube.css index 4ec2319..8f73600 100644 --- a/apps/client/src/features/editor/styles/youtube.css +++ b/apps/client/src/features/editor/styles/youtube.css @@ -17,5 +17,9 @@ &.ProseMirror-selectednode { background-color: transparent; } + + @media print { + display: none; + } } } diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 718406d..fccb1c1 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core"; import { IconArrowsHorizontal, IconDots, + IconDownload, IconHistory, IconLink, IconMessage, + IconPrinter, IconTrash, } from "@tabler/icons-react"; import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -import { useClipboard } from "@mantine/hooks"; +import { useClipboard, useDisclosure } from "@mantine/hooks"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; @@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; +import PageExportModal from "@/features/page/components/page-export-modal.tsx"; interface PageHeaderMenuProps { readOnly?: boolean; @@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }); const { openDeleteModal } = useDeletePageModal(); const [tree] = useAtom(treeApiAtom); + const [opened, { open: openExportModal, close: closeExportModal }] = + useDisclosure(false); const handleCopyLink = () => { const pageUrl = @@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { notifications.show({ message: "Link copied" }); }; + const handlePrint = () => { + setTimeout(() => { + window.print(); + }, 250); + }; + const openHistoryModal = () => { setHistoryModalOpen(true); }; @@ -75,55 +86,79 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }; return ( - - - - - - + <> + + + + + + - - } - onClick={handleCopyLink} - > - Copy link - - + + } + onClick={handleCopyLink} + > + Copy link + + - }> - - - - + }> + + + + - } - onClick={openHistoryModal} - > - Page history - + } + onClick={openHistoryModal} + > + Page history + - {!readOnly && ( - <> - - } - onClick={handleDeletePage} - > - Delete - - - )} - - + + + } + onClick={openExportModal} + > + Export + + + } + onClick={handlePrint} + > + Print PDF + + + {!readOnly && ( + <> + + } + onClick={handleDeletePage} + > + Delete + + + )} + + + + + ); } diff --git a/apps/client/src/features/page/components/header/page-header.module.css b/apps/client/src/features/page/components/header/page-header.module.css index 4b3b46d..f3e5f7a 100644 --- a/apps/client/src/features/page/components/header/page-header.module.css +++ b/apps/client/src/features/page/components/header/page-header.module.css @@ -8,4 +8,8 @@ top: var(--app-shell-header-offset, 0rem); inset-inline-start: var(--app-shell-navbar-offset, 0rem); inset-inline-end: var(--app-shell-aside-offset, 0rem); + + @media print { + display: none; + } } diff --git a/apps/client/src/features/page/components/page-export-modal.tsx b/apps/client/src/features/page/components/page-export-modal.tsx new file mode 100644 index 0000000..21fe19e --- /dev/null +++ b/apps/client/src/features/page/components/page-export-modal.tsx @@ -0,0 +1,84 @@ +import { Modal, Button, Group, Text, Select } from "@mantine/core"; +import { exportPage } from "@/features/page/services/page-service.ts"; +import { useState } from "react"; +import * as React from "react"; +import { ExportFormat } from "@/features/page/types/page.types.ts"; +import { notifications } from "@mantine/notifications"; + +interface PageExportModalProps { + pageId: string; + open: boolean; + onClose: () => void; +} + +export default function PageExportModal({ + pageId, + open, + onClose, +}: PageExportModalProps) { + const [format, setFormat] = useState(ExportFormat.Markdown); + + const handleExport = async () => { + try { + await exportPage({ pageId: pageId, format }); + onClose(); + } catch (err) { + notifications.show({ + message: "Export failed:" + err.response?.data.message, + color: "red", + }); + console.error("export error", err); + } + }; + + const handleChange = (format: ExportFormat) => { + setFormat(format); + }; + + return ( + <> + + +
+ Export format +
+ +
+ + + + + +
+ + ); +} + +interface ExportFormatSelection { + onChange: (value: string) => void; +} +function ExportFormatSelection({ onChange }: ExportFormatSelection) { + return ( +