diff --git a/package.json b/package.json index baa15ae7..aa9a42d1 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,14 @@ "@radix-ui/react-tooltip": "^1.0.7", "@swc/helpers": "~0.5.0", "@tailwindcss/forms": "^0.5.3", + "@tiptap/extension-image": "^2.2.4", "@tiptap/extension-link": "^2.0.3", + "@tiptap/extension-mention": "^2.2.1", + "@tiptap/extension-underline": "^2.2.4", "@tiptap/pm": "^2.0.3", "@tiptap/react": "^2.0.3", "@tiptap/starter-kit": "^2.0.3", + "@tiptap/suggestion": "^2.2.1", "apexcharts": "^3.41.0", "axios": "^1.4.0", "bcryptjs": "^2.4.3", @@ -59,6 +63,7 @@ "firebase": "^10.7.1", "firebase-admin": "^12.0.0", "formik": "^2.4.1", + "fuse.js": "^7.0.0", "immer": "^10.0.2", "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.0", @@ -81,6 +86,7 @@ "react-icons": "^4.9.0", "read-excel-file": "^5.6.1", "resend": "^1.0.0", + "tippy.js": "^6.3.7", "tslib": "^2.3.0", "zod": "^3.21.4", "zustand": "^4.3.8" diff --git a/packages/be-gateway/src/routes/comment/index.ts b/packages/be-gateway/src/routes/comment/index.ts new file mode 100644 index 00000000..9b45be0c --- /dev/null +++ b/packages/be-gateway/src/routes/comment/index.ts @@ -0,0 +1,129 @@ +import { Request, Response } from 'express' +import { CommentRepository } from '@shared/models' +import { Comment } from '@prisma/client' +import { + BaseController, + Controller, + Res, + Req, + Body, + Next, + ExpressResponse, + Get, + Post, + Put, + Delete +} from '../../core' +import { pusherServer } from '../../lib/pusher-server' +import { AuthRequest } from '../../types' + +@Controller('/comment') +export default class TaskComment extends BaseController { + name: string + commentRepo: CommentRepository + constructor() { + super() + this.name = 'comment' + this.commentRepo = new CommentRepository() + } + + @Get('') + async getCommentByObjectId(@Res() res: Response, @Req() req: Request) { + const { taskId } = req.query as { taskId: string } + + try { + const results = await this.commentRepo.mdCommentGetAllByTask(taskId) + // results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : 0)) + res.json({ status: 200, data: results }) + } catch (error) { + res.json({ + status: 500, + err: error, + data: [] + }) + } + } + + @Post('') + createComment( + @Body() body: Omit, + @Res() res: ExpressResponse, + @Req() req: AuthRequest + ) { + this.commentRepo + .mdCommentAdd(body) + .then(result => { + const { taskId } = body as Comment + const eventName = `event-send-task-comment-${taskId}` + + console.log(`trigger event ${eventName} `, body) + + pusherServer.trigger('team-collab', eventName, { + ...result + }) + + res.json({ status: 200, data: result }) + }) + .catch(error => { + console.log({ error }) + res.json({ + status: 500, + err: error + }) + }) + } + + @Put('') + updateComment(@Res() res: Response, @Req() req: AuthRequest, @Next() next) { + const body = req.body as Comment + const { id, ...rest } = body + this.commentRepo + .mdCommentUpdate(id, rest) + .then(result => { + const { taskId } = result as Comment + const eventName = `event-update-task-comment-${taskId}` + + console.log(`trigger event ${eventName} `, body) + + pusherServer.trigger('team-collab', eventName, { + ...result + }) + + res.json({ status: 200, data: result }) + }) + .catch(error => { + console.log({ error }) + res.json({ + status: 500, + err: error + }) + }) + } + + @Delete('') + async commentDelete(@Req() req: Request, @Res() res: Response) { + try { + const { id, taskId, updatedBy } = req.query as { + id: string + taskId: string + updatedBy: string + } + const result = await this.commentRepo.mdCommentDel(id) + const eventName = `event-delete-task-comment-${taskId}` + + console.log(`trigger event ${eventName} `, id) + + pusherServer.trigger('team-collab', eventName, { + id, + triggerBy: updatedBy + }) + + res.json({ status: 200, data: result }) + } catch (error) { + res.json({ + status: 500, + err: error + }) + } + } +} diff --git a/packages/be-gateway/src/routes/index.ts b/packages/be-gateway/src/routes/index.ts index 86db9f18..ef2f0437 100644 --- a/packages/be-gateway/src/routes/index.ts +++ b/packages/be-gateway/src/routes/index.ts @@ -14,6 +14,7 @@ import buzzerRouter from './buzzer' import meetingRouter from './meeting' import { authMiddleware } from '../middlewares' import ActivityRouter from './activity' +import CommentRouer from './comment' // import "./test"; import ProjectController from './project/project.controller' @@ -42,6 +43,7 @@ router.use( TestController, ProjectController, ActivityRouter, + CommentRouer, EventController, ProjectViewController, PermissionController, diff --git a/packages/shared-models/src/lib/_prisma.ts b/packages/shared-models/src/lib/_prisma.ts index 3cd821ad..b36e1e41 100644 --- a/packages/shared-models/src/lib/_prisma.ts +++ b/packages/shared-models/src/lib/_prisma.ts @@ -20,3 +20,4 @@ export const taskAutomation = pmClient.taskAutomation export const fileStorageModel = pmClient.fileStorage export const visionModel = pmClient.vision export const activityModel = pmClient.activity +export const commentModel = pmClient.comment diff --git a/packages/shared-models/src/lib/comment.repository.ts b/packages/shared-models/src/lib/comment.repository.ts new file mode 100644 index 00000000..b75289b8 --- /dev/null +++ b/packages/shared-models/src/lib/comment.repository.ts @@ -0,0 +1,50 @@ +import { Comment } from '@prisma/client' +import { pmClient } from './_prisma' + +const mdComment = pmClient.comment +export class CommentRepository { + async mdCommentAdd(data: Omit) { + return mdComment.create({ + data + }) + } + + async mdCommentAddMany(data: Omit[]) { + return mdComment.createMany({ + data + }) + } + + async mdCommentDel(id: string) { + return mdComment.delete({ + where: { + id + } + }) + } + + async mdCommentUpdate(id: string, data: Omit) { + return mdComment.update({ + where: { + id + }, + data: data + }) + } + + async mdCommentGetAllByTask(taskId: string) { + return mdComment.findMany({ + where: { + taskId: taskId + } + }) + } + + async mdCommentGetAllByProject(projectId: string) { + return mdComment.findMany({ + where: { + projectId: projectId + } + }) + } +} diff --git a/packages/shared-models/src/lib/index.ts b/packages/shared-models/src/lib/index.ts index d7f95101..277737a4 100644 --- a/packages/shared-models/src/lib/index.ts +++ b/packages/shared-models/src/lib/index.ts @@ -18,3 +18,4 @@ export * from './taskAutomation' export * from './storage' export * from './activity' export * from './scheduler.repository' +export * from './comment.repository' diff --git a/packages/shared-models/src/prisma/schema.prisma b/packages/shared-models/src/prisma/schema.prisma index eccaae89..74af58b6 100644 --- a/packages/shared-models/src/prisma/schema.prisma +++ b/packages/shared-models/src/prisma/schema.prisma @@ -403,3 +403,15 @@ model Activity { updatedAt DateTime? updatedBy String? } + +model Comment { + id String @id @default(auto()) @map("_id") @db.ObjectId + taskId String @db.ObjectId + projectId String @db.ObjectId + + content String + + createdBy String + createdAt DateTime + updatedAt DateTime +} diff --git a/packages/shared-ui/src/components/Controls/RichTextEditorControl/MentionList.css b/packages/shared-ui/src/components/Controls/RichTextEditorControl/MentionList.css new file mode 100644 index 00000000..e69de29b diff --git a/packages/shared-ui/src/components/Controls/RichTextEditorControl/MentionList.tsx b/packages/shared-ui/src/components/Controls/RichTextEditorControl/MentionList.tsx new file mode 100644 index 00000000..1ff8a980 --- /dev/null +++ b/packages/shared-ui/src/components/Controls/RichTextEditorControl/MentionList.tsx @@ -0,0 +1,102 @@ +import { SuggestionProps } from '@tiptap/suggestion' +import { + ReactElement, + Ref, + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react' + +import MemberAvatar from '@/components/MemberAvatar' + +export type TItemBase = { + id: string + label: string +} +type TMemberMentionProps = SuggestionProps +type TMemberMentionRef = Ref<{ onKeyDown: ({ event }: { event: any }) => void }> + +const Mention = ( + props: TMemberMentionProps, + ref: TMemberMentionRef +) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + const { id, label } = item + props.command({ id, label }) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + } + })) + + return ( +
+ {props.items?.length ? ( + props.items?.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ) +} + +export default forwardRef(Mention) as ( + p: TMemberMentionProps & { r: TMemberMentionRef } +) => ReactElement diff --git a/packages/shared-ui/src/components/Controls/RichTextEditorControl/index.tsx b/packages/shared-ui/src/components/Controls/RichTextEditorControl/index.tsx new file mode 100644 index 00000000..2c0ee715 --- /dev/null +++ b/packages/shared-ui/src/components/Controls/RichTextEditorControl/index.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react' +import { TextareaProps } from '../type' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import Bold from '@tiptap/extension-bold' +import Italic from '@tiptap/extension-italic' +import Underline from '@tiptap/extension-underline' +import Image from '@tiptap/extension-image' + +import { RiImageAddFill } from 'react-icons/ri' +import { Extension } from '@tiptap/core' +// import { keymap } from '@tiptap/pm/keymap' +// import { baseKeymap } from '@tiptap/pm/commands' + +import './style.css' +import { Text } from '@tiptap/extension-text' + +const DisableEscape = Extension.create({ + addKeyboardShortcuts() { + return { + Escape: () => true + } + } +}) + +// const KeyEventHandler = Extension.create({ +// name: 'KeyEventHandler', +// +// addProseMirrorPlugins() { +// return [ +// keymap(baseKeymap), +// keymap({ +// 'Shift-a': () => { +// console.log('Shift-a pressed') +// return true +// } +// }) +// ] +// } +// }) + +export default function RichTextEditor({ + title, + value, + helper, + error, + required, + disabled, + readOnly, + extensions = [], + onCtrlEnter, + onCtrlEsc +}: TextareaProps) { + const classes = ['form-control'] + + disabled && classes.push('disabled') + required && classes.push('required') + readOnly && classes.push('readonly') + error && classes.push('error') + + const editor = useEditor({ + extensions: [ + StarterKit, + Link.configure({ openOnClick: false }), + Bold, + Italic, + Underline, + Image, + ...extensions, + DisableEscape, + // KeyEventHandler, + Text.extend({ + addKeyboardShortcuts() { + return { + 'Control-Enter': () => { + const html = this.editor.getHTML() + html && onCtrlEnter && onCtrlEnter(html) + this.editor.commands.clearContent() + return true + }, + 'Control-Escape': () => { + console.log('Control-escape pressed') + onCtrlEsc && onCtrlEsc() + return true + } + } + } + }) + ], + editable: !readOnly, + content: value + }) + + useEffect(() => { + value && + editor?.commands.setContent(value, false, { preserveWhitespace: 'full' }) + }, [value, editor]) + + useEffect(() => { + editor?.setEditable(!readOnly, true) + }, [readOnly, editor]) + + if (!editor) { + return null + } + + const marks = () => { + return ( +
+ + + + { + const url = window.prompt('URL') + + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + }} + /> +
+ ) + } + + return ( +
+ {title ? : null} +
+
+ {!readOnly ? marks() : null} + +
+
+ {helper && !error ? ( +

{helper}

+ ) : null} + {error ?

{error}

: null} +
+ ) +} diff --git a/packages/shared-ui/src/components/Controls/RichTextEditorControl/style.css b/packages/shared-ui/src/components/Controls/RichTextEditorControl/style.css new file mode 100644 index 00000000..7510afc5 --- /dev/null +++ b/packages/shared-ui/src/components/Controls/RichTextEditorControl/style.css @@ -0,0 +1,50 @@ +.tiptap { + > * + * { + margin-top: 0.75em; + } +} + +.mention { + border: 1px solid #000; + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; +} + +.mark { + min-width: 2rem; + border: 0.5px solid #bfbfbf; + border-radius: 5px; + padding: 1px; +} + +.mark-active { + background-color: #bfbfbf; +} + +.items { + background: #fff; + border-radius: 0.5rem; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + color: rgba(0, 0, 0, 0.8); + font-size: 0.9rem; + overflow: hidden; + padding: 0.2rem; + position: relative; + pointer-events: auto; +} + +.item { + background: transparent; + border: 1px solid transparent; + border-radius: 0.4rem; + display: block; + margin: 0; + padding: 0.2rem 0.4rem; + text-align: left; + width: 100%; + + &.is-selected { + border-color: #000; + } +} diff --git a/packages/shared-ui/src/components/Controls/RichTextEditorControl/suggestionBase.ts b/packages/shared-ui/src/components/Controls/RichTextEditorControl/suggestionBase.ts new file mode 100644 index 00000000..006bd787 --- /dev/null +++ b/packages/shared-ui/src/components/Controls/RichTextEditorControl/suggestionBase.ts @@ -0,0 +1,75 @@ +import { ReactRenderer } from '@tiptap/react' +import tippy, { Instance } from 'tippy.js' +import MentionList from './MentionList' +import { type TRichTextEditorMention } from '../type' +import { SuggestionOptions } from '@tiptap/suggestion' +import Fuse from 'fuse.js' + +export const getMentionSuggestion = ( + items: (TRichTextEditorMention & T)[] +): Partial => ({ + items: ({ query }): TRichTextEditorMention[] => { + if (!query) return items + + const fuse = new Fuse(items, { + keys: ['label', 'email'] + }) + const searchResults = fuse.search(query.toLowerCase()) + return searchResults.map(({ item }) => item) + }, + + render: () => { + let component: ReactRenderer + let popup: Instance[] + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start' + }) + }, + + onUpdate(props) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide() + + return true + } + + return component.ref?.onKeyDown(props) + }, + + onExit() { + popup[0].destroy() + component.destroy() + } + } + } +}) diff --git a/packages/shared-ui/src/components/Controls/TextareaControl/index.tsx b/packages/shared-ui/src/components/Controls/TextareaControl/index.tsx index e1aea566..c309d7ce 100644 --- a/packages/shared-ui/src/components/Controls/TextareaControl/index.tsx +++ b/packages/shared-ui/src/components/Controls/TextareaControl/index.tsx @@ -1,15 +1,22 @@ -import { ChangeEvent, useEffect, useState } from "react"; -import { TextareaProps } from "../type"; +import { ChangeEvent, useEffect, useState } from 'react' +import { TextareaProps } from '../type' export default function TextareaControl({ - title, value, name, - onChange, placeholder, - onEnter, - helper, error, - required, disabled, readOnly, - rows = 4, cols, + title, + value, + name, + onChange, + placeholder, + onShiftEnter, + helper, + error, + required, + disabled, + readOnly, + rows = 4, + cols }: TextareaProps) { - const classes = ["form-control"] + const classes = ['form-control'] const [val, setValue] = useState(value) const onInputChange = (ev: ChangeEvent) => { @@ -20,34 +27,38 @@ export default function TextareaControl({ setValue(value) }, [value]) - disabled && classes.push("disabled") - required && classes.push("required") - readOnly && classes.push("readonly") - error && classes.push("error") + disabled && classes.push('disabled') + required && classes.push('required') + readOnly && classes.push('readonly') + error && classes.push('error') - return
- {title ? : null} -
-