From 3fae41a5cabdb68081b6734c206400539cf39753 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:04:01 +0100 Subject: [PATCH 1/8] fix: editor performance improvements (#1648) * Switch to useEditorState * change shouldRerenderOnTransaction to false --- .../components/bubble-menu/bubble-menu.tsx | 31 ++++++++-- .../components/bubble-menu/color-selector.tsx | 35 +++++++++-- .../components/bubble-menu/node-selector.tsx | 48 ++++++++++----- .../bubble-menu/text-alignment-selector.tsx | 39 +++++++++---- .../components/callout/callout-menu.tsx | 44 +++++++------- .../editor/components/drawio/drawio-menu.tsx | 57 +++++++++++------- .../components/excalidraw/excalidraw-menu.tsx | 57 +++++++++++------- .../editor/components/image/image-menu.tsx | 43 ++++++++------ .../editor/components/link/link-menu.tsx | 22 +++++-- .../table/table-background-color.tsx | 58 ++++++++++++------- .../components/table/table-text-alignment.tsx | 39 +++++++++---- .../editor/components/video/video-menu.tsx | 43 ++++++++------ .../src/features/editor/page-editor.tsx | 30 +++++++--- 13 files changed, 369 insertions(+), 177 deletions(-) diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 3b7692f4..d28eae98 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -3,6 +3,7 @@ import { BubbleMenuProps, isNodeSelection, useEditor, + useEditorState, } from "@tiptap/react"; import { FC, useEffect, useRef, useState } from "react"; import { @@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC = (props) => { showCommentPopupRef.current = showCommentPopup; }, [showCommentPopup]); + const editorState = useEditorState({ + editor: props.editor, + selector: (ctx) => { + if (!props.editor) { + return null; + } + + return { + isBold: ctx.editor.isActive("bold"), + isItalic: ctx.editor.isActive("italic"), + isUnderline: ctx.editor.isActive("underline"), + isStrike: ctx.editor.isActive("strike"), + isCode: ctx.editor.isActive("code"), + isComment: ctx.editor.isActive("comment"), + }; + }, + }); + const items: BubbleMenuItem[] = [ { name: "Bold", - isActive: () => props.editor.isActive("bold"), + isActive: () => editorState?.isBold, command: () => props.editor.chain().focus().toggleBold().run(), icon: IconBold, }, { name: "Italic", - isActive: () => props.editor.isActive("italic"), + isActive: () => editorState?.isItalic, command: () => props.editor.chain().focus().toggleItalic().run(), icon: IconItalic, }, { name: "Underline", - isActive: () => props.editor.isActive("underline"), + isActive: () => editorState?.isUnderline, command: () => props.editor.chain().focus().toggleUnderline().run(), icon: IconUnderline, }, { name: "Strike", - isActive: () => props.editor.isActive("strike"), + isActive: () => editorState?.isStrike, command: () => props.editor.chain().focus().toggleStrike().run(), icon: IconStrikethrough, }, { name: "Code", - isActive: () => props.editor.isActive("code"), + isActive: () => editorState?.isCode, command: () => props.editor.chain().focus().toggleCode().run(), icon: IconCode, }, @@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC = (props) => { const commentItem: BubbleMenuItem = { name: "Comment", - isActive: () => props.editor.isActive("comment"), + isActive: () => editorState?.isComment, command: () => { const commentId = uuid7(); diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index 1148e0f4..a59eb8e4 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -9,7 +9,8 @@ import { Text, Tooltip, } from "@mantine/core"; -import { useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; export interface BubbleColorMenuItem { @@ -18,7 +19,7 @@ export interface BubbleColorMenuItem { } interface ColorSelectorProps { - editor: ReturnType; + editor: Editor | null; isOpen: boolean; setIsOpen: Dispatch>; } @@ -108,12 +109,36 @@ export const ColorSelector: FC = ({ setIsOpen, }) => { const { t } = useTranslation(); + + const editorState = useEditorState({ + editor, + selector: ctx => { + if (!ctx.editor) { + return null; + } + + const activeColors: Record = {}; + TEXT_COLORS.forEach(({ color }) => { + activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color }); + }); + HIGHLIGHT_COLORS.forEach(({ color }) => { + activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color }); + }); + + return activeColors; + }, + }); + + if (!editor || !editorState) { + return null; + } + const activeColorItem = TEXT_COLORS.find(({ color }) => - editor.isActive("textStyle", { color }), + editorState[`text_${color}`] ); const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => - editor.isActive("highlight", { color }), + editorState[`highlight_${color}`] ); return ( @@ -151,7 +176,7 @@ export const ColorSelector: FC = ({ justify="left" fullWidth rightSection={ - editor.isActive("textStyle", { color }) && ( + editorState[`text_${color}`] && ( ) } diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx index bc2eb702..13b2117f 100644 --- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx @@ -13,11 +13,12 @@ import { IconTypography, } from "@tabler/icons-react"; import { Popover, Button, ScrollArea } from "@mantine/core"; -import { useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; interface NodeSelectorProps { - editor: ReturnType; + editor: Editor | null; isOpen: boolean; setIsOpen: Dispatch>; } @@ -36,6 +37,27 @@ export const NodeSelector: FC = ({ }) => { const { t } = useTranslation(); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!editor) { + return null; + } + + return { + isParagraph: ctx.editor.isActive("paragraph"), + isBulletList: ctx.editor.isActive("bulletList"), + isOrderedList: ctx.editor.isActive("orderedList"), + isHeading1: ctx.editor.isActive("heading", { level: 1 }), + isHeading2: ctx.editor.isActive("heading", { level: 2 }), + isHeading3: ctx.editor.isActive("heading", { level: 3 }), + isTaskItem: ctx.editor.isActive("taskItem"), + isBlockquote: ctx.editor.isActive("blockquote"), + isCodeBlock: ctx.editor.isActive("codeBlock"), + }; + }, + }); + const items: BubbleMenuItem[] = [ { name: "Text", @@ -43,45 +65,45 @@ export const NodeSelector: FC = ({ command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(), isActive: () => - editor.isActive("paragraph") && - !editor.isActive("bulletList") && - !editor.isActive("orderedList"), + editorState?.isParagraph && + !editorState?.isBulletList && + !editorState?.isOrderedList, }, { name: "Heading 1", icon: IconH1, command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), - isActive: () => editor.isActive("heading", { level: 1 }), + isActive: () => editorState?.isHeading1, }, { name: "Heading 2", icon: IconH2, command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: () => editor.isActive("heading", { level: 2 }), + isActive: () => editorState?.isHeading2, }, { name: "Heading 3", icon: IconH3, command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: () => editor.isActive("heading", { level: 3 }), + isActive: () => editorState?.isHeading3, }, { name: "To-do List", icon: IconCheckbox, command: () => editor.chain().focus().toggleTaskList().run(), - isActive: () => editor.isActive("taskItem"), + isActive: () => editorState?.isTaskItem, }, { name: "Bullet List", icon: IconList, command: () => editor.chain().focus().toggleBulletList().run(), - isActive: () => editor.isActive("bulletList"), + isActive: () => editorState?.isBulletList, }, { name: "Numbered List", icon: IconListNumbers, command: () => editor.chain().focus().toggleOrderedList().run(), - isActive: () => editor.isActive("orderedList"), + isActive: () => editorState?.isOrderedList, }, { name: "Blockquote", @@ -93,13 +115,13 @@ export const NodeSelector: FC = ({ .toggleNode("paragraph", "paragraph") .toggleBlockquote() .run(), - isActive: () => editor.isActive("blockquote"), + isActive: () => editorState?.isBlockquote, }, { name: "Code", icon: IconCode, command: () => editor.chain().focus().toggleCodeBlock().run(), - isActive: () => editor.isActive("codeBlock"), + isActive: () => editorState?.isCodeBlock, }, ]; diff --git a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx index 8330684b..b5277651 100644 --- a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx @@ -8,11 +8,12 @@ import { IconChevronDown, } from "@tabler/icons-react"; import { Popover, Button, ScrollArea, rem } from "@mantine/core"; -import { useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; interface TextAlignmentProps { - editor: ReturnType; + editor: Editor | null; isOpen: boolean; setIsOpen: Dispatch>; } @@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC = ({ }) => { const { t } = useTranslation(); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + return { + isAlignLeft: ctx.editor.isActive({ textAlign: "left" }), + isAlignCenter: ctx.editor.isActive({ textAlign: "center" }), + isAlignRight: ctx.editor.isActive({ textAlign: "right" }), + isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }), + }; + }, + }); + + if (!editor || !editorState) { + return null; + } + const items: BubbleMenuItem[] = [ { name: "Align left", - isActive: () => editor.isActive({ textAlign: "left" }), + isActive: () => editorState?.isAlignLeft, command: () => editor.chain().focus().setTextAlign("left").run(), icon: IconAlignLeft, }, { name: "Align center", - isActive: () => editor.isActive({ textAlign: "center" }), + isActive: () => editorState?.isAlignCenter, command: () => editor.chain().focus().setTextAlign("center").run(), icon: IconAlignCenter, }, { name: "Align right", - isActive: () => editor.isActive({ textAlign: "right" }), + isActive: () => editorState?.isAlignRight, command: () => editor.chain().focus().setTextAlign("right").run(), icon: IconAlignRight, }, { name: "Justify", - isActive: () => editor.isActive({ textAlign: "justify" }), + isActive: () => editorState?.isAlignJustify, command: () => editor.chain().focus().setTextAlign("justify").run(), icon: IconAlignJustified, }, ]; - const activeItem = items.filter((item) => item.isActive()).pop() ?? { - name: "Multiple", - }; + const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0]; return ( @@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC = ({ rightSection={} onClick={() => setIsOpen(!isOpen)} > - + diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index 988f214a..c0485614 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect, + useEditorState, } from "@tiptap/react"; import React, { useCallback } from "react"; import { Node as PMNode } from "prosemirror-model"; @@ -9,7 +10,7 @@ import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; -import { ActionIcon, Tooltip, Divider } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; import { IconAlertTriangleFilled, IconCircleCheckFilled, @@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) { [editor], ); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + return { + isCallout: ctx.editor.isActive("callout"), + isInfo: ctx.editor.isActive("callout", { type: "info" }), + isSuccess: ctx.editor.isActive("callout", { type: "success" }), + isWarning: ctx.editor.isActive("callout", { type: "warning" }), + isDanger: ctx.editor.isActive("callout", { type: "danger" }), + }; + }, + }); + const getReferenceClientRect = useCallback(() => { const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "callout"; @@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { return ( setCalloutType("info")} size="lg" aria-label={t("Info")} - variant={ - editor.isActive("callout", { type: "info" }) ? "light" : "default" - } + variant={editorState?.isInfo ? "light" : "default"} > @@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("success")} size="lg" aria-label={t("Success")} - variant={ - editor.isActive("callout", { type: "success" }) - ? "light" - : "default" - } + variant={editorState?.isSuccess ? "light" : "default"} > @@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("warning")} size="lg" aria-label={t("Warning")} - variant={ - editor.isActive("callout", { type: "warning" }) - ? "light" - : "default" - } + variant={editorState?.isWarning ? "light" : "default"} > @@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("danger")} size="lg" aria-label={t("Danger")} - variant={ - editor.isActive("callout", { type: "danger" }) - ? "light" - : "default" - } + variant={editorState?.isDanger ? "light" : "default"} > diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 76771b10..0efc2ec0 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -2,15 +2,16 @@ import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect, -} from '@tiptap/react'; -import { useCallback } from 'react'; -import { sticky } from 'tippy.js'; -import { Node as PMNode } from 'prosemirror-model'; + useEditorState, +} from "@tiptap/react"; +import { useCallback } from "react"; +import { sticky } from "tippy.js"; +import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, -} from '@/features/editor/components/table/types/types.ts'; -import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; +} from "@/features/editor/components/table/types/types.ts"; +import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; export function DrawioMenu({ editor }: EditorMenuProps) { const shouldShow = useCallback( @@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) { return false; } - return editor.isActive('drawio') && editor.getAttributes('drawio')?.src; + return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; }, - [editor] + [editor], ); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const drawioAttr = ctx.editor.getAttributes("drawio"); + return { + isDrawio: ctx.editor.isActive("drawio"), + width: drawioAttr?.width ? parseInt(drawioAttr.width) : null, + }; + }, + }); + const getReferenceClientRect = useCallback(() => { const { selection } = editor.state; - const predicate = (node: PMNode) => node.type.name === 'drawio'; + const predicate = (node: PMNode) => node.type.name === "drawio"; const parent = findParentNode(predicate)(selection); if (parent) { @@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) { const onWidthChange = useCallback( (value: number) => { - editor.commands.updateAttributes('drawio', { width: `${value}%` }); + editor.commands.updateAttributes("drawio", { width: `${value}%` }); }, - [editor] + [editor], ); return (
- {editor.getAttributes('drawio')?.width && ( - + {editorState?.width && ( + )}
diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 5672e4f8..42329e5c 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -2,15 +2,16 @@ import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect, -} from '@tiptap/react'; -import { useCallback } from 'react'; -import { sticky } from 'tippy.js'; -import { Node as PMNode } from 'prosemirror-model'; + useEditorState, +} from "@tiptap/react"; +import { useCallback } from "react"; +import { sticky } from "tippy.js"; +import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, -} from '@/features/editor/components/table/types/types.ts'; -import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; +} from "@/features/editor/components/table/types/types.ts"; +import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; export function ExcalidrawMenu({ editor }: EditorMenuProps) { const shouldShow = useCallback( @@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { return false; } - return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src; + return ( + editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src + ); }, - [editor] + [editor], ); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const excalidrawAttr = ctx.editor.getAttributes("excalidraw"); + return { + isExcalidraw: ctx.editor.isActive("excalidraw"), + width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null, + }; + }, + }); + const getReferenceClientRect = useCallback(() => { const { selection } = editor.state; - const predicate = (node: PMNode) => node.type.name === 'excalidraw'; + const predicate = (node: PMNode) => node.type.name === "excalidraw"; const parent = findParentNode(predicate)(selection); if (parent) { @@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { const onWidthChange = useCallback( (value: number) => { - editor.commands.updateAttributes('excalidraw', { width: `${value}%` }); + editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); }, - [editor] + [editor], ); return ( @@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { offset: [0, 8], zIndex: 99, popperOptions: { - modifiers: [{ name: 'flip', enabled: false }], + modifiers: [{ name: "flip", enabled: false }], }, plugins: [sticky], - sticky: 'popper', + sticky: "popper", }} shouldShow={shouldShow} >
- {editor.getAttributes('excalidraw')?.width && ( - + {editorState?.width && ( + )}
diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index abb1c1ca..723ec299 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect, + useEditorState, } from "@tiptap/react"; import React, { useCallback } from "react"; import { sticky } from "tippy.js"; @@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) { [editor], ); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const imageAttrs = ctx.editor.getAttributes("image"); + + return { + isImage: ctx.editor.isActive("image"), + isAlignLeft: ctx.editor.isActive("image", { align: "left" }), + isAlignCenter: ctx.editor.isActive("image", { align: "center" }), + isAlignRight: ctx.editor.isActive("image", { align: "right" }), + width: imageAttrs?.width ? parseInt(imageAttrs.width) : null, + }; + }, + }); + const getReferenceClientRect = useCallback(() => { const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "image"; @@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { return ( @@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageCenter} size="lg" aria-label={t("Align center")} - variant={ - editor.isActive("image", { align: "center" }) - ? "light" - : "default" - } + variant={editorState?.isAlignCenter ? "light" : "default"} > @@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageRight} size="lg" aria-label={t("Align right")} - variant={ - editor.isActive("image", { align: "right" }) ? "light" : "default" - } + variant={editorState?.isAlignRight ? "light" : "default"} > - {editor.getAttributes("image")?.width && ( - + {editorState?.width && ( + )} ); diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx index 7cdd2f0f..69f7c449 100644 --- a/apps/client/src/features/editor/components/link/link-menu.tsx +++ b/apps/client/src/features/editor/components/link/link-menu.tsx @@ -1,4 +1,4 @@ -import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; import React, { useCallback, useState } from "react"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; @@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) { return editor.isActive("link"); }, [editor]); - const { href: link } = editor.getAttributes("link"); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + const link = ctx.editor.getAttributes("link"); + return { + href: link.href, + }; + }, + }); const handleEdit = useCallback(() => { setShowEdit(true); @@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) { padding="xs" bg="var(--mantine-color-body)" > - + ) : ( diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx index 204f0b02..7508d4fe 100644 --- a/apps/client/src/features/editor/components/table/table-background-color.tsx +++ b/apps/client/src/features/editor/components/table/table-background-color.tsx @@ -9,7 +9,8 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; import { useTranslation } from "react-i18next"; export interface TableColorItem { @@ -18,7 +19,7 @@ export interface TableColorItem { } interface TableBackgroundColorProps { - editor: ReturnType; + editor: Editor | null; } const TABLE_COLORS: TableColorItem[] = [ @@ -38,37 +39,50 @@ export const TableBackgroundColor: FC = ({ const { t } = useTranslation(); const [opened, setOpened] = React.useState(false); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + let currentColor = ""; + if (ctx.editor.isActive("tableCell")) { + const attrs = ctx.editor.getAttributes("tableCell"); + currentColor = attrs.backgroundColor || ""; + } else if (ctx.editor.isActive("tableHeader")) { + const attrs = ctx.editor.getAttributes("tableHeader"); + currentColor = attrs.backgroundColor || ""; + } + + return { + currentColor, + isTableCell: ctx.editor.isActive("tableCell"), + isTableHeader: ctx.editor.isActive("tableHeader"), + }; + }, + }); + + if (!editor || !editorState) { + return null; + } + const setTableCellBackground = (color: string, colorName: string) => { editor .chain() .focus() - .updateAttributes("tableCell", { + .updateAttributes("tableCell", { backgroundColor: color || null, - backgroundColorName: color ? colorName : null + backgroundColorName: color ? colorName : null, }) - .updateAttributes("tableHeader", { + .updateAttributes("tableHeader", { backgroundColor: color || null, - backgroundColorName: color ? colorName : null + backgroundColorName: color ? colorName : null, }) .run(); setOpened(false); }; - // Get current cell's background color - const getCurrentColor = () => { - if (editor.isActive("tableCell")) { - const attrs = editor.getAttributes("tableCell"); - return attrs.backgroundColor || ""; - } - if (editor.isActive("tableHeader")) { - const attrs = editor.getAttributes("tableHeader"); - return attrs.backgroundColor || ""; - } - return ""; - }; - - const currentColor = getCurrentColor(); - return ( = ({ cursor: "pointer", }} > - {currentColor === item.color && ( + {editorState.currentColor === item.color && ( ; + editor: Editor | null; } interface AlignmentItem { @@ -32,25 +32,44 @@ export const TableTextAlignment: FC = ({ editor }) => { const { t } = useTranslation(); const [opened, setOpened] = React.useState(false); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + return { + isAlignLeft: ctx.editor.isActive({ textAlign: "left" }), + isAlignCenter: ctx.editor.isActive({ textAlign: "center" }), + isAlignRight: ctx.editor.isActive({ textAlign: "right" }), + }; + }, + }); + + if (!editor || !editorState) { + return null; + } + const items: AlignmentItem[] = [ { name: "Align left", value: "left", - isActive: () => editor.isActive({ textAlign: "left" }), + isActive: () => editorState?.isAlignLeft, command: () => editor.chain().focus().setTextAlign("left").run(), icon: IconAlignLeft, }, { name: "Align center", value: "center", - isActive: () => editor.isActive({ textAlign: "center" }), + isActive: () => editorState?.isAlignCenter, command: () => editor.chain().focus().setTextAlign("center").run(), icon: IconAlignCenter, }, { name: "Align right", value: "right", - isActive: () => editor.isActive({ textAlign: "right" }), + isActive: () => editorState?.isAlignRight, command: () => editor.chain().focus().setTextAlign("right").run(), icon: IconAlignRight, }, @@ -64,7 +83,7 @@ export const TableTextAlignment: FC = ({ editor }) => { onChange={setOpened} position="bottom" withArrow - transitionProps={{ transition: 'pop' }} + transitionProps={{ transition: "pop" }} > @@ -87,9 +106,7 @@ export const TableTextAlignment: FC = ({ editor }) => { key={index} variant="default" leftSection={} - rightSection={ - item.isActive() && - } + rightSection={item.isActive() && } justify="left" fullWidth onClick={() => { @@ -106,4 +123,4 @@ export const TableTextAlignment: FC = ({ editor }) => { ); -}; \ No newline at end of file +}; diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 0c671cd6..3252e621 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect, + useEditorState, } from "@tiptap/react"; import React, { useCallback } from "react"; import { sticky } from "tippy.js"; @@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) { [editor], ); + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const videoAttrs = ctx.editor.getAttributes("video"); + + return { + isVideo: ctx.editor.isActive("video"), + isAlignLeft: ctx.editor.isActive("video", { align: "left" }), + isAlignCenter: ctx.editor.isActive("video", { align: "center" }), + isAlignRight: ctx.editor.isActive("video", { align: "right" }), + width: videoAttrs?.width ? parseInt(videoAttrs.width) : null, + }; + }, + }); + const getReferenceClientRect = useCallback(() => { const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "video"; @@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { return ( @@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { onClick={alignVideoCenter} size="lg" aria-label={t("Align center")} - variant={ - editor.isActive("video", { align: "center" }) - ? "light" - : "default" - } + variant={editorState?.isAlignCenter ? "light" : "default"} > @@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) { onClick={alignVideoRight} size="lg" aria-label={t("Align right")} - variant={ - editor.isActive("video", { align: "right" }) ? "light" : "default" - } + variant={editorState?.isAlignRight ? "light" : "default"} > - {editor.getAttributes("video")?.width && ( - + {editorState?.width && ( + )} ); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index e97a783f..a2dc0d93 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -7,7 +7,12 @@ import { onAuthenticationFailedParameters, WebSocketStatus, } from "@hocuspocus/provider"; -import { EditorContent, EditorProvider, useEditor } from "@tiptap/react"; +import { + EditorContent, + EditorProvider, + useEditor, + useEditorState, +} from "@tiptap/react"; import { collabExtensions, mainExtensions, @@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; -import { searchSpotlight } from '@/features/search/constants.ts'; +import { searchSpotlight } from "@/features/search/constants.ts"; interface PageEditorProps { pageId: string; @@ -77,7 +82,7 @@ export default function PageEditor({ const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( - yjsConnectionStatusAtom + yjsConnectionStatusAtom, ); const menuContainerRef = useRef(null); const documentName = `page.${pageId}`; @@ -213,17 +218,17 @@ export default function PageEditor({ extensions, editable, immediatelyRender: true, - shouldRerenderOnTransaction: true, + shouldRerenderOnTransaction: false, editorProps: { scrollThreshold: 80, scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { - if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { + if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { event.preventDefault(); return true; } - if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') { + if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { searchSpotlight.open(); return true; } @@ -268,9 +273,16 @@ export default function PageEditor({ debouncedUpdateContent(editorJson); }, }, - [pageId, editable, remoteProvider] + [pageId, editable, remoteProvider], ); + const editorIsEditable = useEditorState({ + editor, + selector: (ctx) => { + return ctx.editor?.isEditable ?? false; + }, + }); + const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const pageData = queryClient.getQueryData(["pages", slugId]); @@ -306,7 +318,7 @@ export default function PageEditor({ return () => { document.removeEventListener( "ACTIVE_COMMENT_EVENT", - handleActiveCommentEvent + handleActiveCommentEvent, ); }; }, []); @@ -389,7 +401,7 @@ export default function PageEditor({ )} - {editor && editor.isEditable && ( + {editor && editorIsEditable && (
From 31350303760163384113bf49d07127e886f94907 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:07:19 +0100 Subject: [PATCH 2/8] fix editor converter (#1647) --- apps/server/package.json | 2 +- .../src/collaboration/collaboration.util.ts | 3 +- .../helpers/prosemirror/html/generateHTML.ts | 38 ++++++++----- .../helpers/prosemirror/html/generateJSON.ts | 56 +++++++++++++++---- .../prosemirror/html/getHTMLFromFragment.ts | 54 ++++++++++++++++++ 5 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts diff --git a/apps/server/package.json b/apps/server/package.json index 71865a07..0c5ea90e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -62,7 +62,7 @@ "class-validator": "^0.14.1", "cookie": "^1.0.2", "fs-extra": "^11.3.0", - "happy-dom": "^15.11.6", + "happy-dom": "^18.0.1", "jsonwebtoken": "^9.0.2", "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 37645f44..008bfa31 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,11 +35,10 @@ import { Subpages, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; -import { generateHTML } from '../common/helpers/prosemirror/html'; +import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 -import { generateJSON } from '@tiptap/html'; import { Node } from '@tiptap/pm/model'; export const tiptapExtensions = [ diff --git a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts index 3622ed4c..52196aa2 100644 --- a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts +++ b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts @@ -1,21 +1,29 @@ -import { Extensions, getSchema, JSONContent } from '@tiptap/core'; -import { DOMSerializer, Node } from '@tiptap/pm/model'; -import { Window } from 'happy-dom'; +import { type Extensions, type JSONContent, getSchema } from '@tiptap/core'; +import { Node } from '@tiptap/pm/model'; +import { getHTMLFromFragment } from './getHTMLFromFragment'; +/** + * This function generates HTML from a ProseMirror JSON content object. + * + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param doc - The ProseMirror JSON content object. + * @param extensions - The Tiptap extensions used to build the schema. + * @returns The generated HTML string. + * @example + * ```js + * const html = generateHTML(doc, extensions) + * console.log(html) + * ``` + */ export function generateHTML(doc: JSONContent, extensions: Extensions): string { + if (typeof window !== 'undefined') { + throw new Error( + 'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', + ); + } + const schema = getSchema(extensions); const contentNode = Node.fromJSON(schema, doc); - const window = new Window(); - - const fragment = DOMSerializer.fromSchema(schema).serializeFragment( - contentNode.content, - { - document: window.document as unknown as Document, - }, - ); - - const serializer = new window.XMLSerializer(); - // @ts-ignore - return serializer.serializeToString(fragment as unknown as Node); + return getHTMLFromFragment(contentNode, schema); } diff --git a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts index 23d66119..bd6e735c 100644 --- a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts +++ b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts @@ -1,21 +1,55 @@ -import { Extensions, getSchema } from '@tiptap/core'; -import { DOMParser, ParseOptions } from '@tiptap/pm/model'; +import type { Extensions } from '@tiptap/core'; +import { getSchema } from '@tiptap/core'; +import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model'; import { Window } from 'happy-dom'; -// this function does not work as intended -// it has issues with closing tags +/** + * Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content. + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param {string} html - The HTML string to be converted into a Prosemirror node. + * @param {Extensions} extensions - The extensions to be used for generating the schema. + * @param {ParseOptions} options - The options to be supplied to the parser. + * @returns {Promise>} - A promise with the generated JSON object. + * @example + * const html = '

Hello, world!

' + * const extensions = [...] + * const json = generateJSON(html, extensions) + * console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] } + */ export function generateJSON( html: string, extensions: Extensions, options?: ParseOptions, ): Record { - const schema = getSchema(extensions); + if (typeof window !== 'undefined') { + throw new Error( + 'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.', + ); + } - const window = new Window(); - const document = window.document; - document.body.innerHTML = html; + const localWindow = new Window(); + const localDOMParser = new localWindow.DOMParser(); + let result: Record; - return DOMParser.fromSchema(schema) - .parse(document as never, options) - .toJSON(); + try { + const schema = getSchema(extensions); + let doc: ReturnType | null = null; + + const htmlString = `${html}`; + doc = localDOMParser.parseFromString(htmlString, 'text/html'); + + if (!doc) { + throw new Error('Failed to parse HTML string'); + } + + result = PMDOMParser.fromSchema(schema) + .parse(doc.body as unknown as Node, options) + .toJSON(); + } finally { + // clean up happy-dom to avoid memory leaks + localWindow.happyDOM.abort(); + localWindow.happyDOM.close(); + } + + return result; } diff --git a/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts new file mode 100644 index 00000000..635ee6a4 --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts @@ -0,0 +1,54 @@ +import type { Node, Schema } from '@tiptap/pm/model'; +import { DOMSerializer } from '@tiptap/pm/model'; +import { Window } from 'happy-dom'; + +/** + * Returns the HTML string representation of a given document node. + * + * @remarks **Important**: This function requires `happy-dom` to be installed in your project. + * @param doc - The document node to serialize. + * @param schema - The Prosemirror schema to use for serialization. + * @returns A promise containing the HTML string representation of the document fragment. + * + * @example + * ```typescript + * const html = getHTMLFromFragment(doc, schema) + * ``` + */ +export function getHTMLFromFragment( + doc: Node, + schema: Schema, + options?: { document?: Document }, +): string { + if (options?.document) { + const wrap = options.document.createElement('div'); + + DOMSerializer.fromSchema(schema).serializeFragment( + doc.content, + { document: options.document }, + wrap, + ); + return wrap.innerHTML; + } + + const localWindow = new Window(); + let result: string; + + try { + const fragment = DOMSerializer.fromSchema(schema).serializeFragment( + doc.content, + { + document: localWindow.document as unknown as Document, + }, + ); + + const serializer = new localWindow.XMLSerializer(); + result = serializer.serializeToString(fragment as any); + } finally { + // clean up happy-dom to avoid memory leaks + localWindow.happyDOM.abort(); + localWindow.happyDOM.close(); + } + + return result; +} From bf8cf6254f570a1a6b33b5a743af915481908b29 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:34:32 +0100 Subject: [PATCH 3/8] feat: Typesense search driver (EE) (#1664) * feat: typesense driver (EE) - WIP * feat: typesense driver (EE) - WIP * feat: typesense * sync * fix --- apps/server/package.json | 5 +- apps/server/src/app.module.ts | 5 + .../src/common/events/event.contants.ts | 7 +- .../server/src/common/validator/is-iso6391.ts | 34 +++++++ apps/server/src/core/page/page.module.ts | 2 +- .../src/core/page/services/page.service.ts | 11 +++ .../core/search/dto/search-response.dto.ts | 3 + .../src/core/search/search.controller.ts | 57 ++++++++++- apps/server/src/core/search/search.service.ts | 3 +- apps/server/src/database/database.module.ts | 6 +- .../src/database/listeners/page.listener.ts | 49 ++++++++++ .../src/database/repos/page/page.repo.ts | 63 ++++++++---- apps/server/src/ee | 2 +- .../environment/environment.service.ts | 20 ++++ .../environment/environment.validation.ts | 33 +++++++ .../services/file-import-task.service.ts | 9 ++ .../queue/constants/queue.constants.ts | 18 ++++ .../src/integrations/queue/queue.module.ts | 8 ++ .../redis/redis-config.service.ts | 26 +++++ pnpm-lock.yaml | 98 ++++++++++++++----- 20 files changed, 406 insertions(+), 53 deletions(-) create mode 100644 apps/server/src/common/validator/is-iso6391.ts create mode 100644 apps/server/src/database/listeners/page.listener.ts create mode 100644 apps/server/src/integrations/redis/redis-config.service.ts diff --git a/apps/server/package.json b/apps/server/package.json index 0c5ea90e..38be6184 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,6 +37,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.0.3", "@fastify/static": "^8.2.0", + "@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", @@ -55,7 +56,7 @@ "@react-email/render": "1.0.2", "@socket.io/redis-adapter": "^8.3.0", "bcrypt": "^5.1.1", - "bullmq": "^5.53.2", + "bullmq": "^5.61.0", "cache-manager": "^6.4.3", "cheerio": "^1.1.0", "class-transformer": "^0.5.1", @@ -63,6 +64,7 @@ "cookie": "^1.0.2", "fs-extra": "^11.3.0", "happy-dom": "^18.0.1", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", @@ -89,6 +91,7 @@ "socket.io": "^4.8.1", "stripe": "^17.5.0", "tmp-promise": "^3.0.3", + "typesense": "^2.1.0", "ws": "^8.18.2", "yauzl": "^3.2.0" }, diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 052faed2..56691444 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -16,6 +16,8 @@ import { ExportModule } from './integrations/export/export.module'; import { ImportModule } from './integrations/import/import.module'; import { SecurityModule } from './integrations/security/security.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module'; +import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; +import { RedisConfigService } from './integrations/redis/redis-config.service'; const enterpriseModules = []; try { @@ -36,6 +38,9 @@ try { CoreModule, DatabaseModule, EnvironmentModule, + RedisModule.forRootAsync({ + useClass: RedisConfigService, + }), CollaborationModule, WsModule, QueueModule, diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index 23149288..7adeb043 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -1,3 +1,8 @@ export enum EventName { COLLAB_PAGE_UPDATED = 'collab.page.updated', -} \ No newline at end of file + PAGE_CREATED = 'page.created', + PAGE_UPDATED = 'page.updated', + PAGE_DELETED = 'page.deleted', + PAGE_SOFT_DELETED = 'page.soft_deleted', + PAGE_RESTORED = 'page.restored', +} diff --git a/apps/server/src/common/validator/is-iso6391.ts b/apps/server/src/common/validator/is-iso6391.ts new file mode 100644 index 00000000..888157f0 --- /dev/null +++ b/apps/server/src/common/validator/is-iso6391.ts @@ -0,0 +1,34 @@ +// MIT - https://github.com/typestack/class-validator/pull/2626 +import isISO6391Validator from 'validator/lib/isISO6391'; +import { buildMessage, ValidateBy, ValidationOptions } from 'class-validator'; + +export const IS_ISO6391 = 'isISO6391'; + +/** + * Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code. + */ +export function isISO6391(value: unknown): boolean { + return typeof value === 'string' && isISO6391Validator(value); +} + +/** + * Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code. + */ +export function IsISO6391( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: IS_ISO6391, + validator: { + validate: (value, args): boolean => isISO6391(value), + defaultMessage: buildMessage( + (eachPrefix) => + eachPrefix + '$property must be a valid ISO 639-1 language code', + validationOptions, + ), + }, + }, + validationOptions, + ); +} diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 42693e3d..9dfba84a 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -9,6 +9,6 @@ import { StorageModule } from '../../integrations/storage/storage.module'; controllers: [PageController], providers: [PageService, PageHistoryService, TrashCleanupService], exports: [PageService, PageHistoryService], - imports: [StorageModule] + imports: [StorageModule], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index a538eedf..3ffa2042 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; +import { EventName } from '../../../common/events/event.contants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class PageService { @@ -49,6 +51,7 @@ export class PageService { @InjectKysely() private readonly db: KyselyDB, private readonly storageService: StorageService, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, + private eventEmitter: EventEmitter2, ) {} async findById( @@ -380,6 +383,11 @@ export class PageService { await this.db.insertInto('pages').values(insertablePages).execute(); + const insertedPageIds = insertablePages.map((page) => page.id); + this.eventEmitter.emit(EventName.PAGE_CREATED, { + pageIds: insertedPageIds, + }); + //TODO: best to handle this in a queue const attachmentsIds = Array.from(attachmentMap.keys()); if (attachmentsIds.length > 0) { @@ -606,6 +614,9 @@ export class PageService { if (pageIds.length > 0) { await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); + this.eventEmitter.emit(EventName.PAGE_DELETED, { + pageIds: pageIds, + }); } } diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts index bf8db9d1..8f5b343d 100644 --- a/apps/server/src/core/search/dto/search-response.dto.ts +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -1,3 +1,5 @@ +import { Space } from '@docmost/db/types/entity.types'; + export class SearchResponseDto { id: string; title: string; @@ -8,4 +10,5 @@ export class SearchResponseDto { highlight: string; createdAt: Date; updatedAt: Date; + space: Partial; } diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index 35083faf..c968c344 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -5,6 +5,7 @@ import { ForbiddenException, HttpCode, HttpStatus, + Logger, Post, UseGuards, } from '@nestjs/common'; @@ -24,13 +25,19 @@ import { } from '../casl/interfaces/space-ability.type'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { Public } from 'src/common/decorators/public.decorator'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { ModuleRef } from '@nestjs/core'; @UseGuards(JwtAuthGuard) @Controller('search') export class SearchController { + private readonly logger = new Logger(SearchController.name); + constructor( private readonly searchService: SearchService, private readonly spaceAbility: SpaceAbilityFactory, + private readonly environmentService: EnvironmentService, + private moduleRef: ModuleRef, ) {} @HttpCode(HttpStatus.OK) @@ -53,7 +60,14 @@ export class SearchController { } } - return this.searchService.searchPage(searchDto.query, searchDto, { + if (this.environmentService.getSearchDriver() === 'typesense') { + return this.searchTypesense(searchDto, { + userId: user.id, + workspaceId: workspace.id, + }); + } + + return this.searchService.searchPage(searchDto, { userId: user.id, workspaceId: workspace.id, }); @@ -81,8 +95,47 @@ export class SearchController { throw new BadRequestException('shareId is required'); } - return this.searchService.searchPage(searchDto.query, searchDto, { + if (this.environmentService.getSearchDriver() === 'typesense') { + return this.searchTypesense(searchDto, { + workspaceId: workspace.id, + }); + } + + return this.searchService.searchPage(searchDto, { workspaceId: workspace.id, }); } + + async searchTypesense( + searchParams: SearchDTO, + opts: { + userId?: string; + workspaceId: string; + }, + ) { + const { userId, workspaceId } = opts; + let TypesenseModule: any; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + TypesenseModule = require('./../../ee/typesense/services/page-search.service'); + + const PageSearchService = this.moduleRef.get( + TypesenseModule.PageSearchService, + { + strict: false, + }, + ); + + return PageSearchService.searchPage(searchParams, { + userId: userId, + workspaceId, + }); + } catch (err) { + this.logger.debug( + 'Typesense module requested but enterprise module not bundled in this build', + ); + } + + throw new BadRequestException('Enterprise Typesense search module missing'); + } } diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 60432ce8..0f8dbb90 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -21,13 +21,14 @@ export class SearchService { ) {} async searchPage( - query: string, searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; }, ): Promise { + const { query } = searchParams; + if (query.length < 1) { return; } diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 68c35dd3..bd331ada 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PageListener } from '@docmost/db/listeners/page.listener'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, - ShareRepo + ShareRepo, + PageListener, ], exports: [ WorkspaceRepo, @@ -90,7 +92,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, - ShareRepo + ShareRepo, ], }) export class DatabaseModule diff --git a/apps/server/src/database/listeners/page.listener.ts b/apps/server/src/database/listeners/page.listener.ts new file mode 100644 index 00000000..7e2d97e2 --- /dev/null +++ b/apps/server/src/database/listeners/page.listener.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../../common/events/event.contants'; +import { InjectQueue } from '@nestjs/bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { Queue } from 'bullmq'; + +export class PageEvent { + pageIds: string[]; +} + +@Injectable() +export class PageListener { + private readonly logger = new Logger(PageListener.name); + + constructor( + @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, + ) {} + + @OnEvent(EventName.PAGE_CREATED) + async handlePageCreated(event: PageEvent) { + const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds }); + } + + @OnEvent(EventName.PAGE_UPDATED) + async handlePageUpdated(event: PageEvent) { + const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds }); + } + + @OnEvent(EventName.PAGE_DELETED) + async handlePageDeleted(event: PageEvent) { + const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds }); + } + + @OnEvent(EventName.PAGE_SOFT_DELETED) + async handlePageSoftDeleted(event: PageEvent) { + const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds }); + } + + @OnEvent(EventName.PAGE_RESTORED) + async handlePageRestored(event: PageEvent) { + const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds }); + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index e577cc43..ca46ddc9 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -14,32 +14,17 @@ import { ExpressionBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventName } from '../../../common/events/event.contants'; @Injectable() export class PageRepo { constructor( @InjectKysely() private readonly db: KyselyDB, private spaceMemberRepo: SpaceMemberRepo, + private eventEmitter: EventEmitter2, ) {} - withHasChildren(eb: ExpressionBuilder) { - return eb - .selectFrom('pages as child') - .select((eb) => - eb - .case() - .when(eb.fn.countAll(), '>', 0) - .then(true) - .else(false) - .end() - .as('count'), - ) - .whereRef('child.parentPageId', '=', 'pages.id') - .where('child.deletedAt', 'is', null) - .limit(1) - .as('hasChildren'); - } - private baseFields: Array = [ 'id', 'slugId', @@ -63,6 +48,7 @@ export class PageRepo { pageId: string, opts?: { includeContent?: boolean; + includeTextContent?: boolean; includeYdoc?: boolean; includeSpace?: boolean; includeCreator?: boolean; @@ -80,6 +66,7 @@ export class PageRepo { .select(this.baseFields) .$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeYdoc, (qb) => qb.select('ydoc')) + .$if(opts?.includeTextContent, (qb) => qb.select('textContent')) .$if(opts?.includeHasChildren, (qb) => qb.select((eb) => this.withHasChildren(eb)), ); @@ -126,7 +113,7 @@ export class PageRepo { pageIds: string[], trx?: KyselyTransaction, ) { - return dbOrTx(this.db, trx) + const result = await dbOrTx(this.db, trx) .updateTable('pages') .set({ ...updatePageData, updatedAt: new Date() }) .where( @@ -135,6 +122,12 @@ export class PageRepo { pageIds, ) .executeTakeFirst(); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: pageIds, + }); + + return result; } async insertPage( @@ -142,11 +135,17 @@ export class PageRepo { trx?: KyselyTransaction, ): Promise { const db = dbOrTx(this.db, trx); - return db + const result = await db .insertInto('pages') .values(insertablePage) .returning(this.baseFields) .executeTakeFirst(); + + this.eventEmitter.emit(EventName.PAGE_CREATED, { + pageIds: [result.id], + }); + + return result; } async deletePage(pageId: string): Promise { @@ -196,6 +195,9 @@ export class PageRepo { await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute(); }); + this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, { + pageIds: pageIds, + }); } } @@ -259,6 +261,9 @@ export class PageRepo { .where('id', '=', pageId) .execute(); } + this.eventEmitter.emit(EventName.PAGE_RESTORED, { + pageIds: pageIds, + }); } async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) { @@ -379,6 +384,24 @@ export class PageRepo { ).as('contributors'); } + withHasChildren(eb: ExpressionBuilder) { + return eb + .selectFrom('pages as child') + .select((eb) => + eb + .case() + .when(eb.fn.countAll(), '>', 0) + .then(true) + .else(false) + .end() + .as('count'), + ) + .whereRef('child.parentPageId', '=', 'pages.id') + .where('child.deletedAt', 'is', null) + .limit(1) + .as('hasChildren'); + } + async getPageAndDescendants( parentPageId: string, opts: { includeContent: boolean }, diff --git a/apps/server/src/ee b/apps/server/src/ee index d2ead431..a4a19f71 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit d2ead431819025e735e8b8e63d6d898d76c417e6 +Subproject commit a4a19f71e15e3770e6a4af24d54c198aba65254a diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 3ce728ea..e41a5ec3 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -213,4 +213,24 @@ export class EnvironmentService { getPostHogKey(): string { return this.configService.get('POSTHOG_KEY'); } + + getSearchDriver(): string { + return this.configService + .get('SEARCH_DRIVER', 'database') + .toLowerCase(); + } + + getTypesenseUrl(): string { + return this.configService + .get('TYPESENSE_URL', 'http://localhost:8108') + .toLowerCase(); + } + + getTypesenseApiKey(): string { + return this.configService.get('TYPESENSE_API_KEY'); + } + + getTypesenseLocale(): string { + return this.configService.get('TYPESENSE_LOCALE', 'en').toLowerCase(); + } } diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index a2aeb6dd..d59558f8 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -3,12 +3,14 @@ import { IsNotEmpty, IsNotIn, IsOptional, + IsString, IsUrl, MinLength, ValidateIf, validateSync, } from 'class-validator'; import { plainToInstance } from 'class-transformer'; +import { IsISO6391 } from '../../common/validator/is-iso6391'; export class EnvironmentVariables { @IsNotEmpty() @@ -68,6 +70,37 @@ export class EnvironmentVariables { ) @ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase()) SUBDOMAIN_HOST: string; + + @IsOptional() + @IsIn(['database', 'typesense']) + @IsString() + SEARCH_DRIVER: string; + + @IsOptional() + @IsUrl( + { + protocols: ['http', 'https'], + require_tld: false, + allow_underscores: true, + }, + { + message: + 'TYPESENSE_URL must be a valid typesense url e.g http://localhost:8108', + }, + ) + @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense') + TYPESENSE_URL: string; + + @IsOptional() + @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense') + @IsString() + TYPESENSE_API_KEY: string; + + @IsOptional() + @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense') + @IsISO6391() + @IsString() + TYPESENSE_LOCALE: string; } export function validate(config: Record) { diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index f7d93ec0..6337f9e1 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -32,6 +32,8 @@ import { ImportAttachmentService } from './import-attachment.service'; import { ModuleRef } from '@nestjs/core'; import { PageService } from '../../../core/page/services/page.service'; import { ImportPageNode } from '../dto/file-task-dto'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventName } from '../../../common/events/event.contants'; @Injectable() export class FileImportTaskService { @@ -45,6 +47,7 @@ export class FileImportTaskService { @InjectKysely() private readonly db: KyselyDB, private readonly importAttachmentService: ImportAttachmentService, private moduleRef: ModuleRef, + private eventEmitter: EventEmitter2, ) {} async processZIpImport(fileTaskId: string): Promise { @@ -396,6 +399,12 @@ export class FileImportTaskService { } } + if (validPageIds.size > 0) { + this.eventEmitter.emit(EventName.PAGE_CREATED, { + pageIds: Array.from(validPageIds), + }); + } + this.logger.log( `Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`, ); diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 4a1b1d1c..122d2a76 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -4,6 +4,7 @@ export enum QueueName { GENERAL_QUEUE = '{general-queue}', BILLING_QUEUE = '{billing-queue}', FILE_TASK_QUEUE = '{file-task-queue}', + SEARCH_QUEUE = '{search-queue}', } export enum QueueJob { @@ -25,4 +26,21 @@ export enum QueueJob { IMPORT_TASK = 'import-task', EXPORT_TASK = 'export-task', + + SEARCH_INDEX_PAGE = 'search-index-page', + SEARCH_INDEX_PAGES = 'search-index-pages', + SEARCH_INDEX_COMMENT = 'search-index-comment', + SEARCH_INDEX_COMMENTS = 'search-index-comments', + SEARCH_INDEX_ATTACHMENT = 'search-index-attachment', + SEARCH_INDEX_ATTACHMENTS = 'search-index-attachments', + SEARCH_REMOVE_PAGE = 'search-remove-page', + SEARCH_REMOVE_ASSET = 'search-remove-attachment', + SEARCH_REMOVE_FACE = 'search-remove-comment', + TYPESENSE_FLUSH = 'typesense-flush', + + PAGE_CREATED = 'page-created', + PAGE_UPDATED = 'page-updated', + PAGE_SOFT_DELETED = 'page-soft-deleted', + PAGE_RESTORED = 'page-restored', + PAGE_DELETED = 'page-deleted', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 81aa0a5f..32d009ad 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -57,6 +57,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor'; attempts: 1, }, }), + BullModule.registerQueue({ + name: QueueName.SEARCH_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 2, + }, + }), ], exports: [BullModule], providers: [BacklinksProcessor], diff --git a/apps/server/src/integrations/redis/redis-config.service.ts b/apps/server/src/integrations/redis/redis-config.service.ts new file mode 100644 index 00000000..719f89f1 --- /dev/null +++ b/apps/server/src/integrations/redis/redis-config.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { + RedisModuleOptions, + RedisOptionsFactory, +} from '@nestjs-labs/nestjs-ioredis'; +import { createRetryStrategy, parseRedisUrl } from '../../common/helpers'; +import { EnvironmentService } from '../environment/environment.service'; + +@Injectable() +export class RedisConfigService implements RedisOptionsFactory { + constructor(private readonly environmentService: EnvironmentService) {} + createRedisOptions(): RedisModuleOptions { + const redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); + return { + readyLog: true, + config: { + host: redisConfig.host, + port: redisConfig.port, + password: redisConfig.password, + db: redisConfig.db, + family: redisConfig.family, + retryStrategy: createRetryStrategy(), + }, + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad3a5ed..b9ac61ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,9 +447,12 @@ importers: '@fastify/static': specifier: ^8.2.0 version: 8.2.0 + '@nestjs-labs/nestjs-ioredis': + specifier: ^11.0.4 + version: 11.0.4(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(ioredis@5.4.1) '@nestjs/bullmq': specifier: ^11.0.2 - version: 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.53.2) + version: 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.61.0) '@nestjs/common': specifier: ^11.1.3 version: 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -502,8 +505,8 @@ importers: specifier: ^5.1.1 version: 5.1.1 bullmq: - specifier: ^5.53.2 - version: 5.53.2 + specifier: ^5.61.0 + version: 5.61.0 cache-manager: specifier: ^6.4.3 version: 6.4.3 @@ -523,8 +526,11 @@ importers: specifier: ^11.3.0 version: 11.3.0 happy-dom: - specifier: ^15.11.6 - version: 15.11.7 + specifier: ^18.0.1 + version: 18.0.1 + ioredis: + specifier: ^5.4.1 + version: 5.4.1 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -603,6 +609,9 @@ importers: tmp-promise: specifier: ^3.0.3 version: 3.0.3 + typesense: + specifier: ^2.1.0 + version: 2.1.0(@babel/runtime@7.25.6) ws: specifier: ^8.18.2 version: 8.18.2 @@ -2820,6 +2829,14 @@ packages: '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} + '@nestjs-labs/nestjs-ioredis@11.0.4': + resolution: {integrity: sha512-4jPNOrxDiwNMIN5OLmsMWhA782kxv/ZBxkySX9l8n6sr55acHX/BciaFsOXVa/ILsm+Y7893y98/6WNhmEoiNQ==} + engines: {node: '>=16'} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + ioredis: ^5.0.0 + '@nestjs/bull-shared@11.0.2': resolution: {integrity: sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==} peerDependencies: @@ -4547,6 +4564,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.19': + resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} + '@types/node@22.10.0': resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==} @@ -4637,6 +4657,9 @@ packages: '@types/validator@13.12.0': resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.5.14': resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} @@ -5217,8 +5240,8 @@ packages: builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} - bullmq@5.53.2: - resolution: {integrity: sha512-xHgxrP/yNJHD7VCw1h+eRBh+2TCPBCM39uC9gCyksYc6ufcJP+HTZ/A2lzB2x7qMFWrvsX7tM40AT2BmdkYL/Q==} + bullmq@5.61.0: + resolution: {integrity: sha512-khaTjc1JnzaYFl4FrUtsSsqugAW/urRrcZ9Q0ZE+REAw8W+gkHFqxbGlutOu6q7j7n91wibVaaNlOUMdiEvoSQ==} busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} @@ -6537,9 +6560,9 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - happy-dom@15.11.7: - resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} - engines: {node: '>=18.0.0'} + happy-dom@18.0.1: + resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} + engines: {node: '>=20.0.0'} has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -7442,10 +7465,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - luxon@3.5.0: - resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} - engines: {node: '>=12'} - luxon@3.6.1: resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} engines: {node: '>=12'} @@ -9457,6 +9476,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + typesense@2.1.0: + resolution: {integrity: sha512-a/IRTL+dRXlpRDU4UodyGj8hl5xBz3nKihVRd/KfSFAfFPGcpdX6lxIgwdXy3O6VLNNiEsN8YwIsPHQPVT0vNw==} + engines: {node: '>=18'} + peerDependencies: + '@babel/runtime': ^7.23.2 + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -9487,6 +9512,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.10.0: resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} engines: {node: '>=20.18.1'} @@ -12870,18 +12898,25 @@ snapshots: '@emnapi/runtime': 1.2.0 '@tybys/wasm-util': 0.9.0 + '@nestjs-labs/nestjs-ioredis@11.0.4(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(ioredis@5.4.1)': + dependencies: + '@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + ioredis: 5.4.1 + tslib: 2.8.1 + '@nestjs/bull-shared@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)': dependencies: '@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.53.2)': + '@nestjs/bullmq@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.61.0)': dependencies: '@nestjs/bull-shared': 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3) '@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.53.2 + bullmq: 5.61.0 tslib: 2.8.1 '@nestjs/cli@11.0.4(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)': @@ -14666,6 +14701,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@20.19.19': + dependencies: + undici-types: 6.21.0 + '@types/node@22.10.0': dependencies: undici-types: 6.20.0 @@ -14778,6 +14817,8 @@ snapshots: '@types/validator@13.12.0': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.5.14': dependencies: '@types/node': 22.13.4 @@ -15541,7 +15582,7 @@ snapshots: dependencies: semver: 7.7.2 - bullmq@5.53.2: + bullmq@5.61.0: dependencies: cron-parser: 4.9.0 ioredis: 5.4.1 @@ -15549,7 +15590,7 @@ snapshots: node-abort-controller: 3.1.1 semver: 7.7.2 tslib: 2.8.1 - uuid: 9.0.1 + uuid: 11.1.0 transitivePeerDependencies: - supports-color @@ -15873,7 +15914,7 @@ snapshots: cron-parser@4.9.0: dependencies: - luxon: 3.5.0 + luxon: 3.6.1 cron@4.3.0: dependencies: @@ -17085,10 +17126,10 @@ snapshots: hachure-fill@0.5.2: {} - happy-dom@15.11.7: + happy-dom@18.0.1: dependencies: - entities: 4.5.0 - webidl-conversions: 7.0.0 + '@types/node': 20.19.19 + '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 has-bigints@1.0.2: {} @@ -18176,8 +18217,6 @@ snapshots: dependencies: yallist: 4.0.0 - luxon@3.5.0: {} - luxon@3.6.1: {} magic-string@0.30.17: @@ -20495,6 +20534,15 @@ snapshots: typescript@5.7.3: {} + typesense@2.1.0(@babel/runtime@7.25.6): + dependencies: + '@babel/runtime': 7.25.6 + axios: 1.9.0 + loglevel: 1.9.1 + tslib: 2.8.1 + transitivePeerDependencies: + - debug + uc.micro@2.1.0: {} ufo@1.6.1: {} @@ -20520,6 +20568,8 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: {} + undici@7.10.0: {} unicode-canonical-property-names-ecmascript@2.0.0: {} From c9b1cad982a5ba1c4a09af332fc1f8ed88aa9f7a Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:39:30 +0100 Subject: [PATCH 4/8] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index a4a19f71..ce112343 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit a4a19f71e15e3770e6a4af24d54c198aba65254a +Subproject commit ce1123439bd7bfc4bc5626f6604e3fed5e759908 From 16c1e864af63391dd9d5516defe25b4250331d73 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:44:37 +0100 Subject: [PATCH 5/8] fix comment space --- .../src/core/page/services/page.service.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 3ffa2042..b3d08c6b 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -234,21 +234,28 @@ export class PageService { ); } - // update spaceId in shares if (pageIds.length > 0) { + // update spaceId in shares await trx .updateTable('shares') .set({ spaceId: spaceId }) .where('pageId', 'in', pageIds) .execute(); - } - // Update attachments - await this.attachmentRepo.updateAttachmentsByPageId( - { spaceId }, - pageIds, - trx, - ); + // Update comments + await trx + .updateTable('comments') + .set({ spaceId: spaceId }) + .where('pageId', 'in', pageIds) + .execute(); + + // Update attachments + await this.attachmentRepo.updateAttachmentsByPageId( + { spaceId }, + pageIds, + trx, + ); + } }); } From 3164b6981cb9a084e6f035270e97154ab661d155 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 7 Oct 2025 21:05:13 +0100 Subject: [PATCH 6/8] feat: api keys management (EE) (#1665) * feat: api keys (EE) * improvements * fix table * fix route * remove token suffix * api settings * Fix * fix * fix * fix --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 24 ++- apps/client/src/App.tsx | 4 + .../components/common/no-table-results.tsx | 5 +- .../components/settings/settings-queries.tsx | 15 ++ .../components/settings/settings-sidebar.tsx | 25 +++ .../components/api-key-created-modal.tsx | 72 +++++++++ .../ee/api-key/components/api-key-table.tsx | 143 ++++++++++++++++ .../components/create-api-key-modal.tsx | 153 ++++++++++++++++++ .../components/revoke-api-key-modal.tsx | 62 +++++++ .../components/update-api-key-modal.tsx | 80 +++++++++ apps/client/src/ee/api-key/index.ts | 11 ++ .../src/ee/api-key/pages/user-api-keys.tsx | 106 ++++++++++++ .../ee/api-key/pages/workspace-api-keys.tsx | 117 ++++++++++++++ .../src/ee/api-key/queries/api-key-query.ts | 97 +++++++++++ .../ee/api-key/services/api-key-service.ts | 32 ++++ .../src/ee/api-key/types/api-key.types.ts | 23 +++ .../group/components/create-group-form.tsx | 3 +- .../group/components/edit-group-form.tsx | 3 +- .../src/features/group/queries/group-query.ts | 13 +- apps/client/src/lib/types.ts | 1 + apps/client/src/main.tsx | 2 + apps/server/src/core/auth/dto/jwt-payload.ts | 8 + .../src/core/auth/services/token.service.ts | 27 +++- .../src/core/auth/strategies/jwt.strategy.ts | 42 ++++- .../abilities/workspace-ability.factory.ts | 3 + .../casl/interfaces/workspace-ability.type.ts | 4 +- .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 9 ++ .../migrations/20250912T101500-api-keys.ts | 30 ++++ .../database/pagination/pagination-options.ts | 5 + .../repos/workspace/workspace.repo.ts | 18 +++ apps/server/src/database/types/db.d.ts | 28 +++- .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- pnpm-lock.yaml | 21 +++ 36 files changed, 1176 insertions(+), 23 deletions(-) create mode 100644 apps/client/src/ee/api-key/components/api-key-created-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/api-key-table.tsx create mode 100644 apps/client/src/ee/api-key/components/create-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/components/update-api-key-modal.tsx create mode 100644 apps/client/src/ee/api-key/index.ts create mode 100644 apps/client/src/ee/api-key/pages/user-api-keys.tsx create mode 100644 apps/client/src/ee/api-key/pages/workspace-api-keys.tsx create mode 100644 apps/client/src/ee/api-key/queries/api-key-query.ts create mode 100644 apps/client/src/ee/api-key/services/api-key-service.ts create mode 100644 apps/client/src/ee/api-key/types/api-key.types.ts create mode 100644 apps/server/src/database/migrations/20250912T101500-api-keys.ts diff --git a/apps/client/package.json b/apps/client/package.json index 0cff05cc..37ecb93d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -17,6 +17,7 @@ "@emoji-mart/react": "^1.1.1", "@excalidraw/excalidraw": "0.18.0-864353b", "@mantine/core": "^8.1.3", + "@mantine/dates": "^8.3.2", "@mantine/form": "^8.1.3", "@mantine/hooks": "^8.1.3", "@mantine/modals": "^8.1.3", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6d9e548b..b89dcf61 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -533,5 +533,27 @@ "Remove image": "Remove image", "Failed to remove image": "Failed to remove image", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", - "Image removed successfully": "Image removed successfully" + "Image removed successfully": "Image removed successfully", + "API key": "API key", + "API key created successfully": "API key created successfully", + "API keys": "API keys", + "API management": "API management", + "Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key", + "Create API Key": "Create API Key", + "Custom expiration date": "Custom expiration date", + "Enter a descriptive token name": "Enter a descriptive token name", + "Expiration": "Expiration", + "Expired": "Expired", + "Expires": "Expires", + "I've saved my API key": "I've saved my API key", + "Last use": "Last Used", + "No API keys found": "No API keys found", + "No expiration": "No expiration", + "Revoke API key": "Revoke API key", + "Revoked successfully": "Revoked successfully", + "Select expiration date": "Select expiration date", + "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", + "Token name": "Token name", + "Update API key": "Update API key", + "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3995191d..7048f08a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import SpaceTrash from "@/pages/space/space-trash.tsx"; +import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; +import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; export default function App() { const { t } = useTranslation(); @@ -96,8 +98,10 @@ export default function App() { path={"account/preferences"} element={} /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/client/src/components/common/no-table-results.tsx b/apps/client/src/components/common/no-table-results.tsx index 124bbb9b..0f34fa2f 100644 --- a/apps/client/src/components/common/no-table-results.tsx +++ b/apps/client/src/components/common/no-table-results.tsx @@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next"; interface NoTableResultsProps { colSpan: number; + text?: string; } -export default function NoTableResults({ colSpan }: NoTableResultsProps) { +export default function NoTableResults({ colSpan, text }: NoTableResultsProps) { const { t } = useTranslation(); return ( - {t("No results found...")} + {text || t("No results found...")} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 2f3b46bd..bc528466 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getShares } from "@/features/share/services/share-service.ts"; +import { getApiKeys } from "@/ee/api-key"; export const prefetchWorkspaceMembers = () => { const params = { limit: 100, page: 1, query: "" } as QueryParams; @@ -65,3 +66,17 @@ export const prefetchShares = () => { queryFn: () => getShares({ page: 1, limit: 100 }), }); }; + +export const prefetchApiKeys = () => { + queryClient.prefetchQuery({ + queryKey: ["api-key-list", { page: 1 }], + queryFn: () => getApiKeys({ page: 1 }), + }); +}; + +export const prefetchApiKeyManagement = () => { + queryClient.prefetchQuery({ + queryKey: ["api-key-list", { page: 1 }], + queryFn: () => getApiKeys({ page: 1, adminView: true }), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index fe0c7e88..abd3f962 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx"; import { useAtom } from "jotai/index"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { + prefetchApiKeyManagement, + prefetchApiKeys, prefetchBilling, prefetchGroups, prefetchLicense, @@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [ icon: IconBrush, path: "/settings/account/preferences", }, + { + label: "API keys", + icon: IconKey, + path: "/settings/account/api-keys", + isCloud: true, + isEnterprise: true, + showDisabledInNonEE: true, + }, ], }, { @@ -90,6 +100,15 @@ const groupedData: DataGroup[] = [ { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, + { + label: "API management", + icon: IconKey, + path: "/settings/api-keys", + isCloud: true, + isEnterprise: true, + isAdmin: true, + showDisabledInNonEE: true, + }, ], }, { @@ -195,6 +214,12 @@ export default function SettingsSidebar() { case "Public sharing": prefetchHandler = prefetchShares; break; + case "API keys": + prefetchHandler = prefetchApiKeys; + break; + case "API management": + prefetchHandler = prefetchApiKeyManagement; + break; default: break; } diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx new file mode 100644 index 00000000..6a01ee3c --- /dev/null +++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx @@ -0,0 +1,72 @@ +import { + Modal, + Text, + Stack, + Alert, + Group, + Button, + TextInput, +} from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { IApiKey } from "@/ee/api-key"; +import CopyTextButton from "@/components/common/copy.tsx"; + +interface ApiKeyCreatedModalProps { + opened: boolean; + onClose: () => void; + apiKey: IApiKey; +} + +export function ApiKeyCreatedModal({ + opened, + onClose, + apiKey, +}: ApiKeyCreatedModalProps) { + const { t } = useTranslation(); + + if (!apiKey) return null; + + return ( + + + } + title={t("Important")} + color="red" + > + {t( + "Make sure to copy your API key now. You won't be able to see it again!", + )} + + +
+ + {t("API key")} + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx new file mode 100644 index 00000000..48757acc --- /dev/null +++ b/apps/client/src/ee/api-key/components/api-key-table.tsx @@ -0,0 +1,143 @@ +import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; +import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; +import { IApiKey } from "@/ee/api-key"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import React from "react"; +import NoTableResults from "@/components/common/no-table-results"; + +interface ApiKeyTableProps { + apiKeys: IApiKey[]; + isLoading?: boolean; + showUserColumn?: boolean; + onUpdate?: (apiKey: IApiKey) => void; + onRevoke?: (apiKey: IApiKey) => void; +} + +export function ApiKeyTable({ + apiKeys, + isLoading, + showUserColumn = false, + onUpdate, + onRevoke, +}: ApiKeyTableProps) { + const { t } = useTranslation(); + + const formatDate = (date: Date | string | null) => { + if (!date) return t("Never"); + return format(new Date(date), "MMM dd, yyyy"); + }; + + const isExpired = (expiresAt: string | null) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + return ( + + + + + {t("Name")} + {showUserColumn && {t("User")}} + {t("Last used")} + {t("Expires")} + {t("Created")} + + + + + + {apiKeys && apiKeys.length > 0 ? ( + apiKeys.map((apiKey: IApiKey, index: number) => ( + + + + {apiKey.name} + + + + {showUserColumn && apiKey.creator && ( + + + + + {apiKey.creator.name} + + + + )} + + + + {formatDate(apiKey.lastUsedAt)} + + + + + {apiKey.expiresAt ? ( + isExpired(apiKey.expiresAt) ? ( + + {t("Expired")} + + ) : ( + + {formatDate(apiKey.expiresAt)} + + ) + ) : ( + + {t("Never")} + + )} + + + + + {formatDate(apiKey.createdAt)} + + + + + + + + + + + + {onUpdate && ( + } + onClick={() => onUpdate(apiKey)} + > + {t("Rename")} + + )} + {onRevoke && ( + } + color="red" + onClick={() => onRevoke(apiKey)} + > + {t("Revoke")} + + )} + + + + + )) + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx new file mode 100644 index 00000000..cade36e8 --- /dev/null +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -0,0 +1,153 @@ +import { lazy, Suspense, useState } from "react"; +import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { z } from "zod"; +import { useTranslation } from "react-i18next"; +import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query"; +import { IconCalendar } from "@tabler/icons-react"; +import { IApiKey } from "@/ee/api-key"; + +const DateInput = lazy(() => + import("@mantine/dates").then((module) => ({ + default: module.DateInput, + })), +); + +interface CreateApiKeyModalProps { + opened: boolean; + onClose: () => void; + onSuccess: (response: IApiKey) => void; +} + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + expiresAt: z.string().optional(), +}); +type FormValues = z.infer; + +export function CreateApiKeyModal({ + opened, + onClose, + onSuccess, +}: CreateApiKeyModalProps) { + const { t } = useTranslation(); + const [expirationOption, setExpirationOption] = useState("30"); + const createApiKeyMutation = useCreateApiKeyMutation(); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + name: "", + expiresAt: "", + }, + }); + + const getExpirationDate = (): string | undefined => { + if (expirationOption === "never") { + return undefined; + } + if (expirationOption === "custom") { + return form.values.expiresAt; + } + const days = parseInt(expirationOption); + const date = new Date(); + date.setDate(date.getDate() + days); + return date.toISOString(); + }; + + const getExpirationLabel = (days: number) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const formatted = date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + return `${days} days (${formatted})`; + }; + + const expirationOptions = [ + { value: "30", label: getExpirationLabel(30) }, + { value: "60", label: getExpirationLabel(60) }, + { value: "90", label: getExpirationLabel(90) }, + { value: "365", label: getExpirationLabel(365) }, + { value: "custom", label: t("Custom") }, + { value: "never", label: t("No expiration") }, + ]; + + const handleSubmit = async (data: { + name?: string; + expiresAt?: string | Date; + }) => { + const groupData = { + name: data.name, + expiresAt: getExpirationDate(), + }; + + try { + const createdKey = await createApiKeyMutation.mutateAsync(groupData); + onSuccess(createdKey); + form.reset(); + onClose(); + } catch (err) { + // + } + }; + + const handleClose = () => { + form.reset(); + setExpirationOption("30"); + onClose(); + }; + + return ( + +
handleSubmit(values))}> + + + +