mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 07:12:04 +10:00
Compare commits
20 Commits
v0.20.3
...
69447fc375
| Author | SHA1 | Date | |
|---|---|---|---|
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 | |||
| c6bca6a602 | |||
| 55d1a2c932 | |||
| bc3cb2d63f | |||
| 7adbf85030 | |||
| de7982fe30 | |||
| 0402f7efb5 | |||
| 8327251ab6 | |||
| e8847bd9cd |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.20.3",
|
||||
"version": "0.20.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -29,6 +29,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.1",
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
|
||||
"Share deleted successfully": "Share deleted successfully",
|
||||
"Share not found": "Share not found",
|
||||
"Failed to share page": "Failed to share page"
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export enum BillingPlan {
|
||||
STANDARD = "standard",
|
||||
BUSINESS = "business",
|
||||
}
|
||||
|
||||
export interface IBilling {
|
||||
|
||||
@ -2,14 +2,18 @@ import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
||||
|
||||
export const usePlan = () => {
|
||||
const usePlan = () => {
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const isStandard =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
||||
|
||||
return { isStandard };
|
||||
const isBusiness =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
||||
|
||||
return { isStandard, isBusiness };
|
||||
};
|
||||
|
||||
export default usePlan;
|
||||
|
||||
@ -15,7 +15,7 @@ export default function EnforceSso() {
|
||||
<Text size="md">{t("Enforce SSO")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, members will not able able to login with email and password.",
|
||||
"Once enforced, members will not be able to login with email and password.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -10,11 +10,13 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const { isBusiness } = usePlan();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@ -35,8 +37,7 @@ export default function Security() {
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
{/*TODO: revisit when we add a second plan */}
|
||||
{!isCloud() && hasLicenseKey ? (
|
||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||
<>
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Button, Group, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentActionsProps = {
|
||||
@ -15,7 +15,7 @@ function CommentActions({
|
||||
isCommentEditor,
|
||||
}: CommentActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
return (
|
||||
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
||||
{isCommentEditor && (
|
||||
|
||||
@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
.run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
//unselect text to close bubble menu
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
}, 400);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
@ -109,6 +124,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
|
||||
<CommentEditor
|
||||
onUpdate={handleCommentEditorChange}
|
||||
onSave={handleAddComment}
|
||||
placeholder={t("Write a comment")}
|
||||
editable={true}
|
||||
autofocus={true}
|
||||
|
||||
@ -8,10 +8,12 @@ import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
onSave?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
|
||||
{
|
||||
defaultContent,
|
||||
onUpdate,
|
||||
onSave,
|
||||
editable,
|
||||
placeholder,
|
||||
autofocus,
|
||||
@ -42,7 +45,35 @@ const CommentEditor = forwardRef(
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
EmojiCommand,
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Enter",
|
||||
].includes(event.key)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
if (emojiCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (onSave) onSave();
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
@ -53,6 +84,10 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@ -15,12 +15,14 @@ import {
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment }: CommentListItemProps) {
|
||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -29,6 +31,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content)
|
||||
}, [comment]);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@ -39,6 +46,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
} finally {
|
||||
@ -50,11 +62,27 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("comment-highlight");
|
||||
setTimeout(() => {
|
||||
el.classList.remove("comment-highlight");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
@ -99,7 +127,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection && (
|
||||
<Box className={classes.textSelection}>
|
||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
@ -112,6 +140,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
defaultContent={content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => setContent(newContent)}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
function CommentList() {
|
||||
const { t } = useTranslation();
|
||||
@ -26,6 +27,7 @@ function CommentList() {
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
@ -38,6 +40,11 @@ function CommentList() {
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
@ -59,8 +66,8 @@ function CommentList() {
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||
<CommentListItem comment={comment} pageId={page?.id} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
@ -99,8 +106,9 @@ function CommentList() {
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
}
|
||||
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
@ -113,10 +121,11 @@ const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<CommentListItem comment={childComment} pageId={pageId} />
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -142,6 +151,7 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
|
||||
@ -11,22 +11,25 @@
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
placement: "bottom",
|
||||
});
|
||||
},
|
||||
onStart: (props: {
|
||||
|
||||
@ -3,6 +3,7 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@ -18,7 +19,7 @@ import {
|
||||
import clsx from "clsx";
|
||||
import classes from "./mention.module.css";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
@ -28,14 +29,28 @@ import {
|
||||
MentionListProps,
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { SimpleTree } from "react-arborist";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const { spaceSlug } = useParams();
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: props.query,
|
||||
@ -45,12 +60,23 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
return {
|
||||
id: null,
|
||||
label: label,
|
||||
entityType: "page",
|
||||
entityId: null,
|
||||
slugId: null,
|
||||
icon: null,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestion && !isLoading) {
|
||||
let items: MentionSuggestionItem[] = [];
|
||||
|
||||
if (suggestion?.users?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Users" });
|
||||
items.push({ entityType: "header", label: t("Users") });
|
||||
|
||||
items = items.concat(
|
||||
suggestion.users.map((user) => ({
|
||||
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
}
|
||||
|
||||
if (suggestion?.pages?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Pages" });
|
||||
items.push({ entityType: "header", label: t("Pages") });
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
})),
|
||||
);
|
||||
}
|
||||
items.push(createPageItem(props.query));
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page") {
|
||||
if (item.entityType === "page" && item.id!==null) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.label || "Untitled",
|
||||
@ -106,6 +133,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page" && item.id===null) {
|
||||
createPage(item.label);
|
||||
}
|
||||
}
|
||||
},
|
||||
[renderItems],
|
||||
@ -167,6 +197,58 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const createPage = async (title: string) => {
|
||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
||||
spaceId: space.id,
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
};
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
const parentId = page.id || null;
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
name: createdPage.title,
|
||||
position: createdPage.position,
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
children: [],
|
||||
} as any;
|
||||
|
||||
const lastIndex = tree.data.length;
|
||||
|
||||
tree.create({ parentId, index: lastIndex, data });
|
||||
setData(tree.data);
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: space.id,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
} catch (err) {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
}
|
||||
|
||||
// if no results and enter what to do?
|
||||
|
||||
useEffect(() => {
|
||||
@ -178,7 +260,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper shadow="md" p="xs" withBorder>
|
||||
No results
|
||||
{ t("No results") }
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||
</ActionIcon>
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@ -58,6 +58,7 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
import erlang from "highlight.js/lib/languages/erlang";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
@ -76,7 +77,7 @@ import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("abap", abap);
|
||||
lowlight.register("erlang", erlang);
|
||||
lowlight.register("elixir", elixir);
|
||||
lowlight.register("dockerfile", dockerfile);
|
||||
|
||||
@ -219,9 +219,12 @@ export default function PageEditor({
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
//wait if aside is closed
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -144,6 +144,19 @@
|
||||
border-bottom: 2px solid rgb(166, 158, 12);
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: flash-highlight 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-highlight {
|
||||
0% {
|
||||
background-color: #ff4d4d;
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(255, 215, 0, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
|
||||
.column-resize-handle {
|
||||
background-color: #adf;
|
||||
bottom: -2px;
|
||||
bottom: -1px;
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
pointer-events: none;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
@ -38,7 +38,7 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
@ -94,7 +94,7 @@ export function TitleEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
updatePageMutationAsync({
|
||||
updateTitlePageMutationAsync({
|
||||
pageId: pageId,
|
||||
title: titleEditor.getText(),
|
||||
}).then((page) => {
|
||||
@ -106,6 +106,10 @@ export function TitleEditor({
|
||||
payload: { title: page.title, slugId: page.slugId },
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
|
||||
updatePageData(page);
|
||||
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
});
|
||||
|
||||
105
apps/client/src/features/page/components/copy-page-modal.tsx
Normal file
105
apps/client/src/features/page/components/copy-page-modal.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
interface CopyPageModalProps {
|
||||
pageId: string;
|
||||
currentSpaceSlug: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CopyPageModal({
|
||||
pageId,
|
||||
currentSpaceSlug,
|
||||
open,
|
||||
onClose,
|
||||
}: CopyPageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [targetSpace, setTargetSpace] = useState<ISpace>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!targetSpace) return;
|
||||
|
||||
try {
|
||||
const copiedPage = await copyPageToSpace({
|
||||
pageId,
|
||||
spaceId: targetSpace.id,
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
predicate: (item) =>
|
||||
["pages", "sidebar-pages", "root-sidebar-pages"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
copiedPage.space.slug,
|
||||
copiedPage.slugId,
|
||||
copiedPage.title,
|
||||
);
|
||||
navigate(pageUrl);
|
||||
notifications.show({
|
||||
message: t("Page copied successfully"),
|
||||
});
|
||||
onClose();
|
||||
setTargetSpace(null);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
color: "red",
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (space: ISpace) => {
|
||||
setTargetSpace(space);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Text mb="xs" c="dimmed" size="sm">
|
||||
{t("Copy page to a different space.")}
|
||||
</Text>
|
||||
|
||||
<SpaceSelect
|
||||
value={currentSpaceSlug}
|
||||
clearable={false}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Group justify="end" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>{t("Copy")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ import {
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
@ -35,7 +35,7 @@ import {
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import ShareModal from '@/features/share/components/share-modal.tsx';
|
||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ShareModal readOnly={readOnly}/>
|
||||
<ShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
@ -106,7 +106,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page.updatedAt);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
|
||||
@ -46,6 +46,7 @@ export default function MovePageModal({
|
||||
message: t("Page moved successfully"),
|
||||
});
|
||||
onClose();
|
||||
setTargetSpace(null);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
@ -53,7 +54,6 @@ export default function MovePageModal({
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
setTargetSpace(null);
|
||||
};
|
||||
|
||||
const handleChange = (space: ISpace) => {
|
||||
@ -69,7 +69,7 @@ export default function MovePageModal({
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
@ -78,7 +78,9 @@ export default function MovePageModal({
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text>
|
||||
<Text mb="xs" c="dimmed" size="sm">
|
||||
{t("Move page to a different space.")}
|
||||
</Text>
|
||||
|
||||
<SpaceSelect
|
||||
value={currentSpaceSlug}
|
||||
|
||||
@ -63,28 +63,36 @@ export function useCreatePageMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||
"pages",
|
||||
data.slugId,
|
||||
]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateTitlePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||
"pages",
|
||||
data.slugId,
|
||||
]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
updatePage(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
ICopyPageToSpace,
|
||||
IExportPageParams,
|
||||
IMovePage,
|
||||
IMovePageToSpace,
|
||||
@ -39,6 +40,11 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||
await api.post<void>("/pages/move-to-space", data);
|
||||
}
|
||||
|
||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSidebarPages(
|
||||
params: SidebarPagesParams,
|
||||
): Promise<IPagination<IPage>> {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileDescription,
|
||||
IconFileExport,
|
||||
@ -60,6 +61,7 @@ import ExportModal from "@/components/common/export-modal";
|
||||
import MovePageModal from "../../components/move-page-modal.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -448,6 +450,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
movePageModalOpened,
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
copyPageModalOpened,
|
||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -511,6 +517,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
{t("Move")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
@ -536,6 +553,13 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
open={movePageModalOpened}
|
||||
/>
|
||||
|
||||
<CopyPageModal
|
||||
pageId={node.id}
|
||||
currentSpaceSlug={spaceSlug}
|
||||
onClose={closeCopySpaceModal}
|
||||
open={copyPageModalOpened}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={node.id}
|
||||
|
||||
@ -12,7 +12,7 @@ export interface IPage {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
lastUpdatedById: Date;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
@ -47,6 +47,11 @@ export interface IMovePageToSpace {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export interface ICopyPageToSpace {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
|
||||
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Affix, Button } from "@mantine/core";
|
||||
|
||||
export default function ShareBranding() {
|
||||
return (
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
@ -36,6 +36,7 @@ import {
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
const MemoizedSharedTree = React.memo(SharedTree);
|
||||
|
||||
@ -163,16 +164,7 @@ export default function ShareShell({
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside
|
||||
|
||||
@ -41,6 +41,7 @@ export interface ISharedPage extends IShare {
|
||||
level: number;
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
@ -70,4 +71,5 @@ export interface IShareInfoInput {
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ export function SpaceSelect({
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ width, withinPortal: false }}
|
||||
comboboxProps={{ width, withinPortal: true, position: "bottom" }}
|
||||
dropdownOpened={opened}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -7,6 +7,11 @@ export type InvalidateEvent = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type InvalidateCommentsEvent = {
|
||||
operation: "invalidateComment";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type UpdateEvent = {
|
||||
operation: "updateOne";
|
||||
spaceId: string;
|
||||
@ -52,4 +57,4 @@ export type DeleteTreeNodeEvent = {
|
||||
}
|
||||
};
|
||||
|
||||
export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
|
||||
@ -3,6 +3,7 @@ import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@ -21,6 +22,11 @@ export const useQuerySubscription = () => {
|
||||
queryKey: [...data.entity, data.id].filter(Boolean),
|
||||
});
|
||||
break;
|
||||
case "invalidateComment":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: RQ_KEY(data.pageId),
|
||||
});
|
||||
break;
|
||||
case "updateOne":
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
|
||||
@ -7,8 +7,9 @@ import React, { useEffect } from "react";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||
|
||||
export default function SingleSharedPage() {
|
||||
export default function SharedPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@ -53,6 +54,8 @@ export default function SingleSharedPage() {
|
||||
content={data.page.content}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{data && !shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.20.3",
|
||||
"version": "0.20.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
@ -22,6 +22,7 @@ import { LoggerExtension } from './extensions/logger.extension';
|
||||
imports: [TokenModule],
|
||||
})
|
||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CollaborationModule.name);
|
||||
private collabWsAdapter: CollabWsAdapter;
|
||||
private path = '/collab';
|
||||
|
||||
@ -38,7 +39,15 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
wss.on('connection', (client: WebSocket, request: IncomingMessage) => {
|
||||
this.collaborationGateway.handleConnection(client, request);
|
||||
|
||||
client.on('error', (error) => {
|
||||
this.logger.error('WebSocket client error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('error', (error) =>
|
||||
this.logger.log('WebSocket server error:', error),
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
|
||||
@ -46,7 +46,7 @@ export const tiptapExtensions = [
|
||||
codeBlock: false,
|
||||
}),
|
||||
Comment,
|
||||
TextAlign,
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
TaskItem,
|
||||
Underline,
|
||||
|
||||
@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
|
||||
);
|
||||
this.contributors.delete(documentName);
|
||||
} catch (err) {
|
||||
this.logger.log('Contributors error:' + err?.['message']);
|
||||
this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
import {
|
||||
jsonToNode,
|
||||
tiptapExtensions,
|
||||
} from '../../../collaboration/collaboration.util';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
import { Transform } from '@tiptap/pm/transform';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface MentionNode {
|
||||
id: string;
|
||||
@ -59,7 +64,6 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
||||
return pageMentionList as MentionNode[];
|
||||
}
|
||||
|
||||
|
||||
export function getProsemirrorContent(content: any) {
|
||||
return (
|
||||
content ?? {
|
||||
@ -107,4 +111,19 @@ export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
|
||||
|
||||
const tr = new Transform(doc).removeMark(0, doc.content.size, markType);
|
||||
return tr.doc;
|
||||
}
|
||||
}
|
||||
|
||||
export function createYdocFromJson(prosemirrorJson: any): Buffer | null {
|
||||
if (prosemirrorJson) {
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
24
apps/server/src/core/page/dto/copy-page.dto.ts
Normal file
24
apps/server/src/core/page/dto/copy-page.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CopyPageToSpaceDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export type CopyPageMapEntry = {
|
||||
newPageId: string;
|
||||
newSlugId: string;
|
||||
oldSlugId: string;
|
||||
};
|
||||
|
||||
export type ICopyPageAttachment = {
|
||||
newPageId: string,
|
||||
oldPageId: string,
|
||||
oldAttachmentId: string,
|
||||
newAttachmentId: string,
|
||||
};
|
||||
@ -1,4 +1,10 @@
|
||||
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
|
||||
export class MovePageDto {
|
||||
@IsString()
|
||||
@ -15,9 +21,11 @@ export class MovePageDto {
|
||||
}
|
||||
|
||||
export class MovePageToSpaceDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@ -237,6 +238,36 @@ export class PageController {
|
||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('copy-to-space')
|
||||
async copyPageToSpace(
|
||||
@Body() dto: CopyPageToSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!copiedPage) {
|
||||
throw new NotFoundException('Page to copy not found');
|
||||
}
|
||||
if (copiedPage.spaceId === dto.spaceId) {
|
||||
throw new BadRequestException('Page is already in this space');
|
||||
}
|
||||
|
||||
const abilities = await Promise.all([
|
||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||
]);
|
||||
|
||||
if (
|
||||
abilities.some((ability) =>
|
||||
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move')
|
||||
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
|
||||
|
||||
@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule]
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
executeWithPagination,
|
||||
@ -21,13 +22,28 @@ import { DB } from '@docmost/db/types/db';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import {
|
||||
createYdocFromJson,
|
||||
getAttachmentIds,
|
||||
getProsemirrorContent,
|
||||
isAttachmentNode,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
private readonly logger = new Logger(PageService.name);
|
||||
|
||||
constructor(
|
||||
private pageRepo: PageRepo,
|
||||
private attachmentRepo: AttachmentRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@ -242,6 +258,154 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
||||
//TODO:
|
||||
// i. maintain internal links within copied pages
|
||||
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
|
||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
const pageMap = new Map<string, CopyPageMapEntry>();
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
newPageId: uuid7(),
|
||||
newSlugId: generateSlugId(),
|
||||
oldSlugId: page.slugId,
|
||||
});
|
||||
});
|
||||
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment>();
|
||||
|
||||
const insertablePages: InsertablePage[] = await Promise.all(
|
||||
pages.map(async (page) => {
|
||||
const pageContent = getProsemirrorContent(page.content);
|
||||
const pageFromMap = pageMap.get(page.id);
|
||||
|
||||
const doc = jsonToNode(pageContent);
|
||||
const prosemirrorDoc = removeMarkTypeFromDoc(doc, 'comment');
|
||||
|
||||
const attachmentIds = getAttachmentIds(prosemirrorDoc.toJSON());
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
attachmentIds.forEach((attachmentId: string) => {
|
||||
const newPageId = pageFromMap.newPageId;
|
||||
const newAttachmentId = uuid7();
|
||||
attachmentMap.set(attachmentId, {
|
||||
newPageId: newPageId,
|
||||
oldPageId: page.id,
|
||||
oldAttachmentId: attachmentId,
|
||||
newAttachmentId: newAttachmentId,
|
||||
});
|
||||
|
||||
prosemirrorDoc.descendants((node: PMNode) => {
|
||||
if (isAttachmentNode(node.type.name)) {
|
||||
if (node.attrs.attachmentId === attachmentId) {
|
||||
//@ts-ignore
|
||||
node.attrs.attachmentId = newAttachmentId;
|
||||
|
||||
if (node.attrs.src) {
|
||||
//@ts-ignore
|
||||
node.attrs.src = node.attrs.src.replace(
|
||||
attachmentId,
|
||||
newAttachmentId,
|
||||
);
|
||||
}
|
||||
if (node.attrs.src) {
|
||||
//@ts-ignore
|
||||
node.attrs.src = node.attrs.src.replace(
|
||||
attachmentId,
|
||||
newAttachmentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
|
||||
return {
|
||||
id: pageFromMap.newPageId,
|
||||
slugId: pageFromMap.newSlugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
ydoc: createYdocFromJson(prosemirrorJson),
|
||||
position: page.id === rootPage.id ? nextPosition : page.position,
|
||||
spaceId: spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
creatorId: authUser.id,
|
||||
lastUpdatedById: authUser.id,
|
||||
parentPageId: page.parentPageId
|
||||
? pageMap.get(page.parentPageId)?.newPageId
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
||||
|
||||
//TODO: best to handle this in a queue
|
||||
const attachmentsIds = Array.from(attachmentMap.keys());
|
||||
if (attachmentsIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.where('id', 'in', attachmentsIds)
|
||||
.where('workspaceId', '=', rootPage.workspaceId)
|
||||
.execute();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const pageAttachment = attachmentMap.get(attachment.id);
|
||||
|
||||
// make sure the copied attachment belongs to the page it was copied from
|
||||
if (attachment.pageId !== pageAttachment.oldPageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAttachmentId = pageAttachment.newAttachmentId;
|
||||
|
||||
const newPageId = pageAttachment.newPageId;
|
||||
|
||||
const newPathFile = attachment.filePath.replace(
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.log(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||
return await this.pageRepo.findById(newPageId, {
|
||||
includeSpace: true,
|
||||
});
|
||||
}
|
||||
|
||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||
// validate position value by attempting to generate a key
|
||||
try {
|
||||
|
||||
@ -30,6 +30,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@ -39,6 +40,7 @@ export class ShareController {
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -61,7 +63,12 @@ export class ShareController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return this.shareService.getSharedPage(dto, workspace.id);
|
||||
return {
|
||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -166,6 +173,11 @@ export class ShareController {
|
||||
@Body() dto: ShareIdDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.shareService.getShareTree(dto.shareId, workspace.id);
|
||||
return {
|
||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -387,14 +387,14 @@ export class WorkspaceService {
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.substring(0, 20);
|
||||
// Ensure we leave room for a random suffix.
|
||||
const maxSuffixLength = 3;
|
||||
const maxSuffixLength = 6;
|
||||
|
||||
if (subdomain.length < 4) {
|
||||
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||
subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
let uniqueHostname = subdomain;
|
||||
|
||||
@ -70,7 +70,7 @@ export class UserTokenRepo {
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', tokenType)
|
||||
.orderBy('expiresAt desc')
|
||||
.orderBy('expiresAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ export class WorkspaceRepo {
|
||||
return await this.db
|
||||
.selectFrom('workspaces')
|
||||
.selectAll()
|
||||
.orderBy('createdAt asc')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
Submodule apps/server/src/ee updated: 4e7319ab01...b312008b4b
@ -25,6 +25,16 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
||||
try {
|
||||
if (await this.exists(fromFilePath)) {
|
||||
await fs.copy(fromFilePath, toFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async read(filePath: string): Promise<Buffer> {
|
||||
try {
|
||||
return await fs.readFile(this._fullPath(filePath));
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { S3StorageConfig, StorageDriver, StorageOption } from '../interfaces';
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
@ -39,6 +40,22 @@ export class S3Driver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
||||
try {
|
||||
if (await this.exists(fromFilePath)) {
|
||||
await this.s3Client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
CopySource: `${this.config.bucket}/${fromFilePath}`,
|
||||
Key: toFilePath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async read(filePath: string): Promise<Buffer> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
export interface StorageDriver {
|
||||
upload(filePath: string, file: Buffer): Promise<void>;
|
||||
|
||||
copy(fromFilePath: string, toFilePath: string): Promise<void>;
|
||||
|
||||
read(filePath: string): Promise<Buffer>;
|
||||
|
||||
exists(filePath: string): Promise<boolean>;
|
||||
|
||||
@ -14,6 +14,11 @@ export class StorageService {
|
||||
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
|
||||
}
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string) {
|
||||
await this.storageDriver.copy(fromFilePath, toFilePath);
|
||||
this.logger.debug(`File copied successfully. Path: ${toFilePath}`);
|
||||
}
|
||||
|
||||
async read(filePath: string): Promise<Buffer> {
|
||||
return this.storageDriver.read(filePath);
|
||||
}
|
||||
|
||||
@ -4,12 +4,7 @@ import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
RequestMethod,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
@ -92,6 +87,14 @@ async function bootstrap() {
|
||||
|
||||
const logger = new Logger('NestApplication');
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error(`UnhandledRejection: ${promise}, reason: ${reason}`);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('UncaughtException:', error);
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port, '0.0.0.0', () => {
|
||||
logger.log(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.20.3",
|
||||
"version": "0.20.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -257,6 +257,9 @@ importers:
|
||||
file-saver:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
highlightjs-sap-abap:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
i18next:
|
||||
specifier: ^23.14.0
|
||||
version: 23.14.0
|
||||
@ -5872,6 +5875,9 @@ packages:
|
||||
resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
highlightjs-sap-abap@0.3.0:
|
||||
resolution: {integrity: sha512-nSiUvEOCycjtFA3pHaTowrbAAk5+lciBHyoVkDsd6FTRBtW9sT2dt42o2jAKbXjZVUidtacdk+j0Y2xnd233Mw==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
@ -15436,6 +15442,8 @@ snapshots:
|
||||
|
||||
highlight.js@11.10.0: {}
|
||||
|
||||
highlightjs-sap-abap@0.3.0: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
Reference in New Issue
Block a user