switch to nx monorepo

This commit is contained in:
Philipinho
2024-01-09 18:58:26 +01:00
parent e1bb2632b8
commit 093e634c0b
273 changed files with 11419 additions and 31 deletions

View File

@ -0,0 +1,5 @@
import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');

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,
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 { useCreateCommentMutation } from '@/features/comment/queries/comment-query';
import { asideStateAtom } from '@/components/navbar/atoms/sidebar-atom';
interface CommentDialogProps {
editor: Editor,
pageId: string,
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [comment, setComment] = useState('');
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
const { isPending } = 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 () => {
try {
const selectedText = getSelectedText();
const commentData = {
id: draftCommentId,
pageId: pageId,
content: JSON.stringify(comment),
selection: selectedText,
};
const createdComment = await createCommentMutation.mutateAsync(commentData);
editor.chain().setComment(createdComment.id).unsetCommentDecoration().run();
setActiveCommentId(createdComment.id);
setAsideState({ tab: 'comments', isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView();
});
} finally {
setShowCommentPopup(false);
setDraftCommentId('');
}
};
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={isPending}
/>
</Stack>
</Dialog>
);
}
export default CommentDialog;

View File

@ -0,0 +1,58 @@
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 classes from './comment.module.css';
import { useFocusWithin } from '@mantine/hooks';
import clsx from 'clsx';
import { forwardRef, useImperativeHandle } from 'react';
interface CommentEditorProps {
defaultContent?: any;
onUpdate?: any;
editable: boolean;
placeholder?: string;
autofocus?: boolean;
}
const CommentEditor = forwardRef(({ defaultContent, onUpdate, editable, placeholder, autofocus }: CommentEditorProps,
ref) => {
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,
});
useImperativeHandle(ref, () => ({
clearContent: () => {
commentEditor.commands.clearContent();
},
}));
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,110 @@
import { Group, Text, Box } from '@mantine/core';
import React, { useState } from 'react';
import classes from './comment.module.css';
import { useAtomValue } from 'jotai';
import { timeAgo } from '@/lib/time';
import CommentEditor from '@/features/comment/components/comment-editor';
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms';
import CommentActions from '@/features/comment/components/comment-actions';
import CommentMenu from '@/features/comment/components/comment-menu';
import { useHover } from '@mantine/hooks';
import { useDeleteCommentMutation, useUpdateCommentMutation } from '@/features/comment/queries/comment-query';
import { IComment } from '@/features/comment/types/comment.types';
import { UserAvatar } from '@/components/ui/user-avatar';
interface CommentListItemProps {
comment: IComment;
}
function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState(comment.content);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
async function handleUpdateComment() {
try {
setIsLoading(true);
const commentToUpdate = {
id: comment.id,
content: JSON.stringify(content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false);
} catch (error) {
console.error('Failed to update comment:', error);
} finally {
setIsLoading(false);
}
}
async function handleDeleteComment() {
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
} catch (error) {
console.error('Failed to delete comment:', error);
}
}
function handleEditToggle() {
setIsEditing(true);
}
return (
<Box ref={ref} pb="xs">
<Group>
<UserAvatar color="blue" size="sm" avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
<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 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,105 @@
import React, { useState, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Divider, Paper } from '@mantine/core';
import CommentListItem from '@/features/comment/components/comment-list-item';
import { useCommentsQuery, useCreateCommentMutation } from '@/features/comment/queries/comment-query';
import CommentEditor from '@/features/comment/components/comment-editor';
import CommentActions from '@/features/comment/components/comment-actions';
import { useFocusWithin } from '@mantine/hooks';
function CommentList() {
const { pageId } = useParams();
const { data: comments, isLoading: isCommentsLoading, isError } = useCommentsQuery(pageId);
const [isLoading, setIsLoading] = useState(false);
const createCommentMutation = useCreateCommentMutation();
if (isCommentsLoading) {
return <></>;
}
if (isError) {
return <div>Error loading comments.</div>;
}
if (!comments || comments.length === 0) {
return <>No comments yet.</>;
}
const renderComments = (comment) => {
const handleAddReply = async (commentId, content) => {
try {
setIsLoading(true);
const commentData = {
pageId: comment.pageId,
parentCommentId: comment.id,
content: JSON.stringify(content),
};
await createCommentMutation.mutateAsync(commentData);
} catch (error) {
console.error('Failed to post comment:', error);
} finally {
setIsLoading(false);
}
};
return (
<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder key={comment.id} data-comment-id={comment.id}>
<div>
<CommentListItem comment={comment} />
<ChildComments comments={comments} parentId={comment.id} />
</div>
<Divider my={4} />
<CommentEditorWithActions commentId={comment.id} onSave={handleAddReply} isLoading={isLoading} />
</Paper>
);
};
return (
<>
{comments.filter(comment => comment.parentCommentId === null).map(renderComments)}
</>
);
}
const ChildComments = ({ comments, parentId }) => {
const getChildComments = (parentId) => {
return comments.filter(comment => comment.parentCommentId === parentId);
};
return (
<div>
{getChildComments(parentId).map(childComment => (
<div key={childComment.id}>
<CommentListItem comment={childComment} />
<ChildComments comments={comments} parentId={childComment.id} />
</div>
))}
</div>
);
};
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState('');
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = () => {
onSave(commentId, content);
setContent('');
commentEditorRef.current?.clearContent();
};
return (
<div ref={ref}>
<CommentEditor ref={commentEditorRef} onUpdate={setContent} editable={true} />
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
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,37 @@
import { ActionIcon } from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { useResolveCommentMutation } from '@/features/comment/queries/comment-query';
function ResolveComment({ commentId, pageId, resolvedAt }) {
const resolveCommentMutation = useResolveCommentMutation();
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 {
await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
//TODO: remove comment mark
// Remove comment thread from state on resolve
} 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,96 @@
import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import {
createComment,
deleteComment, getPageComments,
resolveComment,
updateComment,
} from '@/features/comment/services/comment-service';
import { IComment, IResolveComment } from '@/features/comment/types/comment.types';
import { notifications } from '@mantine/notifications';
export const RQ_KEY = (pageId: string) => ['comments', pageId];
export function useCommentsQuery(pageId: string): UseQueryResult<IComment[], Error> {
return useQuery({
queryKey: RQ_KEY(pageId),
queryFn: () => getPageComments(pageId),
enabled: !!pageId,
});
}
export function useCreateCommentMutation() {
const queryClient = useQueryClient();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
onSuccess: (data) => {
const newComment = data;
let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
if (comments) {
comments = prevComments => [...prevComments, newComment];
queryClient.setQueryData(RQ_KEY(data.pageId), comments);
}
notifications.show({ message: 'Comment created successfully' });
},
onError: (error) => {
notifications.show({ message: 'Error creating comment', color: 'red' });
},
});
}
export function useUpdateCommentMutation() {
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => updateComment(data),
onSuccess: (data) => {
notifications.show({ message: 'Comment updated successfully' });
},
onError: (error) => {
notifications.show({ message: 'Failed to update comment', color: 'red' });
},
});
}
export function useDeleteCommentMutation(pageId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: (data, variables) => {
let comments = queryClient.getQueryData(RQ_KEY(pageId)) as IComment[];
if (comments) {
comments = comments.filter(comment => comment.id !== variables);
queryClient.setQueryData(RQ_KEY(pageId), comments);
}
notifications.show({ message: 'Comment deleted successfully' });
},
onError: (error) => {
notifications.show({ message: 'Failed to delete comment', color: 'red' });
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onSuccess: (data: IComment, variables) => {
const currentComments = queryClient.getQueryData(RQ_KEY(data.pageId)) as IComment[];
if (currentComments) {
const updatedComments = currentComments.map((comment) =>
comment.id === variables.commentId ? { ...comment, ...data } : comment,
);
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}
notifications.show({ message: 'Comment resolved successfully' });
},
onError: (error) => {
notifications.show({ message: 'Failed to resolve comment', color: 'red' });
},
});
}

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