feat: comments

* create comment
* reply to comment thread
* edit comment
* delete comment
* resolve comment
This commit is contained in:
Philipinho
2023-11-09 16:52:34 +00:00
parent dea2cad89c
commit 4cb7a56f65
49 changed files with 1486 additions and 87 deletions

View File

@ -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",

View 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>
);
}

View File

@ -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 />
</>
);

View File

@ -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(

View File

@ -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>
);

View File

@ -1,3 +1,6 @@
import { atomWithWebStorage } from "@/lib/jotai-helper";
import { atom } from 'jotai';
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
export const desktopAsideAtom = atom(false);

View 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);
}
));

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 {
}
}

View 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;

View 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,
};
}

View 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 });
}

View 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;
}

View 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;
}
};

View File

@ -0,0 +1,4 @@
import { atom } from 'jotai';
import { Editor } from '@tiptap/core';
export const editorAtom = atom<Editor | null>(null);

View File

@ -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>
);
};

View File

@ -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} />
)}
</>
);
}

View 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,
}),
];

View File

@ -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);
},
},
});
}

View 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()];
},
},
);

View File

@ -94,3 +94,7 @@
cursor: col-resize;
}
.comment-mark {
background: rgba(0,203,15,0.2);
border-bottom: 2px solid #0ca678;
}

View File

@ -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(

View File

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

View File

@ -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}>

View File

@ -0,0 +1,5 @@
import { formatDistanceStrict } from 'date-fns';
export function timeAgo(date: Date){
return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true })
}

View File

@ -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>,
);

View File

@ -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';

View 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);
}
}

View 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 {}

View 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();
});
});

View 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.`);
}
}
}

View File

@ -0,0 +1,11 @@
import { IsUUID } from 'class-validator';
export class CommentsInput {
@IsUUID()
pageId: string;
}
export class SingleCommentInput {
@IsUUID()
id: string;
}

View 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;
}

View File

@ -0,0 +1,9 @@
import { IsBoolean, IsUUID } from 'class-validator';
export class ResolveCommentDto {
@IsUUID()
commentId: string;
@IsBoolean()
resolved: boolean;
}

View File

@ -0,0 +1,9 @@
import { IsJSON, IsUUID } from 'class-validator';
export class UpdateCommentDto {
@IsUUID()
id: string;
@IsJSON()
content: any;
}

View 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;
}

View 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 });
}
}

View File

@ -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 {}

View File

@ -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[];
}

View File

@ -33,7 +33,6 @@ export class PageRepository extends Repository<Page> {
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
'page.children',
])
.getOne();
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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[];
}