mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-26 06:31:07 +10:00
feat: page history
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,4 +22,6 @@ export type SlashMenuItemType = {
|
||||
disable?: (editor: Editor) => boolean;
|
||||
}
|
||||
|
||||
export type SlashMenuGroupedItemsType = Record<string, SlashMenuItemType[]>;
|
||||
export type SlashMenuGroupedItemsType = {
|
||||
[category: string]: SlashMenuItemType[];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -127,6 +127,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
return elem;
|
||||
},
|
||||
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
|
||||
20
client/src/features/editor/full-editor.tsx
Normal file
20
client/src/features/editor/full-editor.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
75
client/src/features/editor/title-editor.tsx
Normal file
75
client/src/features/editor/title-editor.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
4
client/src/features/page-history/atoms/history-atoms.ts
Normal file
4
client/src/features/page-history/atoms/history-atoms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const historyAtoms = atom<boolean>(false);
|
||||
export const activeHistoryIdAtom = atom(null);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
client/src/features/page-history/components/history-item.tsx
Normal file
40
client/src/features/page-history/components/history-item.tsx
Normal 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;
|
||||
85
client/src/features/page-history/components/history-list.tsx
Normal file
85
client/src/features/page-history/components/history-list.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
client/src/features/page-history/components/history-view.tsx
Normal file
26
client/src/features/page-history/components/history-view.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
21
client/src/features/page-history/types/page.types.ts
Normal file
21
client/src/features/page-history/types/page.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user