mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 09:11:15 +10:00
feat: page history
This commit is contained in:
@ -9,7 +9,7 @@
|
|||||||
"preview": "vite preview --port 3000"
|
"preview": "vite preview --port 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/provider": "^2.7.1",
|
"@hocuspocus/provider": "^2.8.1",
|
||||||
"@mantine/core": "^7.2.2",
|
"@mantine/core": "^7.2.2",
|
||||||
"@mantine/form": "^7.2.2",
|
"@mantine/form": "^7.2.2",
|
||||||
"@mantine/hooks": "^7.2.2",
|
"@mantine/hooks": "^7.2.2",
|
||||||
|
|||||||
@ -1,11 +1,41 @@
|
|||||||
import React, { Suspense } from 'react';
|
import { Box, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||||
|
import CommentList from '@/features/comment/components/comment-list';
|
||||||
const Comments = React.lazy(() => import('@/features/comment/comments'));
|
import { useAtom } from 'jotai';
|
||||||
|
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
export default function Aside() {
|
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 (
|
return (
|
||||||
<Suspense fallback={<div>Loading comments...</div>}>
|
<Box p="md" bg={theme.colors?.gray[1]}>
|
||||||
<Comments />
|
{component && (
|
||||||
</Suspense>
|
<>
|
||||||
|
<Text mb="md" fw={500}>{title}</Text>
|
||||||
|
|
||||||
|
<ScrollArea style={{ height: '85vh' }} scrollbarSize={5} type="scroll">
|
||||||
|
<div style={{ paddingBottom: '200px' }}>
|
||||||
|
{component}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Menu,
|
Menu,
|
||||||
Button,
|
Button,
|
||||||
rem,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconDots,
|
IconDots,
|
||||||
@ -15,15 +14,20 @@ import {
|
|||||||
IconMessage,
|
IconMessage,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import React from '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() {
|
export default function Header() {
|
||||||
|
const toggleAside = useToggleAside();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
|
<Button variant="default" style={{ border: 'none' }} size="compact-sm">
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
<ActionIcon variant="default" style={{ border: 'none' }} onClick={() => toggleAside('comments')}>
|
||||||
<IconMessage size={20} stroke={2} />
|
<IconMessage size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
@ -33,6 +37,12 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PageActionMenu() {
|
function PageActionMenu() {
|
||||||
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
|
|
||||||
|
const openHistoryModal = () => {
|
||||||
|
setHistoryModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
shadow="xl"
|
shadow="xl"
|
||||||
@ -50,43 +60,31 @@ function PageActionMenu() {
|
|||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={<IconFileInfo size={16} stroke={2} />}>
|
||||||
<IconFileInfo style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Page info
|
Page info
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconLink size={16} stroke={2} />}
|
||||||
>
|
>
|
||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={<IconShare size={16} stroke={2} />}>
|
||||||
<IconShare style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Share
|
Share
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={<IconHistory size={16} stroke={2} />}
|
||||||
<IconHistory style={{ width: rem(14), height: rem(14) }} />
|
onClick={openHistoryModal}>
|
||||||
}
|
|
||||||
>
|
|
||||||
Page history
|
Page history
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLock style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconLock size={16} stroke={2} />}>
|
||||||
>
|
|
||||||
Lock
|
Lock
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={<IconTrash size={16} stroke={2} />}>
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
|
|||||||
@ -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 { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
|
||||||
import { Navbar } from '@/components/navbar/navbar';
|
import { Navbar } from '@/components/navbar/navbar';
|
||||||
import { AppShell, Burger, Group } from '@mantine/core';
|
import { AppShell, Burger, Group } from '@mantine/core';
|
||||||
@ -8,12 +8,16 @@ import classes from './shell.module.css';
|
|||||||
import Header from '@/components/layouts/header';
|
import Header from '@/components/layouts/header';
|
||||||
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
||||||
import Aside from '@/components/aside/aside';
|
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 }) {
|
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
const [desktopAsideOpened] = useAtom(desktopAsideAtom);
|
const matchPath = useMatchPath();
|
||||||
|
const isPageRoute = matchPath('/p/:pageId');
|
||||||
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@ -24,7 +28,11 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
breakpoint: 'sm',
|
breakpoint: 'sm',
|
||||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
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"
|
padding="md"
|
||||||
>
|
>
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
@ -47,13 +55,15 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Breadcrumb />
|
{isPageRoute && <Breadcrumb />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{
|
||||||
|
isPageRoute &&
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||||
<Header />
|
<Header />
|
||||||
</Group>
|
</Group>
|
||||||
|
}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
@ -66,9 +76,13 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{
|
||||||
|
isPageRoute &&
|
||||||
<AppShell.Aside className={classes.aside}>
|
<AppShell.Aside className={classes.aside}>
|
||||||
<Aside />
|
<Aside />
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
|
}
|
||||||
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import { atomWithWebStorage } from "@/lib/jotai-helper";
|
import { atomWithWebStorage } from '@/lib/jotai-helper';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
|
export const desktopSidebarAtom = atomWithWebStorage('showSidebar', true);
|
||||||
|
|
||||||
export const desktopAsideAtom = atom(false);
|
export const desktopAsideAtom = atom(false);
|
||||||
|
|
||||||
|
type AsideStateType = {
|
||||||
|
tab: string,
|
||||||
|
isAsideOpen: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asideStateAtom = atom<AsideStateType>({
|
||||||
|
tab: '',
|
||||||
|
isAsideOpen: false,
|
||||||
|
});
|
||||||
|
|||||||
34
client/src/components/ui/user-avatar.tsx
Normal file
34
client/src/components/ui/user-avatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -2,3 +2,8 @@ import { atom } from 'jotai';
|
|||||||
import { Editor } from '@tiptap/core';
|
import { Editor } from '@tiptap/core';
|
||||||
|
|
||||||
export const editorAtom = atom<Editor | null>(null);
|
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'>;
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'>;
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
|
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Editor } from '@tiptap/core';
|
import { Editor } from '@tiptap/core';
|
||||||
import { Dispatch, FC, SetStateAction } from 'react';
|
import React, { Dispatch, FC, SetStateAction } from 'react';
|
||||||
import {
|
import {
|
||||||
IconBlockquote,
|
IconBlockquote,
|
||||||
IconCheck, IconCheckbox, IconChevronDown, IconCode,
|
IconCheck, IconCheckbox, IconChevronDown, IconCode,
|
||||||
@ -21,7 +21,7 @@ interface NodeSelectorProps {
|
|||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
icon: FC;
|
icon: React.ElementType;
|
||||||
command: () => void;
|
command: () => void;
|
||||||
isActive: () => boolean;
|
isActive: () => boolean;
|
||||||
}
|
}
|
||||||
@ -126,7 +126,7 @@ export const NodeSelector: FC<NodeSelectorProps> =
|
|||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<item.icon size={16} />}
|
leftSection={<item.icon size={16} />}
|
||||||
rightSection={activeItem.name === item.name
|
rightSection={activeItem.name === item.name
|
||||||
&& (<IconCheck style={{ width: rem(16) }} />)}
|
&& (<IconCheck size={16} />)}
|
||||||
justify="left"
|
justify="left"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
|
SlashMenuGroupedItemsType,
|
||||||
SlashMenuItemType,
|
SlashMenuItemType,
|
||||||
} from '@/features/editor/components/slash-menu/types';
|
} from '@/features/editor/components/slash-menu/types';
|
||||||
import {
|
import {
|
||||||
@ -24,7 +25,7 @@ const CommandList = ({
|
|||||||
editor,
|
editor,
|
||||||
range,
|
range,
|
||||||
}: {
|
}: {
|
||||||
items: SlashMenuItemType[];
|
items: SlashMenuGroupedItemsType;
|
||||||
command: any;
|
command: any;
|
||||||
editor: any;
|
editor: any;
|
||||||
range: any;
|
range: any;
|
||||||
|
|||||||
@ -22,4 +22,6 @@ export type SlashMenuItemType = {
|
|||||||
disable?: (editor: Editor) => boolean;
|
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 { HocuspocusProvider } from '@hocuspocus/provider';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||||
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
|
||||||
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
import useCollaborationUrl from '@/features/editor/hooks/use-collaboration-url';
|
||||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
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 { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||||
import { Document } from '@tiptap/extension-document';
|
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||||
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 { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
import { activeCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||||
import CommentDialog from '@/features/comment/components/comment-dialog';
|
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 { collabExtensions, mainExtensions } from '@/features/editor/extensions';
|
||||||
import { useUpdatePageMutation } from '@/features/page/queries/page';
|
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@ -78,6 +69,9 @@ export default function Editor({ pageId }: EditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSynced = isLocalSynced || isRemoteSynced;
|
const isSynced = isLocalSynced || isRemoteSynced;
|
||||||
|
if (isSynced){
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} pageId={pageId} />);
|
return (isSynced && <TiptapEditor ydoc={yDoc} provider={provider} pageId={pageId} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,61 +84,19 @@ interface TiptapEditorProps {
|
|||||||
function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(editorAtom);
|
const [, setEditor] = useAtom(editorAtom);
|
||||||
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
|
const [titleEditor] = useAtom(titleEditorAtom);
|
||||||
const [debouncedTitleState, setDebouncedTitleState] = useState('');
|
const [asideState, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
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 extensions = [
|
const extensions = [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(ydoc, provider),
|
...collabExtensions(ydoc, provider),
|
||||||
Comment.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'comment-mark',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
autofocus: false,
|
autofocus: 0,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
@ -159,6 +111,7 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
|||||||
},
|
},
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
// @ts-ignore
|
||||||
setEditor(editor);
|
setEditor(editor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -177,30 +130,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
titleEditor?.commands.focus('end');
|
||||||
|
}, 200);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && currentUser.user) {
|
if (editor && currentUser.user) {
|
||||||
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
|
editor.chain().focus().updateUser({ ...currentUser.user, color: getRandomColor() }).run();
|
||||||
}
|
}
|
||||||
}, [editor, currentUser.user]);
|
}, [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 handleActiveCommentEvent = (event) => {
|
||||||
const { commentId } = event.detail;
|
const { commentId } = event.detail;
|
||||||
setActiveCommentId(commentId);
|
setActiveCommentId(commentId);
|
||||||
setDesktopAsideOpened(true);
|
setAsideState({ tab: 'comments', isAsideOpen: true });
|
||||||
|
|
||||||
const selector = `div[data-comment-id="${commentId}"]`;
|
const selector = `div[data-comment-id="${commentId}"]`;
|
||||||
const commentElement = document.querySelector(selector);
|
const commentElement = document.querySelector(selector);
|
||||||
@ -216,21 +161,22 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveCommentId(null);
|
setActiveCommentId(null);
|
||||||
setDesktopAsideOpened(false);
|
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
|
setAsideState({ tab: '', isAsideOpen: false });
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div className={classes.editor}>
|
{editor &&
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
(<div>
|
||||||
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCommentPopup && (
|
{showCommentPopup && (
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
<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 SlashCommand from '@/features/editor/extensions/slash-command';
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||||
|
import { Comment } from '@/features/editor/extensions/comment/comment';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
@ -47,6 +48,11 @@ export const mainExtensions = [
|
|||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
Color,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
|
Comment.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'comment-mark',
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];
|
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];
|
||||||
|
|||||||
@ -127,6 +127,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
|||||||
return elem;
|
return elem;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
addProseMirrorPlugins(): Plugin[] {
|
addProseMirrorPlugins(): Plugin[] {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return [commentDecoration()];
|
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 classes from './home.module.css';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import PageListSkeleton from '@/features/home/components/page-list-skeleton';
|
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() {
|
function RecentChanges() {
|
||||||
const { data, isLoading, isError } = useRecentChangesQuery();
|
const { data, isLoading, isError } = useRecentChangesQuery();
|
||||||
@ -22,7 +22,7 @@ function RecentChanges() {
|
|||||||
<div key={page.id}>
|
<div key={page.id}>
|
||||||
<UnstyledButton component={Link} to={`/p/${page.id}`}
|
<UnstyledButton component={Link} to={`/p/${page.id}`}
|
||||||
className={classes.page} p="xs">
|
className={classes.page} p="xs">
|
||||||
<Group wrap="noWrap">
|
<Group wrap="nowrap">
|
||||||
|
|
||||||
<Stack gap="xs" style={{ flex: 1 }}>
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
<Text fw={500} size="sm">
|
<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> {
|
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['page', pageId],
|
queryKey: ['pages', pageId],
|
||||||
queryFn: () => getPageById(pageId),
|
queryFn: () => getPageById(pageId),
|
||||||
enabled: !!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 { IMovePage } from '@/features/page/types/page.types';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { TreeNode } from '@/features/page/tree/types';
|
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>() {
|
export function usePersistence<T>() {
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
const [data, setData] = useAtom(treeDataAtom);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export default function SettingsModal() {
|
|||||||
<>
|
<>
|
||||||
<Modal.Root size={1000} opened={isModalOpen} onClose={() => setModalOpen(false)}>
|
<Modal.Root size={1000} opened={isModalOpen} onClose={() => setModalOpen(false)}>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content>
|
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
<Text size="md" fw={500}>Settings</Text>
|
<Text size="md" fw={500}>Settings</Text>
|
||||||
|
|||||||
16
client/src/hooks/use-match-path.tsx
Normal file
16
client/src/hooks/use-match-path.tsx
Normal 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;
|
||||||
|
};
|
||||||
18
client/src/hooks/use-toggle-aside.tsx
Normal file
18
client/src/hooks/use-toggle-aside.tsx
Normal 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;
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import Editor from '@/features/editor/editor';
|
|
||||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
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() {
|
export default function Page() {
|
||||||
const { pageId } = useParams();
|
const { pageId } = useParams();
|
||||||
@ -12,17 +13,26 @@ export default function Page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
// @ts-ignore
|
||||||
setPage(data);
|
setPage(data);
|
||||||
}
|
}
|
||||||
}, [data, isLoading, setPage, pageId]);
|
}, [data, isLoading, setPage, pageId]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) { // TODO: fix this
|
if (isError || !data) { // TODO: fix this
|
||||||
return <div>Error fetching page data.</div>;
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.431.0",
|
"@aws-sdk/client-s3": "^3.431.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.431.0",
|
"@aws-sdk/s3-request-presigner": "^3.431.0",
|
||||||
"@hocuspocus/server": "^2.7.1",
|
"@hocuspocus/server": "^2.8.1",
|
||||||
"@hocuspocus/transformer": "^2.7.1",
|
"@hocuspocus/transformer": "^2.8.1",
|
||||||
"@nestjs/common": "^10.2.7",
|
"@nestjs/common": "^10.2.7",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.2.7",
|
"@nestjs/core": "^10.2.7",
|
||||||
@ -59,6 +59,7 @@
|
|||||||
"@nestjs/schematics": "^10.0.2",
|
"@nestjs/schematics": "^10.0.2",
|
||||||
"@nestjs/testing": "^10.2.7",
|
"@nestjs/testing": "^10.2.7",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.2",
|
"@types/fs-extra": "^11.0.2",
|
||||||
"@types/jest": "^29.5.5",
|
"@types/jest": "^29.5.5",
|
||||||
"@types/mime-types": "^2.1.2",
|
"@types/mime-types": "^2.1.2",
|
||||||
|
|||||||
@ -4,18 +4,24 @@ import WebSocket from 'ws';
|
|||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { HistoryExtension } from './extensions/history.extension';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
constructor(
|
constructor(
|
||||||
private authenticationExtension: AuthenticationExtension,
|
private authenticationExtension: AuthenticationExtension,
|
||||||
private persistenceExtension: PersistenceExtension,
|
private persistenceExtension: PersistenceExtension,
|
||||||
|
private historyExtension: HistoryExtension,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private hocuspocus = HocuspocusServer.configure({
|
private hocuspocus = HocuspocusServer.configure({
|
||||||
debounce: 5000,
|
debounce: 5000,
|
||||||
maxDebounce: 10000,
|
maxDebounce: 10000,
|
||||||
extensions: [this.authenticationExtension, this.persistenceExtension],
|
extensions: [
|
||||||
|
this.authenticationExtension,
|
||||||
|
this.persistenceExtension,
|
||||||
|
this.historyExtension,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||||
|
|||||||
@ -9,12 +9,14 @@ import { HttpAdapterHost } from '@nestjs/core';
|
|||||||
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
import { HistoryExtension } from './extensions/history.extension';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CollaborationGateway,
|
CollaborationGateway,
|
||||||
AuthenticationExtension,
|
AuthenticationExtension,
|
||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
|
HistoryExtension,
|
||||||
],
|
],
|
||||||
imports: [UserModule, AuthModule, PageModule],
|
imports: [UserModule, AuthModule, PageModule],
|
||||||
})
|
})
|
||||||
|
|||||||
64
server/src/collaboration/extensions/history.extension.ts
Normal file
64
server/src/collaboration/extensions/history.extension.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Extension,
|
||||||
|
onChangePayload,
|
||||||
|
onDisconnectPayload,
|
||||||
|
} from '@hocuspocus/server';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PageService } from '../../core/page/services/page.service';
|
||||||
|
import { PageHistoryService } from '../../core/page/services/page-history.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HistoryExtension implements Extension {
|
||||||
|
ACTIVE_EDITING_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
historyIntervalMap = new Map<string, NodeJS.Timeout>();
|
||||||
|
lastEditTimeMap = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pageService: PageService,
|
||||||
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onChange(data: onChangePayload): Promise<void> {
|
||||||
|
const pageId = data.documentName;
|
||||||
|
this.lastEditTimeMap.set(pageId, Date.now());
|
||||||
|
|
||||||
|
if (!this.historyIntervalMap.has(pageId)) {
|
||||||
|
const historyInterval = setInterval(() => {
|
||||||
|
if (this.isActiveEditing(pageId)) {
|
||||||
|
this.recordHistory(pageId);
|
||||||
|
}
|
||||||
|
}, this.ACTIVE_EDITING_INTERVAL);
|
||||||
|
this.historyIntervalMap.set(pageId, historyInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDisconnect(data: onDisconnectPayload): Promise<void> {
|
||||||
|
const pageId = data.documentName;
|
||||||
|
if (data.clientsCount === 0) {
|
||||||
|
if (this.historyIntervalMap.has(pageId)) {
|
||||||
|
clearInterval(this.historyIntervalMap.get(pageId));
|
||||||
|
this.historyIntervalMap.delete(pageId);
|
||||||
|
this.lastEditTimeMap.delete(pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActiveEditing(pageId: string): boolean {
|
||||||
|
const lastEditTime = this.lastEditTimeMap.get(pageId);
|
||||||
|
if (!lastEditTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - lastEditTime < this.ACTIVE_EDITING_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordHistory(pageId: string) {
|
||||||
|
try {
|
||||||
|
const page = await this.pageService.findWithContent(pageId);
|
||||||
|
// Todo: compare if data is the same as the previous version
|
||||||
|
await this.pageHistoryService.saveHistory(page);
|
||||||
|
console.log(`New history created for: ${pageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('An error occurred saving page history', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,12 +14,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
const { documentName, document } = data;
|
const { documentName, document } = data;
|
||||||
|
const pageId = documentName;
|
||||||
|
|
||||||
if (!document.isEmpty('default')) {
|
if (!document.isEmpty('default')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageService.findById(documentName);
|
const page = await this.pageService.findWithAllFields(pageId);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.log('page does not exist.');
|
console.log('page does not exist.');
|
||||||
|
|||||||
@ -9,10 +9,6 @@ export class CreatePageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
content?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
parentPageId?: string;
|
parentPageId?: string;
|
||||||
|
|||||||
6
server/src/core/page/dto/history-details.dto.ts
Normal file
6
server/src/core/page/dto/history-details.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class HistoryDetailsDto {
|
||||||
|
@IsUUID()
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
6
server/src/core/page/dto/page-history.dto.ts
Normal file
6
server/src/core/page/dto/page-history.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class PageHistoryDto {
|
||||||
|
@IsUUID()
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
63
server/src/core/page/entities/page-history.entity.ts
Normal file
63
server/src/core/page/entities/page-history.entity.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||||
|
import { Page } from './page.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity('page_history')
|
||||||
|
export class PageHistory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Page, (page) => page.pageHistory, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'pageId' })
|
||||||
|
page: Page;
|
||||||
|
|
||||||
|
@Column({ length: 500, nullable: true })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
coverPhoto: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
lastUpdatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'lastUpdatedById' })
|
||||||
|
lastUpdatedBy: User;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
workspaceId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'workspaceId' })
|
||||||
|
workspace: Workspace;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
import { User } from '../../user/entities/user.entity';
|
import { User } from '../../user/entities/user.entity';
|
||||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||||
import { Comment } from '../../comment/entities/comment.entity';
|
import { Comment } from '../../comment/entities/comment.entity';
|
||||||
|
import { PageHistory } from './page-history.entity';
|
||||||
|
|
||||||
@Entity('pages')
|
@Entity('pages')
|
||||||
export class Page {
|
export class Page {
|
||||||
@ -101,6 +102,9 @@ export class Page {
|
|||||||
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
|
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
|
||||||
childPages: Page[];
|
childPages: Page[];
|
||||||
|
|
||||||
|
@OneToMany(() => PageHistory, (pageHistory) => pageHistory.page)
|
||||||
|
pageHistory: PageHistory[];
|
||||||
|
|
||||||
@OneToMany(() => Comment, (comment) => comment.page)
|
@OneToMany(() => Comment, (comment) => comment.page)
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,9 @@ import { MovePageDto } from './dto/move-page.dto';
|
|||||||
import { PageDetailsDto } from './dto/page-details.dto';
|
import { PageDetailsDto } from './dto/page-details.dto';
|
||||||
import { DeletePageDto } from './dto/delete-page.dto';
|
import { DeletePageDto } from './dto/delete-page.dto';
|
||||||
import { PageOrderingService } from './services/page-ordering.service';
|
import { PageOrderingService } from './services/page-ordering.service';
|
||||||
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
|
import { HistoryDetailsDto } from './dto/history-details.dto';
|
||||||
|
import { PageHistoryDto } from './dto/page-history.dto';
|
||||||
|
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@ -24,13 +27,14 @@ export class PageController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly pageService: PageService,
|
private readonly pageService: PageService,
|
||||||
private readonly pageOrderService: PageOrderingService,
|
private readonly pageOrderService: PageOrderingService,
|
||||||
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/details')
|
@Post('/details')
|
||||||
async getPage(@Body() input: PageDetailsDto) {
|
async getPage(@Body() input: PageDetailsDto) {
|
||||||
return this.pageService.findWithoutYDoc(input.id);
|
return this.pageService.findOne(input.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ -118,4 +122,16 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageOrderService.convertToTree(workspaceId);
|
return this.pageOrderService.convertToTree(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/history')
|
||||||
|
async getPageHistory(@Body() dto: PageHistoryDto) {
|
||||||
|
return this.pageHistoryService.findHistoryByPageId(dto.pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/history/details')
|
||||||
|
async get(@Body() dto: HistoryDetailsDto) {
|
||||||
|
return this.pageHistoryService.findOne(dto.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,15 +8,24 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||||
import { PageOrderingService } from './services/page-ordering.service';
|
import { PageOrderingService } from './services/page-ordering.service';
|
||||||
import { PageOrdering } from './entities/page-ordering.entity';
|
import { PageOrdering } from './entities/page-ordering.entity';
|
||||||
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
|
import { PageHistory } from './entities/page-history.entity';
|
||||||
|
import { PageHistoryRepository } from './repositories/page-history.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Page, PageOrdering]),
|
TypeOrmModule.forFeature([Page, PageOrdering, PageHistory]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
],
|
],
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageOrderingService, PageRepository],
|
providers: [
|
||||||
exports: [PageService, PageOrderingService, PageRepository],
|
PageService,
|
||||||
|
PageOrderingService,
|
||||||
|
PageHistoryService,
|
||||||
|
PageRepository,
|
||||||
|
PageHistoryRepository,
|
||||||
|
],
|
||||||
|
exports: [PageService, PageOrderingService, PageHistoryService],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
26
server/src/core/page/repositories/page-history.repository.ts
Normal file
26
server/src/core/page/repositories/page-history.repository.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PageHistory } from '../entities/page-history.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageHistoryRepository extends Repository<PageHistory> {
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
super(PageHistory, dataSource.createEntityManager());
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(pageId: string) {
|
||||||
|
return this.findOne({
|
||||||
|
where: {
|
||||||
|
id: pageId,
|
||||||
|
},
|
||||||
|
relations: ['lastUpdatedBy'],
|
||||||
|
select: {
|
||||||
|
lastUpdatedBy: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,21 +8,12 @@ export class PageRepository extends Repository<Page> {
|
|||||||
super(Page, dataSource.createEntityManager());
|
super(Page, dataSource.createEntityManager());
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(pageId: string) {
|
public baseFields = [
|
||||||
return this.findOneBy({ id: pageId });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findWithoutYDoc(pageId: string) {
|
|
||||||
return this.dataSource
|
|
||||||
.createQueryBuilder(Page, 'page')
|
|
||||||
.where('page.id = :id', { id: pageId })
|
|
||||||
.select([
|
|
||||||
'page.id',
|
'page.id',
|
||||||
'page.title',
|
'page.title',
|
||||||
'page.slug',
|
'page.slug',
|
||||||
'page.icon',
|
'page.icon',
|
||||||
'page.coverPhoto',
|
'page.coverPhoto',
|
||||||
'page.editor',
|
|
||||||
'page.shareId',
|
'page.shareId',
|
||||||
'page.parentPageId',
|
'page.parentPageId',
|
||||||
'page.creatorId',
|
'page.creatorId',
|
||||||
@ -34,7 +25,32 @@ export class PageRepository extends Repository<Page> {
|
|||||||
'page.createdAt',
|
'page.createdAt',
|
||||||
'page.updatedAt',
|
'page.updatedAt',
|
||||||
'page.deletedAt',
|
'page.deletedAt',
|
||||||
])
|
];
|
||||||
|
|
||||||
|
private async baseFind(pageId: string, selectFields: string[]) {
|
||||||
|
return this.dataSource
|
||||||
|
.createQueryBuilder(Page, 'page')
|
||||||
|
.where('page.id = :id', { id: pageId })
|
||||||
|
.select(selectFields)
|
||||||
.getOne();
|
.getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findById(pageId: string) {
|
||||||
|
return this.baseFind(pageId, this.baseFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithYDoc(pageId: string) {
|
||||||
|
const extendedFields = [...this.baseFields, 'page.ydoc'];
|
||||||
|
return this.baseFind(pageId, extendedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithContent(pageId: string) {
|
||||||
|
const extendedFields = [...this.baseFields, 'page.content'];
|
||||||
|
return this.baseFind(pageId, extendedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithAllFields(pageId: string) {
|
||||||
|
const extendedFields = [...this.baseFields, 'page.content', 'page.ydoc'];
|
||||||
|
return this.baseFind(pageId, extendedFields);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
server/src/core/page/services/page-history.service.ts
Normal file
61
server/src/core/page/services/page-history.service.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { PageHistory } from '../entities/page-history.entity';
|
||||||
|
import { Page } from '../entities/page.entity';
|
||||||
|
import { PageHistoryRepository } from '../repositories/page-history.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageHistoryService {
|
||||||
|
constructor(private pageHistoryRepo: PageHistoryRepository) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(historyId: string): Promise<PageHistory> {
|
||||||
|
const history = await this.pageHistoryRepo.findById(historyId);
|
||||||
|
if (!history) {
|
||||||
|
throw new BadRequestException('History not found');
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveHistory(page: Page): Promise<void> {
|
||||||
|
const pageHistory = new PageHistory();
|
||||||
|
pageHistory.pageId = page.id;
|
||||||
|
pageHistory.title = page.title;
|
||||||
|
pageHistory.content = page.content;
|
||||||
|
pageHistory.slug = page.slug;
|
||||||
|
pageHistory.icon = page.icon;
|
||||||
|
pageHistory.version = 1; // TODO: make incremental
|
||||||
|
pageHistory.coverPhoto = page.coverPhoto;
|
||||||
|
pageHistory.lastUpdatedById = page.lastUpdatedById ?? page.creatorId;
|
||||||
|
pageHistory.workspaceId = page.workspaceId;
|
||||||
|
|
||||||
|
await this.pageHistoryRepo.save(pageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHistoryByPageId(pageId: string, limit = 50, offset = 0) {
|
||||||
|
const history = await this.pageHistoryRepo
|
||||||
|
.createQueryBuilder('history')
|
||||||
|
.where('history.pageId = :pageId', { pageId })
|
||||||
|
.leftJoinAndSelect('history.lastUpdatedBy', 'user')
|
||||||
|
.select([
|
||||||
|
'history.id',
|
||||||
|
'history.pageId',
|
||||||
|
'history.title',
|
||||||
|
'history.slug',
|
||||||
|
'history.icon',
|
||||||
|
'history.coverPhoto',
|
||||||
|
'history.version',
|
||||||
|
'history.lastUpdatedById',
|
||||||
|
'history.workspaceId',
|
||||||
|
'history.createdAt',
|
||||||
|
'history.updatedAt',
|
||||||
|
'user.id',
|
||||||
|
'user.name',
|
||||||
|
'user.avatarUrl',
|
||||||
|
])
|
||||||
|
.orderBy('history.updatedAt', 'DESC')
|
||||||
|
.offset(offset)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,8 +35,25 @@ export class PageService {
|
|||||||
return this.pageRepository.findById(pageId);
|
return this.pageRepository.findById(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findWithoutYDoc(pageId: string) {
|
async findWithContent(pageId: string) {
|
||||||
return this.pageRepository.findWithoutYDoc(pageId);
|
return this.pageRepository.findWithContent(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithYdoc(pageId: string) {
|
||||||
|
return this.pageRepository.findWithYDoc(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithAllFields(pageId: string) {
|
||||||
|
return this.pageRepository.findWithAllFields(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(pageId: string): Promise<Page> {
|
||||||
|
const page = await this.findById(pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new BadRequestException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@ -85,7 +102,7 @@ export class PageService {
|
|||||||
throw new BadRequestException(`Page not found`);
|
throw new BadRequestException(`Page not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.pageRepository.findWithoutYDoc(pageId);
|
return await this.pageRepository.findById(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateState(
|
async updateState(
|
||||||
@ -240,25 +257,7 @@ export class PageService {
|
|||||||
const pages = await this.pageRepository
|
const pages = await this.pageRepository
|
||||||
.createQueryBuilder('page')
|
.createQueryBuilder('page')
|
||||||
.where('page.workspaceId = :workspaceId', { workspaceId })
|
.where('page.workspaceId = :workspaceId', { workspaceId })
|
||||||
.select([
|
.select(this.pageRepository.baseFields)
|
||||||
'page.id',
|
|
||||||
'page.title',
|
|
||||||
'page.slug',
|
|
||||||
'page.icon',
|
|
||||||
'page.coverPhoto',
|
|
||||||
'page.editor',
|
|
||||||
'page.shareId',
|
|
||||||
'page.parentPageId',
|
|
||||||
'page.creatorId',
|
|
||||||
'page.lastUpdatedById',
|
|
||||||
'page.workspaceId',
|
|
||||||
'page.isLocked',
|
|
||||||
'page.status',
|
|
||||||
'page.publishedAt',
|
|
||||||
'page.createdAt',
|
|
||||||
'page.updatedAt',
|
|
||||||
'page.deletedAt',
|
|
||||||
])
|
|
||||||
.orderBy('page.updatedAt', 'DESC')
|
.orderBy('page.updatedAt', 'DESC')
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user