mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:32:37 +10:00
feat: comments
* create comment * reply to comment thread * edit comment * delete comment * resolve comment
This commit is contained in:
@ -10,13 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/provider": "^2.7.1",
|
||||
"@mantine/core": "^7.1.5",
|
||||
"@mantine/form": "^7.1.5",
|
||||
"@mantine/hooks": "^7.1.5",
|
||||
"@mantine/spotlight": "^7.1.5",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@mantine/core": "^7.2.1",
|
||||
"@mantine/form": "^7.2.1",
|
||||
"@mantine/hooks": "^7.2.1",
|
||||
"@mantine/modals": "^7.2.1",
|
||||
"@mantine/spotlight": "^7.2.1",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@tiptap/extension-code-block": "^2.1.12",
|
||||
"@tiptap/extension-collaboration": "^2.1.12",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.1.12",
|
||||
@ -42,20 +42,21 @@
|
||||
"@tiptap/react": "^2.1.12",
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
"@tiptap/suggestion": "^2.1.12",
|
||||
"axios": "^1.5.1",
|
||||
"axios": "^1.6.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jotai": "^2.4.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"jotai": "^2.5.1",
|
||||
"jotai-optics": "^0.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.17.0",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^9.0.1",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.8",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
@ -64,7 +65,7 @@
|
||||
"@types/node": "20.8.6",
|
||||
"@types/react": "^18.2.29",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
|
||||
11
client/src/components/aside/aside.tsx
Normal file
11
client/src/components/aside/aside.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
const Comments = React.lazy(() => import('@/features/comment/comments'));
|
||||
|
||||
export default function Aside() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading comments...</div>}>
|
||||
<Comments />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
Group,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Button,
|
||||
@ -13,6 +12,7 @@ import {
|
||||
IconLock,
|
||||
IconShare,
|
||||
IconTrash,
|
||||
IconMessage,
|
||||
} from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
@ -23,6 +23,10 @@ export default function Header() {
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
<PageActionMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
}
|
||||
|
||||
.aside {
|
||||
background: var(--mantine-color-gray-light);
|
||||
|
||||
[data-layout='alt'] & {
|
||||
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
|
||||
--_section-height: var(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { desktopAsideAtom, desktopSidebarAtom } from '@/components/navbar/atoms/sidebar-atom';
|
||||
import { useToggleSidebar } from '@/components/navbar/hooks/use-toggle-sidebar';
|
||||
import { Navbar } from '@/components/navbar/navbar';
|
||||
import { ActionIcon, UnstyledButton, ActionIconGroup, AppShell, Avatar, Burger, Group } from '@mantine/core';
|
||||
@ -8,11 +8,13 @@ import { useAtom } from 'jotai';
|
||||
import classes from './shell.module.css';
|
||||
import Header from '@/components/layouts/header';
|
||||
import Breadcrumb from '@/components/layouts/components/breadcrumb';
|
||||
import Aside from '@/components/aside/aside';
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const [desktopAsideOpened] = useAtom(desktopAsideAtom);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@ -23,7 +25,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopOpened } }}
|
||||
aside={{ width: 300, breakpoint: 'md', collapsed: { mobile: true, desktop: !desktopAsideOpened } }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header
|
||||
@ -61,13 +63,12 @@ export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
<Navbar />
|
||||
</AppShell.Navbar>
|
||||
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside className={classes.aside}>
|
||||
TODO
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper";
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
|
||||
|
||||
export const desktopAsideAtom = atom(false);
|
||||
|
||||
17
client/src/features/comment/atoms/comment-atom.ts
Normal file
17
client/src/features/comment/atoms/comment-atom.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { IComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export const commentsAtom = atomFamily((pageId: string) => atom<IComment[]>([]));
|
||||
export const showCommentPopupAtom = atom(false);
|
||||
export const activeCommentIdAtom = atom<string | null>(null);
|
||||
export const draftCommentIdAtom = atom<string | null>(null);
|
||||
|
||||
export const deleteCommentAtom = atomFamily((pageId: string) => atom(
|
||||
null,
|
||||
(get, set, idToDelete: string) => {
|
||||
const currentPageComments = get(commentsAtom(pageId));
|
||||
const updatedComments = currentPageComments.filter(comment => comment.id !== idToDelete);
|
||||
set(commentsAtom(pageId), updatedComments);
|
||||
}
|
||||
));
|
||||
34
client/src/features/comment/comments.tsx
Normal file
34
client/src/features/comment/comments.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { getPageComments } from '@/features/comment/services/comment-service';
|
||||
import classes from '@/features/comment/components/comment.module.css';
|
||||
import { Text } from '@mantine/core';
|
||||
import CommentList from '@/features/comment/components/comment-list';
|
||||
|
||||
export default function Comments() {
|
||||
const { pageId } = useParams();
|
||||
const [comments, setComments] = useAtom(commentsAtom(pageId));
|
||||
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const response = await getPageComments(pageId);
|
||||
setComments(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchComments();
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<Text mb="md" fw={500}>Comments</Text>
|
||||
|
||||
<CommentList comments={comments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
client/src/features/comment/components/comment-actions.tsx
Normal file
16
client/src/features/comment/components/comment-actions.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
|
||||
type CommentActionsProps = {
|
||||
onSave: () => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
||||
return (
|
||||
<Group justify="flex-end" pt={2} wrap="nowrap">
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentActions;
|
||||
99
client/src/features/comment/components/comment-dialog.tsx
Normal file
99
client/src/features/comment/components/comment-dialog.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Dialog, Group, Stack, Text } from '@mantine/core';
|
||||
import { useClickOutside } from '@mantine/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
commentsAtom,
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
} from '@/features/comment/atoms/comment-atom';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
}
|
||||
|
||||
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
||||
const [, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
|
||||
const [draftCommentId, setDraftCommentId] = useAtom<string | null>(draftCommentIdAtom);
|
||||
const [comments, setComments] = useAtom(commentsAtom(pageId));
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const useClickOutsideRef = useClickOutside(() => {
|
||||
handleDialogClose();
|
||||
});
|
||||
const { createCommentMutation } = useComment();
|
||||
const { isLoading } = createCommentMutation;
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
const selectedText = getSelectedText();
|
||||
const commentData = {
|
||||
id: draftCommentId,
|
||||
pageId: pageId,
|
||||
content: JSON.stringify(comment),
|
||||
selection: selectedText,
|
||||
};
|
||||
|
||||
try {
|
||||
const createdComment = await createCommentMutation.mutateAsync(commentData);
|
||||
setComments(prevComments => [...prevComments, createdComment]);
|
||||
editor.chain().setComment(createdComment.id).unsetCommentDecoration().run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
setDraftCommentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentEditorChange = (newContent) => {
|
||||
setComment(newContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog opened={true} onClose={handleDialogClose} ref={useClickOutsideRef} size="lg" radius="md"
|
||||
w={300} position={{ bottom: 500, right: 50 }} withCloseButton withBorder>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Group>
|
||||
<Avatar size="sm" color="blue">{currentUser.user.name.charAt(0)}</Avatar>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<CommentEditor onUpdate={handleCommentEditorChange} placeholder="Write a comment"
|
||||
editable={true} autofocus={true}
|
||||
/>
|
||||
<CommentActions onSave={handleAddComment} isLoading={isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentDialog;
|
||||
52
client/src/features/comment/components/comment-editor.tsx
Normal file
52
client/src/features/comment/components/comment-editor.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import React from 'react';
|
||||
import classes from './comment.module.css';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
}
|
||||
|
||||
function CommentEditor({ defaultContent, onUpdate, editable, placeholder, autofocus }: CommentEditorProps) {
|
||||
const { ref: focusRef, focused } = useFocusWithin();
|
||||
|
||||
const commentEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || 'Reply...',
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
],
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
autofocus: (autofocus && 'end') || false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={focusRef} className={classes.commentEditor}>
|
||||
<EditorContent editor={commentEditor}
|
||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default CommentEditor;
|
||||
111
client/src/features/comment/components/comment-list-item.tsx
Normal file
111
client/src/features/comment/components/comment-list-item.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Group, Avatar, Text, Box } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
import classes from './comment.module.css';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { deleteCommentAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { timeAgo } from '@/lib/time-ago';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import { editorAtom } from '@/features/editor/atoms/editorAtom';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import CommentMenu from '@/features/comment/components/comment-menu';
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import ResolveComment from '@/features/comment/components/resolve-comment';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
|
||||
function CommentListItem({ comment }) {
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const editor = useAtomValue(editorAtom);
|
||||
const [content, setContent] = useState(comment.content);
|
||||
const { updateCommentMutation, deleteCommentMutation } = useComment();
|
||||
const { isLoading } = updateCommentMutation;
|
||||
const [, deleteComment] = useAtom(deleteCommentAtom(comment.pageId));
|
||||
|
||||
async function handleUpdateComment() {
|
||||
const commentToUpdate = {
|
||||
id: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update comment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
deleteComment(comment.id); // Todo: unify code
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
{comment.creator.avatarUrl ? (
|
||||
<Avatar
|
||||
src={comment.creator.avatarUrl}
|
||||
alt={comment.creator.name}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>) : (
|
||||
<Avatar size="sm" color="blue">{comment.creator.name.charAt(0)}</Avatar>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{comment.creator.name}</Text>
|
||||
|
||||
<div style={{ visibility: hovered ? 'visible' : 'hidden' }}>
|
||||
{!comment.parentCommentId && (
|
||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||
)}
|
||||
|
||||
<CommentMenu commentId={comment.id}
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment} />
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
{timeAgo(comment.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection &&
|
||||
<Box className={classes.textSelection}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
!isEditing ?
|
||||
(<CommentEditor defaultContent={content} editable={false} />)
|
||||
:
|
||||
(<>
|
||||
<CommentEditor defaultContent={content} editable={true} onUpdate={(newContent) => setContent(newContent)}
|
||||
autofocus={true} />
|
||||
|
||||
<CommentActions onSave={handleUpdateComment} isLoading={isLoading} />
|
||||
</>)
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
96
client/src/features/comment/components/comment-list.tsx
Normal file
96
client/src/features/comment/components/comment-list.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { Divider, Paper, ScrollArea } from '@mantine/core';
|
||||
import CommentEditor from '@/features/comment/components/comment-editor';
|
||||
import CommentActions from '@/features/comment/components/comment-actions';
|
||||
import React, { useState } from 'react';
|
||||
import CommentListItem from '@/features/comment/components/comment-list-item';
|
||||
import { IComment } from '@/features/comment/types/comment.types';
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useFocusWithin } from '@mantine/hooks';
|
||||
|
||||
function CommentList({ comments }: IComment[]) {
|
||||
const { createCommentMutation } = useComment();
|
||||
const { isLoading } = createCommentMutation;
|
||||
const { pageId } = useParams();
|
||||
const [, setCommentsAtom] = useAtom(commentsAtom(pageId));
|
||||
|
||||
const getChildComments = (parentId) => {
|
||||
return comments.filter(comment => comment.parentCommentId === parentId);
|
||||
};
|
||||
|
||||
const renderChildComments = (parentId) => {
|
||||
const children = getChildComments(parentId);
|
||||
return (
|
||||
<div>
|
||||
{children.map(childComment => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
{renderChildComments(childComment.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState('');
|
||||
const { ref, focused } = useFocusWithin();
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(commentId, content);
|
||||
setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor onUpdate={setContent} editable={true} />
|
||||
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderComments = (comment) => {
|
||||
const handleAddReply = async (commentId, content) => {
|
||||
const commentData = {
|
||||
pageId: comment.pageId,
|
||||
parentCommentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
const createdComment = await createCommentMutation.mutateAsync(commentData);
|
||||
setCommentsAtom(prevComments => [...prevComments, createdComment]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder
|
||||
key={comment.id} data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
{renderChildComments(comment.id)}
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions onSave={handleAddReply} isLoading={isLoading} />
|
||||
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: '85vh' }} scrollbarSize={4} type="scroll">
|
||||
<div style={{ paddingBottom: '200px' }}>
|
||||
{comments
|
||||
.filter(comment => comment.parentCommentId === null)
|
||||
.map(comment => renderComments(comment))
|
||||
}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentList;
|
||||
45
client/src/features/comment/components/comment-menu.tsx
Normal file
45
client/src/features/comment/components/comment-menu.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
type CommentMenuProps = {
|
||||
onEditComment: () => void;
|
||||
onDeleteComment: () => void;
|
||||
};
|
||||
|
||||
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
|
||||
|
||||
//@ts-ignore
|
||||
const openDeleteModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to delete this comment?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: onDeleteComment,
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: 'none' }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onEditComment}
|
||||
leftSection={<IconEdit size={14} />}>
|
||||
Edit comment
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />}
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
Delete comment
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentMenu;
|
||||
45
client/src/features/comment/components/comment.module.css
Normal file
45
client/src/features/comment/components/comment.module.css
Normal file
@ -0,0 +1,45 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
49
client/src/features/comment/components/resolve-comment.tsx
Normal file
49
client/src/features/comment/components/resolve-comment.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
|
||||
import useComment from '@/features/comment/hooks/use-comment';
|
||||
import { useAtom } from 'jotai';
|
||||
import { commentsAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { modals } from '@mantine/modals';
|
||||
|
||||
function ResolveComment({ commentId, pageId, resolvedAt }) {
|
||||
const [, setComments] = useAtom(commentsAtom(pageId));
|
||||
const { resolveCommentMutation } = useComment();
|
||||
const isResolved = resolvedAt != null;
|
||||
const iconColor = isResolved ? 'green' : 'gray';
|
||||
|
||||
//@ts-ignore
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure you want to resolve this comment thread?',
|
||||
centered: true,
|
||||
labels: { confirm: 'Confirm', cancel: 'Cancel' },
|
||||
onConfirm: handleResolveToggle,
|
||||
});
|
||||
|
||||
const handleResolveToggle = async () => {
|
||||
try {
|
||||
const resolvedComment = await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
|
||||
//TODO: remove comment mark
|
||||
// Remove comment thread from state on resolve
|
||||
|
||||
setComments((oldComments) =>
|
||||
oldComments.map((comment) =>
|
||||
comment.id === commentId
|
||||
? { ...comment, resolvedAt: resolvedComment.resolvedAt, resolvedById: resolvedComment.resolvedById }
|
||||
: comment,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle resolved state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
|
||||
<IconCircleCheck size={20} stroke={2} color={iconColor} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResolveComment;
|
||||
64
client/src/features/comment/hooks/use-comment.ts
Normal file
64
client/src/features/comment/hooks/use-comment.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
} from '@/features/comment/services/comment-service';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export default function useComment() {
|
||||
|
||||
const createMutation = useMutation(
|
||||
(data: Partial<IComment>) => createComment(data),
|
||||
{
|
||||
onSuccess: (data: IComment) => {
|
||||
toast.success('Comment created successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error creating comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updateMutation = useMutation(
|
||||
(data: Partial<IComment>) => updateComment(data),
|
||||
{
|
||||
onSuccess: (data: IComment) => {
|
||||
toast.success('Comment updated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error updating comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const resolveMutation = useMutation(
|
||||
(data: IResolveComment) => resolveComment(data),
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to perform resolve action: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
(id: string) => deleteComment(id),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Comment deleted successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error deleting comment: ${error.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
createCommentMutation: createMutation,
|
||||
updateCommentMutation: updateMutation,
|
||||
resolveCommentMutation: resolveMutation,
|
||||
deleteCommentMutation: deleteMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
31
client/src/features/comment/services/comment-service.ts
Normal file
31
client/src/features/comment/services/comment-service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
|
||||
|
||||
export async function createComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/create', data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/resolve`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function updateComment(data: Partial<IComment>): Promise<IComment> {
|
||||
const req = await api.post<IComment>(`/comments/update`, data);
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getCommentById(id: string): Promise<IComment> {
|
||||
const req = await api.post<IComment>('/comments/view', { id });
|
||||
return req.data as IComment;
|
||||
}
|
||||
|
||||
export async function getPageComments(pageId: string): Promise<IComment[]> {
|
||||
const req = await api.post<IComment[]>('/comments', { pageId });
|
||||
return req.data as IComment[];
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
await api.post('/comments/delete', { id });
|
||||
}
|
||||
31
client/src/features/comment/types/comment.types.ts
Normal file
31
client/src/features/comment/types/comment.types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IUser } from '@/features/user/types/user.types';
|
||||
|
||||
export interface IComment {
|
||||
id: string;
|
||||
content: string;
|
||||
selection?: string;
|
||||
type?: string;
|
||||
creatorId: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
resolvedById?: string;
|
||||
resolvedAt?: Date;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
creator: IUser
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
id: string;
|
||||
pageId: string;
|
||||
parentCommentId?: string;
|
||||
content: any;
|
||||
selection?: string;
|
||||
}
|
||||
|
||||
export interface IResolveComment {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
19
client/src/features/comment/utils.ts
Normal file
19
client/src/features/comment/utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function scrollToComment(commentId) {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
if (commentElement) {
|
||||
commentElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollToCommentInScrollArea = (commentId, scrollAreaRef) => {
|
||||
const commentElement = scrollAreaRef.current.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
|
||||
if (commentElement) {
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
const commentTop = commentElement.offsetTop;
|
||||
const scrollAreaTop = scrollArea.offsetTop;
|
||||
|
||||
scrollArea.scrollTop = commentTop - scrollAreaTop;
|
||||
}
|
||||
};
|
||||
4
client/src/features/editor/atoms/editorAtom.ts
Normal file
4
client/src/features/editor/atoms/editorAtom.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { atom } from 'jotai';
|
||||
import { Editor } from '@tiptap/core';
|
||||
|
||||
export const editorAtom = atom<Editor | null>(null);
|
||||
@ -1,11 +1,14 @@
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from '@tiptap/react';
|
||||
import { FC, useState } from 'react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline } from '@tabler/icons-react';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline, IconMessage } from '@tabler/icons-react';
|
||||
import clsx from 'clsx';
|
||||
import classes from './bubble-menu.module.css';
|
||||
import { ActionIcon, rem, Tooltip } from '@mantine/core';
|
||||
import { ColorSelector } from './color-selector';
|
||||
import { NodeSelector } from './node-selector';
|
||||
import { draftCommentIdAtom, showCommentPopupAtom } from '@/features/comment/atoms/comment-atom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@ -17,6 +20,9 @@ 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 items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
@ -50,6 +56,20 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
name: 'comment',
|
||||
isActive: () => props.editor.isActive('comment'),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
setShowCommentPopup(true);
|
||||
},
|
||||
icon: IconMessage,
|
||||
};
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
@ -92,7 +112,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={item.name}>
|
||||
<Tooltip key={index} label={item.name} withArrow>
|
||||
|
||||
<ActionIcon key={index} variant="default" size="lg" radius="0" aria-label={item.name}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
@ -115,6 +135,15 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip label={commentItem.name} withArrow>
|
||||
|
||||
<ActionIcon variant="default" size="lg" radius="0" aria-label={commentItem.name}
|
||||
style={{ border: 'none' }}
|
||||
onClick={commentItem.command}>
|
||||
<IconMessage style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,40 +1,29 @@
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import classes from '@/features/editor/styles/editor.module.css';
|
||||
import '@/features/editor/styles/index.css';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { EditorBubbleMenu } from '@/features/editor/components/bubble-menu/bubble-menu';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import SlashCommand from '@/features/editor/extensions/slash-command';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
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 CommentDialog from '@/features/comment/components/comment-dialog';
|
||||
import { editorAtom } from '@/features/editor/atoms/editorAtom';
|
||||
import { collabExtensions, mainExtensions } from '@/features/editor/extensions';
|
||||
|
||||
interface EditorProps {
|
||||
pageId: string,
|
||||
@ -100,10 +89,14 @@ 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 } = usePage();
|
||||
const [desktopAsideOpened, setDesktopAsideOpened] = useAtom<boolean>(desktopAsideAtom);
|
||||
const [activeCommentId, setActiveCommentId] = useAtom<string | null>(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom<boolean>(showCommentPopupAtom);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -133,46 +126,20 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== "") {
|
||||
if (debouncedTitle !== '') {
|
||||
updatePageMutation({ id: pageId, title: debouncedTitle });
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
...mainExtensions,
|
||||
...collabExtensions(ydoc, provider),
|
||||
Comment.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'comment-mark',
|
||||
},
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
|
||||
];
|
||||
|
||||
const editor = useEditor({
|
||||
@ -190,6 +157,11 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
},
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
setEditor(editor);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
const { selection } = editor.state;
|
||||
if (!selection.empty) {
|
||||
@ -225,6 +197,29 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
const { commentId } = event.detail;
|
||||
setActiveCommentId(commentId);
|
||||
setDesktopAsideOpened(true);
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
return () => {
|
||||
document.removeEventListener('ACTIVE_COMMENT_EVENT', handleActiveCommentEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCommentId(null);
|
||||
setDesktopAsideOpened(false);
|
||||
setShowCommentPopup(false);
|
||||
}, [pageId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.editor}>
|
||||
@ -232,6 +227,10 @@ function TiptapEditor({ ydoc, provider, pageId }: TiptapEditorProps) {
|
||||
<EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
61
client/src/features/editor/extensions.ts
Normal file
61
client/src/features/editor/extensions.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TrailingNode } from '@/features/editor/extensions/trailing-node';
|
||||
import DragAndDrop from '@/features/editor/extensions/drag-handle';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
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 * as Y from 'yjs';
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: '#70CFF8',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter "/" for commands',
|
||||
}),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
Superscript,
|
||||
SubScript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
DragAndDrop,
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
];
|
||||
|
||||
type CollabExtensions = (ydoc: Y.Doc, provider: any) => any[];
|
||||
|
||||
export const collabExtensions: CollabExtensions = (ydoc, provider) => [
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
@ -0,0 +1,35 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { commentDecorationMetaKey, commentMarkClass } from '@/features/editor/extensions/comment/comment';
|
||||
|
||||
export function commentDecoration(): Plugin {
|
||||
const commentDecorationPlugin = new PluginKey('commentDecoration');
|
||||
|
||||
return new Plugin({
|
||||
key: commentDecorationPlugin,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, oldSet) {
|
||||
const decorationMeta = tr.getMeta(commentDecorationMetaKey);
|
||||
|
||||
if (decorationMeta) {
|
||||
const { from, to } = tr.selection;
|
||||
const decoration = Decoration.inline(from, to, { class: commentMarkClass });
|
||||
return DecorationSet.create(tr.doc, [decoration]);
|
||||
} else if (decorationMeta === false) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
return oldSet.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
return commentDecorationPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
136
client/src/features/editor/extensions/comment/comment.ts
Normal file
136
client/src/features/editor/extensions/comment/comment.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
import { commentDecoration } from '@/features/editor/extensions/comment/comment-decoration';
|
||||
|
||||
export interface ICommentOptions {
|
||||
HTMLAttributes: Record<string, any>,
|
||||
}
|
||||
|
||||
export interface ICommentStorage {
|
||||
activeCommentId: string | null;
|
||||
}
|
||||
|
||||
export const commentMarkClass = 'comment-mark';
|
||||
export const commentDecorationMetaKey = 'decorateComment';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
comment: {
|
||||
setCommentDecoration: () => ReturnType,
|
||||
unsetCommentDecoration: () => ReturnType,
|
||||
setComment: (commentId: string) => ReturnType,
|
||||
unsetComment: (commentId: string) => ReturnType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
|
||||
name: 'comment',
|
||||
exitable: true,
|
||||
inclusive: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
activeCommentId: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-comment-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.commentId) return;
|
||||
|
||||
return {
|
||||
'data-comment-id': attributes.commentId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-comment-id]',
|
||||
getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, true);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
unsetCommentDecoration: () => ({ tr, dispatch }) => {
|
||||
tr.setMeta(commentDecorationMetaKey, false);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
setComment: (commentId) => ({ commands }) => {
|
||||
if (!commentId) return false;
|
||||
return commands.setMark(this.name, { commentId });
|
||||
},
|
||||
unsetComment:
|
||||
(commentId) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (!commentId) return false;
|
||||
|
||||
tr.doc.descendants((node, pos) => {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
|
||||
const commentMark = node.marks.find(mark =>
|
||||
mark.type.name === this.name && mark.attrs.commentId === commentId);
|
||||
|
||||
if (commentMark) {
|
||||
tr = tr.removeMark(from, to, commentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return dispatch?.(tr);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const commentId = HTMLAttributes?.['data-comment-id'] || null;
|
||||
const elem = document.createElement('span');
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val));
|
||||
|
||||
elem.addEventListener('click', (e) => {
|
||||
const selection = document.getSelection();
|
||||
if (selection.type === 'Range') return;
|
||||
|
||||
this.storage.activeCommentId = commentId;
|
||||
const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', {
|
||||
bubbles: true,
|
||||
detail: { commentId },
|
||||
});
|
||||
|
||||
elem.dispatchEvent(commentEventClick);
|
||||
});
|
||||
|
||||
return elem;
|
||||
},
|
||||
|
||||
addProseMirrorPlugins(): Plugin[] {
|
||||
// @ts-ignore
|
||||
return [commentDecoration()];
|
||||
},
|
||||
|
||||
},
|
||||
);
|
||||
@ -94,3 +94,7 @@
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
background: rgba(0,203,15,0.2);
|
||||
border-bottom: 2px solid #0ca678;
|
||||
}
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { createPage, deletePage, getPageById, updatePage } from '@/features/page/services/page-service';
|
||||
import { IPage } from '@/features/page/types/page.types';
|
||||
import { useAtom } from 'jotai/index';
|
||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
||||
|
||||
export default function usePage(pageId?: string) {
|
||||
const [page, setPage] = useAtom(pageAtom<IPage>(pageId));
|
||||
|
||||
const createMutation = useMutation(
|
||||
(data: Partial<IPage>) => createPage(data),
|
||||
);
|
||||
@ -21,11 +17,6 @@ export default function usePage(pageId?: string) {
|
||||
|
||||
const updateMutation = useMutation(
|
||||
(data: Partial<IPage>) => updatePage(data),
|
||||
{
|
||||
onSuccess: (updatedPageData) => {
|
||||
setPage(updatedPageData);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const removeMutation = useMutation(
|
||||
@ -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 usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
|
||||
export function usePersistence<T>() {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
|
||||
@ -56,7 +56,7 @@ export default function PageTree() {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
}, 200);
|
||||
}, [tree]);
|
||||
}, [tree, pageId]);
|
||||
|
||||
return (
|
||||
<div ref={rootElement} className={classes.treeContainer}>
|
||||
|
||||
5
client/src/lib/time-ago.ts
Normal file
5
client/src/lib/time-ago.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { formatDistanceStrict } from 'date-fns';
|
||||
|
||||
export function timeAgo(date: Date){
|
||||
return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true })
|
||||
}
|
||||
@ -9,16 +9,19 @@ import { MantineProvider } from '@mantine/core';
|
||||
import { TanstackProvider } from '@/components/providers/tanstack-provider';
|
||||
import CustomToaster from '@/components/ui/custom-toaster';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme}>
|
||||
<ModalsProvider>
|
||||
<TanstackProvider>
|
||||
<App />
|
||||
<CustomToaster />
|
||||
</TanstackProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai/index';
|
||||
import usePage from '@/features/page/hooks/usePage';
|
||||
import usePage from '@/features/page/hooks/use-page';
|
||||
import Editor from '@/features/editor/editor';
|
||||
import { pageAtom } from '@/features/page/atoms/page-atom';
|
||||
|
||||
|
||||
75
server/src/core/comment/comment.controller.ts
Normal file
75
server/src/core/comment/comment.controller.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { JwtGuard } from '../auth/guards/JwtGuard';
|
||||
import { CommentsInput, SingleCommentInput } from './dto/comments.input';
|
||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||
import { WorkspaceService } from '../workspace/services/workspace.service';
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@Controller('comments')
|
||||
export class CommentController {
|
||||
constructor(
|
||||
private readonly commentService: CommentService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Req() req: FastifyRequest,
|
||||
@Body() createCommentDto: CreateCommentDto,
|
||||
) {
|
||||
const jwtPayload = req['user'];
|
||||
const userId = jwtPayload.sub;
|
||||
|
||||
const workspaceId = (
|
||||
await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub)
|
||||
).id;
|
||||
return this.commentService.create(userId, workspaceId, createCommentDto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
findPageComments(@Body() input: CommentsInput) {
|
||||
return this.commentService.findByPageId(input.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('view')
|
||||
findOne(@Body() input: SingleCommentInput) {
|
||||
return this.commentService.findWithCreator(input.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
update(@Body() updateCommentDto: UpdateCommentDto) {
|
||||
return this.commentService.update(updateCommentDto.id, updateCommentDto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('resolve')
|
||||
resolve(
|
||||
@Req() req: FastifyRequest,
|
||||
@Body() resolveCommentDto: ResolveCommentDto,
|
||||
) {
|
||||
const userId = req['user'].sub;
|
||||
return this.commentService.resolveComment(userId, resolveCommentDto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
remove(@Body() input: SingleCommentInput) {
|
||||
return this.commentService.remove(input.id);
|
||||
}
|
||||
}
|
||||
22
server/src/core/comment/comment.module.ts
Normal file
22
server/src/core/comment/comment.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
import { CommentRepository } from './repositories/comment.repository';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { PageModule } from '../page/page.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Comment]),
|
||||
AuthModule,
|
||||
WorkspaceModule,
|
||||
PageModule,
|
||||
],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService, CommentRepository],
|
||||
exports: [CommentService, CommentRepository],
|
||||
})
|
||||
export class CommentModule {}
|
||||
18
server/src/core/comment/comment.service.spec.ts
Normal file
18
server/src/core/comment/comment.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CommentService } from './comment.service';
|
||||
|
||||
describe('CommentService', () => {
|
||||
let service: CommentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CommentService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CommentService>(CommentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
122
server/src/core/comment/comment.service.ts
Normal file
122
server/src/core/comment/comment.service.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { CommentRepository } from './repositories/comment.repository';
|
||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||
import { PageService } from '../page/services/page.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
constructor(
|
||||
private commentRepository: CommentRepository,
|
||||
private pageService: PageService,
|
||||
) {}
|
||||
|
||||
async findWithCreator(commentId: string) {
|
||||
return await this.commentRepository.findOne({
|
||||
where: { id: commentId },
|
||||
relations: ['creator'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
createCommentDto: CreateCommentDto,
|
||||
) {
|
||||
const comment = plainToInstance(Comment, createCommentDto);
|
||||
comment.creatorId = userId;
|
||||
comment.workspaceId = workspaceId;
|
||||
comment.content = JSON.parse(createCommentDto.content);
|
||||
|
||||
if (createCommentDto.selection) {
|
||||
comment.selection = createCommentDto.selection.substring(0, 250);
|
||||
}
|
||||
|
||||
const page = await this.pageService.findWithBasic(createCommentDto.pageId);
|
||||
if (!page) {
|
||||
throw new BadRequestException('Page not found');
|
||||
}
|
||||
|
||||
if (createCommentDto.parentCommentId) {
|
||||
const parentComment = await this.commentRepository.findOne({
|
||||
where: { id: createCommentDto.parentCommentId },
|
||||
select: ['id', 'parentCommentId'],
|
||||
});
|
||||
|
||||
if (!parentComment) {
|
||||
throw new BadRequestException('Parent comment not found');
|
||||
}
|
||||
|
||||
if (parentComment.parentCommentId !== null) {
|
||||
throw new BadRequestException('You cannot reply to a reply');
|
||||
}
|
||||
}
|
||||
|
||||
const savedComment = await this.commentRepository.save(comment);
|
||||
return this.findWithCreator(savedComment.id);
|
||||
}
|
||||
|
||||
async findByPageId(pageId: string, offset = 0, limit = 100) {
|
||||
const comments = this.commentRepository.find({
|
||||
where: {
|
||||
pageId: pageId,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
relations: ['creator'],
|
||||
});
|
||||
return comments;
|
||||
}
|
||||
|
||||
async update(
|
||||
commentId: string,
|
||||
updateCommentDto: UpdateCommentDto,
|
||||
): Promise<Comment> {
|
||||
updateCommentDto.content = JSON.parse(updateCommentDto.content);
|
||||
|
||||
const result = await this.commentRepository.update(commentId, {
|
||||
...updateCommentDto,
|
||||
editedAt: new Date(),
|
||||
});
|
||||
if (result.affected === 0) {
|
||||
throw new BadRequestException(`Comment not found`);
|
||||
}
|
||||
|
||||
return this.findWithCreator(commentId);
|
||||
}
|
||||
|
||||
async resolveComment(
|
||||
userId: string,
|
||||
resolveCommentDto: ResolveCommentDto,
|
||||
): Promise<Comment> {
|
||||
const resolvedAt = resolveCommentDto.resolved ? new Date() : null;
|
||||
const resolvedById = resolveCommentDto.resolved ? userId : null;
|
||||
|
||||
const result = await this.commentRepository.update(
|
||||
resolveCommentDto.commentId,
|
||||
{
|
||||
resolvedAt,
|
||||
resolvedById,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.affected === 0) {
|
||||
throw new BadRequestException(`Comment not found`);
|
||||
}
|
||||
|
||||
return this.findWithCreator(resolveCommentDto.commentId);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const result = await this.commentRepository.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new BadRequestException(`Comment with ID ${id} not found.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
server/src/core/comment/dto/comments.input.ts
Normal file
11
server/src/core/comment/dto/comments.input.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class CommentsInput {
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class SingleCommentInput {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
21
server/src/core/comment/dto/create-comment.dto.ts
Normal file
21
server/src/core/comment/dto/create-comment.dto.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
|
||||
@IsJSON()
|
||||
content: any;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selection: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentCommentId: string;
|
||||
}
|
||||
9
server/src/core/comment/dto/resolve-comment.dto.ts
Normal file
9
server/src/core/comment/dto/resolve-comment.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class ResolveCommentDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
|
||||
@IsBoolean()
|
||||
resolved: boolean;
|
||||
}
|
||||
9
server/src/core/comment/dto/update-comment.dto.ts
Normal file
9
server/src/core/comment/dto/update-comment.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsJSON, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsJSON()
|
||||
content: any;
|
||||
}
|
||||
82
server/src/core/comment/entities/comment.entity.ts
Normal file
82
server/src/core/comment/entities/comment.entity.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
DeleteDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Page } from '../../page/entities/page.entity';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
|
||||
@Entity('comments')
|
||||
export class Comment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
content: any;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
selection: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 55, nullable: true })
|
||||
type: string;
|
||||
|
||||
@Column()
|
||||
creatorId: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.comments)
|
||||
@JoinColumn({ name: 'creatorId' })
|
||||
creator: User;
|
||||
|
||||
@Column()
|
||||
pageId: string;
|
||||
|
||||
@ManyToOne(() => Page, (page) => page.comments, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'pageId' })
|
||||
page: Page;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
parentCommentId: string;
|
||||
|
||||
@ManyToOne(() => Comment, (comment) => comment.replies, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'parentCommentId' })
|
||||
parentComment: Comment;
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.parentComment)
|
||||
replies: Comment[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
resolvedById: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'resolvedById' })
|
||||
resolvedBy: User;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
resolvedAt: Date;
|
||||
|
||||
@Column()
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.comments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Workspace;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
editedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ nullable: true })
|
||||
deletedAt: Date;
|
||||
}
|
||||
14
server/src/core/comment/repositories/comment.repository.ts
Normal file
14
server/src/core/comment/repositories/comment.repository.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Comment } from '../entities/comment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CommentRepository extends Repository<Comment> {
|
||||
constructor(private dataSource: DataSource) {
|
||||
super(Comment, dataSource.createEntityManager());
|
||||
}
|
||||
|
||||
async findById(commentId: string) {
|
||||
return this.findOneBy({ id: commentId });
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { PageModule } from './page/page.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { CommentModule } from './comment/comment.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -17,6 +18,7 @@ import { EnvironmentModule } from '../environment/environment.module';
|
||||
imports: [EnvironmentModule],
|
||||
}),
|
||||
AttachmentModule,
|
||||
CommentModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
import { Comment } from '../../comment/entities/comment.entity';
|
||||
|
||||
@Entity('pages')
|
||||
export class Page {
|
||||
@ -68,7 +69,7 @@ export class Page {
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
publishedAt: string;
|
||||
publishedAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
@ -85,4 +86,7 @@ export class Page {
|
||||
|
||||
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
|
||||
childPages: Page[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.page)
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
@ -33,7 +33,6 @@ export class PageRepository extends Repository<Page> {
|
||||
'page.createdAt',
|
||||
'page.updatedAt',
|
||||
'page.deletedAt',
|
||||
'page.children',
|
||||
])
|
||||
.getOne();
|
||||
}
|
||||
|
||||
@ -24,6 +24,13 @@ export class PageService {
|
||||
private pageOrderingService: PageOrderingService,
|
||||
) {}
|
||||
|
||||
async findWithBasic(pageId: string) {
|
||||
return this.pageRepository.findOne({
|
||||
where: { id: pageId },
|
||||
select: ['id', 'title'],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(pageId: string) {
|
||||
return this.pageRepository.findById(pageId);
|
||||
}
|
||||
|
||||
@ -3,8 +3,6 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
@ -13,6 +11,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity';
|
||||
import { Page } from '../../page/entities/page.entity';
|
||||
import { Comment } from '../../comment/entities/comment.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@ -64,6 +63,9 @@ export class User {
|
||||
@OneToMany(() => Page, (page) => page.creator)
|
||||
createdPages: Page[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.creator)
|
||||
comments: Comment[];
|
||||
|
||||
toJSON() {
|
||||
delete this.password;
|
||||
return this;
|
||||
|
||||
@ -12,6 +12,7 @@ import { User } from '../../user/entities/user.entity';
|
||||
import { WorkspaceUser } from './workspace-user.entity';
|
||||
import { Page } from '../../page/entities/page.entity';
|
||||
import { WorkspaceInvitation } from './workspace-invitation.entity';
|
||||
import { Comment } from '../../comment/entities/comment.entity';
|
||||
|
||||
@Entity('workspaces')
|
||||
export class Workspace {
|
||||
@ -66,4 +67,7 @@ export class Workspace {
|
||||
|
||||
@OneToMany(() => Page, (page) => page.workspace)
|
||||
pages: Page[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.workspace)
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user