feat: page history

This commit is contained in:
Philipinho
2023-11-22 20:42:34 +00:00
parent 21347e6c42
commit 3f9b6e1380
50 changed files with 995 additions and 200 deletions

View File

@ -9,7 +9,7 @@
"preview": "vite preview --port 3000"
},
"dependencies": {
"@hocuspocus/provider": "^2.7.1",
"@hocuspocus/provider": "^2.8.1",
"@mantine/core": "^7.2.2",
"@mantine/form": "^7.2.2",
"@mantine/hooks": "^7.2.2",

View File

@ -1,11 +1,41 @@
import React, { Suspense } from 'react';
const Comments = React.lazy(() => import('@/features/comment/comments'));
import { Box, ScrollArea, Text, useMantineTheme } from '@mantine/core';
import CommentList from '@/features/comment/components/comment-list';
import { useAtom } from 'jotai';
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
import React from 'react';
export default function Aside() {
const theme = useMantineTheme();
const [{ tab }] = useAtom(asideStateAtom);
let title;
let component;
switch (tab) {
case 'comments':
component = <CommentList />;
title = 'Comments';
break;
default:
component = null;
title = null;
}
return (
<Suspense fallback={<div>Loading comments...</div>}>
<Comments />
</Suspense>
<Box p="md" bg={theme.colors?.gray[1]}>
{component && (
<>
<Text mb="md" fw={500}>{title}</Text>
<ScrollArea style={{ height: '85vh' }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: '200px' }}>
{component}
</div>
</ScrollArea>
</>
)}
</Box>
);
}

View File

@ -2,7 +2,6 @@ import {
ActionIcon,
Menu,
Button,
rem,
} from '@mantine/core';
import {
IconDots,
@ -15,15 +14,20 @@ import {
IconMessage,
} from '@tabler/icons-react';
import React from 'react';
import useToggleAside from '@/hooks/use-toggle-aside';
import { useAtom } from 'jotai';
import { historyAtoms } from '@/features/page-history/atoms/history-atoms';
export default function Header() {
const toggleAside = useToggleAside();
return (
<>
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
Share
</Button>
<ActionIcon variant="default" style={{ border: 'none' }}>
<ActionIcon variant="default" style={{ border: 'none' }} onClick={() => toggleAside('comments')}>
<IconMessage size={20} stroke={2} />
</ActionIcon>
@ -33,6 +37,12 @@ export default function Header() {
}
function PageActionMenu() {
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const openHistoryModal = () => {
setHistoryModalOpen(true);
};
return (
<Menu
shadow="xl"
@ -50,43 +60,31 @@ function PageActionMenu() {
<Menu.Dropdown>
<Menu.Item
leftSection={
<IconFileInfo style={{ width: rem(14), height: rem(14) }} />
}
>
leftSection={<IconFileInfo size={16} stroke={2} />}>
Page info
</Menu.Item>
<Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
leftSection={<IconLink size={16} stroke={2} />}
>
Copy link
</Menu.Item>
<Menu.Item
leftSection={
<IconShare style={{ width: rem(14), height: rem(14) }} />
}
>
leftSection={<IconShare size={16} stroke={2} />}>
Share
</Menu.Item>
<Menu.Item
leftSection={
<IconHistory style={{ width: rem(14), height: rem(14) }} />
}
>
leftSection={<IconHistory size={16} stroke={2} />}
onClick={openHistoryModal}>
Page history
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconLock style={{ width: rem(14), height: rem(14) }} />}
>
leftSection={<IconLock size={16} stroke={2} />}>
Lock
</Menu.Item>
<Menu.Item
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
>
leftSection={<IconTrash size={16} stroke={2} />}>
Delete
</Menu.Item>
</Menu.Dropdown>

View File

@ -1,4 +1,4 @@
import { desktopAsideAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
import { asideStateAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
import { Navbar } from '@/components/navbar/navbar';
import { AppShell, Burger, Group } from '@mantine/core';
@ -8,12 +8,16 @@ import classes from './shell.module.css';
import Header from '@/components/layouts/header';
import Breadcrumb from '@/components/layouts/components/breadcrumb';
import Aside from '@/components/aside/aside';
import { useMatchPath } from '@/hooks/use-match-path';
import React from 'react';
export default function Shell({ children }: { children: React.ReactNode }) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [desktopAsideOpened] = useAtom(desktopAsideAtom);
const matchPath = useMatchPath();
const isPageRoute = matchPath('/p/:pageId');
const [{ isAsideOpen }] = useAtom(asideStateAtom);
return (
<AppShell
@ -24,7 +28,11 @@ export default function Shell({ children }: { children: React.ReactNode }) {
breakpoint: 'sm',
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopAsideOpened } }}
aside={{
width: 300,
breakpoint: 'md',
collapsed: { mobile: (!isAsideOpen), desktop: (!isAsideOpen) },
}}
padding="md"
>
<AppShell.Header
@ -47,13 +55,15 @@ export default function Shell({ children }: { children: React.ReactNode }) {
size="sm"
/>
<Breadcrumb />
</Group>
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Header />
{isPageRoute && <Breadcrumb />}
</Group>
{
isPageRoute &&
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Header />
</Group>
}
</Group>
</AppShell.Header>
@ -66,9 +76,13 @@ export default function Shell({ children }: { children: React.ReactNode }) {
{children}
</AppShell.Main>
<AppShell.Aside className={classes.aside}>
<Aside />
</AppShell.Aside>
{
isPageRoute &&
<AppShell.Aside className={classes.aside}>
<Aside />
</AppShell.Aside>
}
</AppShell>
);
}

View File

@ -1,6 +1,16 @@
import { atomWithWebStorage } from "@/lib/jotai-helper";
import { atomWithWebStorage } from '@/lib/jotai-helper';
import { atom } from 'jotai';
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
export const desktopSidebarAtom = atomWithWebStorage('showSidebar', true);
export const desktopAsideAtom = atom(false);
type AsideStateType = {
tab: string,
isAsideOpen: boolean,
}
export const asideStateAtom = atom<AsideStateType>({
tab: '',
isAsideOpen: false,
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Avatar } from '@mantine/core';
interface UserAvatarProps extends React.ComponentProps<typeof Avatar> {
avatarUrl: string;
name: string;
color?: string;
size?: string;
}
export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
({ avatarUrl, name, ...props }: UserAvatarProps, ref) => {
const getInitials = (name: string) => {
const names = name.split(' ');
return names.slice(0, 2).map(n => n[0]).join('');
};
return (
avatarUrl ? (
<Avatar
ref={ref}
src={avatarUrl}
alt={name}
radius="xl"
{...props}
/>
) : (
<Avatar ref={ref}
{...props}>{getInitials(name)}</Avatar>
)
);
},
);

View File

@ -2,3 +2,8 @@ import { atom } from 'jotai';
import { Editor } from '@tiptap/core';
export const editorAtom = atom<Editor | null>(null);
export const titleEditorAtom = atom<Editor | null>(null);
export type EditorAtomType = typeof editorAtom;
export type TitleEditorAtomType = typeof titleEditorAtom;

View File

@ -20,8 +20,8 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const items: BubbleMenuItem[] = [
{

View File

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core';
import { Dispatch, FC, SetStateAction } from 'react';
import React, { Dispatch, FC, SetStateAction } from 'react';
import {
IconBlockquote,
IconCheck, IconCheckbox, IconChevronDown, IconCode,
@ -21,7 +21,7 @@ interface NodeSelectorProps {
export interface BubbleMenuItem {
name: string;
icon: FC;
icon: React.ElementType;
command: () => void;
isActive: () => boolean;
}
@ -126,7 +126,7 @@ export const NodeSelector: FC<NodeSelectorProps> =
variant="default"
leftSection={<item.icon size={16} />}
rightSection={activeItem.name === item.name
&& (<IconCheck style={{ width: rem(16) }} />)}
&& (<IconCheck size={16} />)}
justify="left"
fullWidth
onClick={() => {

View File

@ -6,6 +6,7 @@ import {
useState,
} from 'react';
import {
SlashMenuGroupedItemsType,
SlashMenuItemType,
} from '@/features/editor/components/slash-menu/types';
import {
@ -24,7 +25,7 @@ const CommandList = ({
editor,
range,
}: {
items: SlashMenuItemType[];
items: SlashMenuGroupedItemsType;
command: any;
editor: any;
range: any;

View File

@ -22,4 +22,6 @@ export type SlashMenuItemType = {
disable?: (editor: Editor) => boolean;
}
export type SlashMenuGroupedItemsType = Record<string, SlashMenuItemType[]>;
export type SlashMenuGroupedItemsType = {
[category: string]: SlashMenuItemType[];
};

View File

@ -1,29 +1,20 @@
import '@/features/editor/styles/index.css';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { EditorContent, useEditor } from '@tiptap/react';
import { Placeholder } from '@tiptap/extension-placeholder';
import React, { useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
import { IndexeddbPersistence } from 'y-indexeddb';
import classes from '@/features/editor/styles/editor.module.css';
import '@/features/editor/styles/index.css';
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
import { Document } from '@tiptap/extension-document';
import { Text } from '@tiptap/extension-text';
import { Heading } from '@tiptap/extension-heading';
import { useDebouncedValue } from '@mantine/hooks';
import { pageAtom } from '@/features/page/atoms/page-atom';
import { IPage } from '@/features/page/types/page.types';
import { Comment } from '@/features/editor/extensions/comment/comment';
import { desktopAsideAtom } from '@/components/navbar/atoms/sidebar-atom';
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
import { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
import CommentDialog from '@/features/comment/components/comment-dialog';
import { editorAtom } from '@/features/editor/atoms/editorAtom';
import { editorAtom, titleEditorAtom } from '@/features/editor/atoms/editorAtom';
import { collabExtensions, mainExtensions } from '@/features/editor/extensions';
import { useUpdatePageMutation } from '@/features/page/queries/page';
interface EditorProps {
pageId: string,
@ -78,6 +69,9 @@ export default function Editor({ pageId }: EditorProps) {
}
const isSynced = isLocalSynced || isRemoteSynced;
if (isSynced){
window.scrollTo(0, 0);
}
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} pageId={pageId} />);
}
@ -90,61 +84,19 @@ interface TiptapEditorProps {
function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(editorAtom);
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
const [debouncedTitleState, setDebouncedTitleState] = useState('');
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
const [desktopAsideOpened, setDesktopAsideOpened] = useAtom<boolean>(desktopAsideAtom);
const [activeCommentId, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
const titleEditor = useEditor({
extensions: [
Document.extend({
content: 'heading',
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: 'Untitled',
}),
],
onUpdate({ editor }) {
const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle);
},
content: page.title,
});
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus('start');
window.scrollTo(0, 0);
}, 100);
}, []);
useEffect(() => {
if (debouncedTitle !== '') {
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
}
}, [debouncedTitle]);
const [titleEditor] = useAtom(titleEditorAtom);
const [asideState, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const extensions = [
...mainExtensions,
...collabExtensions(ydoc, provider),
Comment.configure({
HTMLAttributes: {
class: 'comment-mark',
},
}),
];
const editor = useEditor({
extensions: extensions,
autofocus: false,
autofocus: 0,
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
@ -159,6 +111,7 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
}
},
@ -177,30 +130,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
},
});
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus('end');
}, 200);
}, [editor]);
useEffect(() => {
if (editor && currentUser.user) {
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
}
}, [editor, currentUser.user]);
function handleTitleKeyDown(event) {
if (!titleEditor || !editor || event.shiftKey) return;
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
(key === 'ArrowRight' && !$head.nodeAfter);
if (shouldFocusEditor) {
editor.commands.focus('start');
}
}
const handleActiveCommentEvent = (event) => {
const { commentId } = event.detail;
setActiveCommentId(commentId);
setDesktopAsideOpened(true);
setAsideState({ tab: 'comments', isAsideOpen: true });
const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector);
@ -216,21 +161,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
useEffect(() => {
setActiveCommentId(null);
setDesktopAsideOpened(false);
setShowCommentPopup(false);
setAsideState({ tab: '', isAsideOpen: false });
}, [pageId]);
return (
<>
<div className={classes.editor}>
{editor && <EditorBubbleMenu editor={editor} />}
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
<EditorContent editor={editor} />
</div>
<div>
{editor &&
(<div>
<EditorBubbleMenu editor={editor} />
<EditorContent editor={editor} />
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
</>
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
</div>)}
</div>
);
}

View File

@ -16,6 +16,7 @@ import { Color } from '@tiptap/extension-color';
import SlashCommand from '@/features/editor/extensions/slash-command';
import { Collaboration } from '@tiptap/extension-collaboration';
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
import { Comment } from '@/features/editor/extensions/comment/comment';
import * as Y from 'yjs';
export const mainExtensions = [
@ -47,6 +48,11 @@ export const mainExtensions = [
TextStyle,
Color,
SlashCommand,
Comment.configure({
HTMLAttributes: {
class: 'comment-mark',
},
}),
];
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];

View File

@ -127,6 +127,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return elem;
},
// @ts-ignore
addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()];

View File

@ -0,0 +1,20 @@
import classes from '@/features/editor/styles/editor.module.css';
import Editor from '@/features/editor/editor';
import React from 'react';
import { TitleEditor } from '@/features/editor/title-editor';
export interface FullEditorProps {
pageId: string;
title: any;
}
export function FullEditor({ pageId, title }: FullEditorProps) {
return (
<div className={classes.editor}>
<TitleEditor pageId={pageId} title={title} />
<Editor pageId={pageId} />
</div>
);
}

View File

@ -0,0 +1,75 @@
import '@/features/editor/styles/index.css';
import React, { useEffect, useState } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { Document } from '@tiptap/extension-document';
import { Heading } from '@tiptap/extension-heading';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extension-placeholder';
import { useAtomValue } from 'jotai';
import { editorAtom, titleEditorAtom } from '@/features/editor/atoms/editorAtom';
import { useUpdatePageMutation } from '@/features/page/queries/page-query';
import { useDebouncedValue } from '@mantine/hooks';
import { useAtom } from 'jotai';
export interface TitleEditorProps {
pageId: string;
title: any;
}
export function TitleEditor({ pageId, title }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState('');
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
const contentEditor = useAtomValue(editorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const titleEditor = useEditor({
extensions: [
Document.extend({
content: 'heading',
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: 'Untitled',
}),
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
}
},
onUpdate({ editor }) {
const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle);
},
content: title,
});
useEffect(() => {
if (debouncedTitle !== '') {
updatePageMutation.mutate({ id: pageId, title: debouncedTitle });
}
}, [debouncedTitle]);
function handleTitleKeyDown(event) {
if (!titleEditor || !contentEditor || event.shiftKey) return;
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor = (key === 'Enter' || key === 'ArrowDown') ||
(key === 'ArrowRight' && !$head.nodeAfter);
if (shouldFocusEditor) {
contentEditor.commands.focus('start');
}
}
return (
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
);
}

View File

@ -3,7 +3,7 @@ import { format } from 'date-fns';
import classes from './home.module.css';
import { Link } from 'react-router-dom';
import PageListSkeleton from '@/features/home/components/page-list-skeleton';
import { useRecentChangesQuery } from '@/features/page/queries/page';
import { useRecentChangesQuery } from '@/features/page/queries/page-query';
function RecentChanges() {
const { data, isLoading, isError } = useRecentChangesQuery();
@ -22,7 +22,7 @@ function RecentChanges() {
<div key={page.id}>
<UnstyledButton component={Link} to={`/p/${page.id}`}
className={classes.page} p="xs">
<Group wrap="noWrap">
<Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}>
<Text fw={500} size="sm">

View File

@ -0,0 +1,4 @@
import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom(null);

View File

@ -0,0 +1,33 @@
import '@/features/editor/styles/index.css';
import React, { useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { mainExtensions } from '@/features/editor/extensions';
import { Title } from '@mantine/core';
export interface HistoryEditorProps {
title: string;
content: any;
}
export function HistoryEditor({ title, content }: HistoryEditorProps) {
const editor = useEditor({
extensions: mainExtensions,
editable: false,
});
useEffect(() => {
if (editor && content) {
editor.commands.setContent(content);
}
}, [title, content, editor]);
return (
<>
<div>
<Title order={1}>{title}</Title>
{editor && <EditorContent editor={editor} />}
</div>
</>
);
}

View File

@ -0,0 +1,40 @@
import { Text, Group, UnstyledButton } from '@mantine/core';
import { UserAvatar } from '@/components/ui/user-avatar';
import { formatDate } from '@/lib/time';
import classes from './history.module.css';
import clsx from 'clsx';
interface HistoryItemProps {
historyItem: any,
onSelect: (id: string) => void;
isActive: boolean;
}
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
return (
<UnstyledButton p="xs" onClick={() => onSelect(historyItem.id)}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Group wrap="nowrap">
<div>
<Text size="sm">
{formatDate(new Date(historyItem.createdAt))}
</Text>
<div style={{ flex: 1 }}>
<Group gap={4} wrap="nowrap">
<UserAvatar color="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
name={historyItem.lastUpdatedBy.name} />
<Text size="sm" c="dimmed" lineClamp={1} fontWeight={400}>
{historyItem.lastUpdatedBy.name}
</Text>
</Group>
</div>
</div>
</Group>
</UnstyledButton>
);
}
export default HistoryItem;

View File

@ -0,0 +1,85 @@
import { usePageHistoryListQuery, usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
import { useParams } from 'react-router-dom';
import HistoryItem from '@/features/page-history/components/history-item';
import { activeHistoryIdAtom, historyAtoms } from '@/features/page-history/atoms/history-atoms';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { Button, ScrollArea, Group, Divider, Text } from '@mantine/core';
import { editorAtom, titleEditorAtom } from '@/features/editor/atoms/editorAtom';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
function HistoryList() {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const { pageId } = useParams();
const { data, isLoading, isError } = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const [mainEditor] = useAtom(editorAtom);
const [mainEditorTitle] = useAtom(titleEditorAtom);
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const confirmModal = () => modals.openConfirmModal({
title: 'Please confirm your action',
children: (
<Text size="sm">
Are you sure you want to restore this version? Any changes not versioned will be lost.
</Text>
),
labels: { confirm: 'Confirm', cancel: 'Cancel' },
onConfirm: handleRestore,
});
const handleRestore = useCallback(() => {
if (activeHistoryData) {
mainEditorTitle.chain().clearContent().setContent(activeHistoryData.title, true).run();
mainEditor.chain().clearContent().setContent(activeHistoryData.content).run();
setHistoryModalOpen(false);
notifications.show({ message: 'Successfully restored' });
}
}, [activeHistoryData]);
useEffect(() => {
if (data && data.length > 0 && !activeHistoryId) {
setActiveHistoryId(data[0].id);
}
}, [data]);
if (isLoading) {
return <></>;
}
if (isError) {
return <div>Error loading page history.</div>;
}
if (!data || data.length === 0) {
return <>No page history saved yet.</>;
}
return (
<div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{data && data.map((historyItem, index) => (
<HistoryItem
key={index}
historyItem={historyItem}
onSelect={setActiveHistoryId}
isActive={historyItem.id === activeHistoryId}
/>
))}
</ScrollArea>
<Divider />
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>Restore</Button>
<Button variant="default" size="compact-md" onClick={() => setHistoryModalOpen(false)}>Cancel</Button>
</Group>
</div>
);
}
export default HistoryList;

View File

@ -0,0 +1,27 @@
import { ScrollArea } from '@mantine/core';
import HistoryList from '@/features/page-history/components/history-list';
import classes from './history.module.css';
import { useAtom } from 'jotai';
import { activeHistoryIdAtom } from '@/features/page-history/atoms/history-atoms';
import HistoryView from '@/features/page-history/components/history-view';
export default function HistoryModalBody() {
const [activeHistoryId] = useAtom(activeHistoryIdAtom);
return (
<div className={classes.sidebarFlex}>
<nav className={classes.sidebar}>
<div className={classes.sidebarMain}>
<HistoryList />
</div>
</nav>
<ScrollArea h="650" w="100%" scrollbarSize={5}>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Modal, Text } from '@mantine/core';
import { useAtom } from 'jotai';
import { historyAtoms } from '@/features/page-history/atoms/history-atoms';
import HistoryModalBody from '@/features/page-history/components/history-modal-body';
export default function HistoryModal() {
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
return (
<>
<Modal.Root size={1200} opened={isModalOpen} onClose={() => setModalOpen(false)}>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>Page history</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody />
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}

View File

@ -0,0 +1,26 @@
import { usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
import { HistoryEditor } from '@/features/page-history/components/history-editor';
interface HistoryProps {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
if (isLoading) {
return <></>;
}
if (isError || !data) {
return <div>Error fetching page data.</div>;
}
return (data &&
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
);
}
export default HistoryView;

View File

@ -0,0 +1,37 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
}
.active {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}

View File

@ -0,0 +1,20 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { getPageHistoryById, getPageHistoryList } from '@/features/page-history/services/page-history-service';
import { IPageHistory } from '@/features/page-history/types/page.types';
export function usePageHistoryListQuery(pageId: string): UseQueryResult<IPageHistory[], Error> {
return useQuery({
queryKey: ['page-history-list', pageId],
queryFn: () => getPageHistoryList(pageId),
enabled: !!pageId,
});
}
export function usePageHistoryQuery(historyId: string): UseQueryResult<IPageHistory, Error> {
return useQuery({
queryKey: ['page-history', historyId],
queryFn: () => getPageHistoryById(historyId),
enabled: !!historyId,
staleTime: 10 * 60 * 1000,
});
}

View File

@ -0,0 +1,12 @@
import api from '@/lib/api-client';
import { IPageHistory } from '@/features/page-history/types/page.types';
export async function getPageHistoryList(pageId: string): Promise<IPageHistory[]> {
const req = await api.post<IPageHistory[]>('/pages/history', { pageId });
return req.data as IPageHistory[];
}
export async function getPageHistoryById(id: string): Promise<IPageHistory> {
const req = await api.post<IPageHistory>('/pages/history/details', { id });
return req.data as IPageHistory;
}

View File

@ -0,0 +1,21 @@
interface IPageHistoryUser {
id: string;
name: string;
avatarUrl: string;
}
export interface IPageHistory {
id: string;
pageId: string;
title: string;
content?: any;
slug: string;
icon: string;
coverPhoto: string;
version: number;
lastUpdatedById: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
lastUpdatedBy: IPageHistoryUser;
}

View File

@ -13,9 +13,10 @@ const RECENT_CHANGES_KEY = ['recentChanges'];
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
return useQuery({
queryKey: ['page', pageId],
queryKey: ['pages', pageId],
queryFn: () => getPageById(pageId),
enabled: !!pageId,
staleTime: 5 * 60 * 1000,
});
}

View File

@ -13,7 +13,7 @@ import { v4 as uuidv4 } from 'uuid';
import { IMovePage } from '@/features/page/types/page.types';
import { useNavigate } from 'react-router-dom';
import { TreeNode } from '@/features/page/tree/types';
import { useCreatePageMutation, useDeletePageMutation, useUpdatePageMutation } from '@/features/page/queries/page';
import { useCreatePageMutation, useDeletePageMutation, useUpdatePageMutation } from '@/features/page/queries/page-query';
export function usePersistence<T>() {
const [data, setData] = useAtom(treeDataAtom);

View File

@ -11,7 +11,7 @@ export default function SettingsModal() {
<>
<Modal.Root size={1000} opened={isModalOpen} onClose={() => setModalOpen(false)}>
<Modal.Overlay />
<Modal.Content>
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>Settings</Text>

View File

@ -0,0 +1,16 @@
import { useLocation } from 'react-router-dom';
export const useMatchPath = () => {
const location = useLocation();
const matchPath = (pattern) => {
const modifiedPattern = pattern
.replace(/:([^/]+)/g, '([^/]+)(?:/.*)?')
.replace(/\//g, '\\/');
const regex = new RegExp(`^${modifiedPattern}$`);
return regex.test(location.pathname);
};
return matchPath;
};

View File

@ -0,0 +1,18 @@
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
import { useAtom } from 'jotai';
const useToggleAside = () => {
const [asideState, setAsideState] = useAtom(asideStateAtom);
const toggleAside = (tab: string) => {
if (asideState.tab === tab) {
setAsideState({ tab, isAsideOpen: !asideState.isAsideOpen });
} else {
setAsideState({ tab, isAsideOpen: true });
}
};
return toggleAside;
};
export default useToggleAside;

View File

@ -1,9 +1,10 @@
import { useParams } from 'react-router-dom';
import React, { useEffect } from 'react';
import { useAtom } from 'jotai';
import Editor from '@/features/editor/editor';
import { pageAtom } from '@/features/page/atoms/page-atom';
import { usePageQuery } from '@/features/page/queries/page';
import { usePageQuery } from '@/features/page/queries/page-query';
import { FullEditor } from '@/features/editor/full-editor';
import HistoryModal from '@/features/page-history/components/history-modal';
export default function Page() {
const { pageId } = useParams();
@ -12,17 +13,26 @@ export default function Page() {
useEffect(() => {
if (data) {
// @ts-ignore
setPage(data);
}
}, [data, isLoading, setPage, pageId]);
if (isLoading) {
return <div>Loading...</div>;
return <></>;
}
if (isError || !data) { // TODO: fix this
return <div>Error fetching page data.</div>;
}
return (<Editor key={pageId} pageId={pageId} />);
return (
data && (
<div>
<FullEditor key={pageId} pageId={pageId} title={data.title} />
<HistoryModal/>
</div>
)
);
}