Compare commits

...

30 Commits

Author SHA1 Message Date
e7651e4b8c switch position 2025-06-08 21:39:06 -07:00
b58dc6dee9 feat: toggle table header row and column 2025-05-22 11:30:36 -07:00
69447fc375 Merge branch 'main' of https://github.com/docmost/docmost 2025-05-21 08:43:56 -07:00
858ff9da06 sync 2025-05-20 09:27:30 -07:00
343b2976c2 #1186/chore: add support language abap syntax highlight (#1188) 2025-05-19 20:05:31 +01:00
7491224d0f hide shared page branding in EE (#1193)
* hide shared page branding in EE

* Hide branding in business plan
2025-05-17 19:17:34 +01:00
4a0b4040ed Add second plan (#1187) 2025-05-17 19:03:01 +01:00
e3ba817723 feat: comment editor emoji picker and ctrl+enter action (#1121)
* commenteditor-emoji-picker

* capture Mac command key
* remove tooltip

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-05-16 20:01:27 +01:00
b0491d5da4 feat: create new page from mention (#1153)
* init

* create page in relative parent root
2025-05-16 19:15:11 +01:00
1c200dbd0f fix(table-hover): adjust row height to prevent unexpected scrollbar on hover (#1124)
fix: Hover table style height error causing scrollbar to appear #1108
2025-05-16 16:26:05 +01:00
fb7e4a7956 fix: copy/move select (#1174) 2025-05-16 16:24:31 +01:00
1413033568 feat: realtime comments (#1144)
* init

* fix: close bubblemenu after comment and wait before scroll

* scroll to comment when click

* highlight comment animation
2025-05-16 16:18:23 +01:00
00f4588c21 fix title update (#1154) 2025-05-16 16:11:29 +01:00
3a75251e75 fix alignment in shared page (#1123) 2025-05-16 16:00:47 +01:00
c6bca6a602 fix deprecated kysely usage 2025-05-09 16:44:33 +01:00
55d1a2c932 Fix typo in enforce-sso.tsx (#1145) 2025-05-09 11:11:02 +01:00
bc3cb2d63f fix: increase random subdomain suffix 2025-05-07 15:10:58 +01:00
7adbf85030 v0.20.4 2025-04-30 14:44:58 +01:00
de7982fe30 feat: copy page to different space (#1118)
* Add copy page to space endpoint
* copy storage function
* copy function
* feat: copy attachments too
* Copy page - WIP
* fix type
* sync
* cleanup
2025-04-30 14:43:16 +01:00
0402f7efb5 sync 2025-04-30 14:33:01 +01:00
8327251ab6 fix typo 2025-04-29 23:30:12 +01:00
e8847bd9cd fix: handle unhandled exceptions (#1116)
* Handle unhandled exceptions
* cleanup
2025-04-29 23:29:00 +01:00
9bbd62e0f0 v0.20.3 2025-04-24 23:22:53 +01:00
0289c5cb09 Reduce markdown checkbox space 2025-04-24 23:19:39 +01:00
7993532111 fix page export (#1081) 2025-04-24 23:18:54 +01:00
31e5c0c660 v0.20.2 2025-04-24 17:57:14 +01:00
33c314d4e8 remove clickoutside hook 2025-04-24 17:56:54 +01:00
08f223899a cloud trial refactor 2025-04-23 16:07:58 +01:00
c528f7e858 v0.20.1 2025-04-23 14:34:28 +01:00
c26a851d52 feat: enhance public sharing (#1057)
* fix tree nodes sort

* remove comment mark in shares

* remove clickoutside hook for now

* feat: search in shared pages

* fix user-select

* use Link

* render page icons
2025-04-23 14:32:35 +01:00
79 changed files with 1262 additions and 175 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.20.0",
"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",

View File

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

View File

@ -14,7 +14,6 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useClickOutside, useMergedRef } from "@mantine/hooks";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
export default function GlobalAppShell({
@ -30,13 +29,6 @@ export default function GlobalAppShell({
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
const navbarOutsideRef = useClickOutside(() => {
if (mobileOpened) {
toggleMobile();
}
});
const mergedRef = useMergedRef(sidebarRef, navbarOutsideRef);
const startResizing = React.useCallback((mouseDownEvent) => {
mouseDownEvent.preventDefault();
@ -112,7 +104,7 @@ export default function GlobalAppShell({
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={mergedRef}
ref={sidebarRef}
>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />}

View File

@ -1,6 +1,7 @@
import { Alert } from "@mantine/core";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getBillingTrialDays } from '@/lib/config.ts';
export default function BillingTrial() {
const { data: billing, isLoading } = useBillingQuery();
@ -15,14 +16,14 @@ export default function BillingTrial() {
{trialDaysLeft > 0 && !billing && (
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
in your 7-day trial. Please subscribe to a plan before your trial
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
ends.
</Alert>
)}
{trialDaysLeft === 0 && (
<Alert title="Your Trial has ended" color="red" radius="md">
Your 7-day trial has come to an end. Please subscribe to a plan to
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
continue using this service.
</Alert>
)}

View File

@ -1,5 +1,6 @@
export enum BillingPlan {
STANDARD = "standard",
BUSINESS = "business",
}
export interface IBilling {

View File

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

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { isCloud } from "@/lib/config.ts";
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { notifications } from "@mantine/notifications";
@ -18,7 +18,7 @@ export const useTrialEndAction = () => {
notifications.show({
position: "top-right",
color: "red",
title: "Your 7-day trial has ended",
title: `Your ${getBillingTrialDays()}-day trial has ended`,
message:
"Please upgrade to a paid plan or contact your workspace admin.",
autoClose: false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ const renderEmojiItems = () => {
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
placement: "bottom",
});
},
onStart: (props: {

View File

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

View File

@ -17,9 +17,9 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconRowRemove, IconTableColumn, IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
} from '@tabler/icons-react';
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
editor.chain().focus().toggleHeaderColumn().run();
}, [editor]);
const toggleHeaderRow = useCallback(() => {
editor.chain().focus().toggleHeaderRow().run();
}, [editor]);
const addColumnLeft = useCallback(() => {
editor.chain().focus().addColumnBefore().run();
}, [editor]);
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header row")}
>
<ActionIcon
onClick={toggleHeaderRow}
variant="default"
size="lg"
aria-label={t("Toggle header row")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}
>
<ActionIcon
onClick={toggleHeaderColumn}
variant="default"
size="lg"
aria-label={t("Toggle header column")}
>
<IconTableColumn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete table")}>
<ActionIcon
onClick={deleteTable}

View File

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

View File

@ -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(() => {

View File

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

View File

@ -47,7 +47,7 @@
.column-resize-handle {
background-color: #adf;
bottom: -2px;
bottom: -1px;
position: absolute;
right: -2px;
pointer-events: none;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
.root {
height: 34px;
padding-left: var(--mantine-spacing-sm);
padding-right: 4px;
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-placeholder);
border: 1px solid;
@mixin light {
border-color: var(--mantine-color-gray-3);
background-color: var(--mantine-color-white);
}
@mixin dark {
border-color: var(--mantine-color-dark-4);
background-color: var(--mantine-color-dark-6);
}
@mixin rtl {
padding-left: 4px;
padding-right: var(--mantine-spacing-sm);
}
}
.shortcut {
font-size: 11px;
line-height: 1;
padding: 4px 7px;
border-radius: var(--mantine-radius-sm);
border: 1px solid;
font-weight: bold;
@mixin light {
color: var(--mantine-color-gray-7);
border-color: var(--mantine-color-gray-2);
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
color: var(--mantine-color-dark-0);
border-color: var(--mantine-color-dark-7);
background-color: var(--mantine-color-dark-7);
}
}

View File

@ -0,0 +1,56 @@
import { IconSearch } from "@tabler/icons-react";
import cx from "clsx";
import {
ActionIcon,
BoxProps,
ElementProps,
Group,
rem,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./search-control.module.css";
import React from "react";
import { useTranslation } from "react-i18next";
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
export function SearchControl({ className, ...others }: SearchControlProps) {
const { t } = useTranslation();
return (
<UnstyledButton {...others} className={cx(classes.root, className)}>
<Group gap="xs" wrap="nowrap">
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
<Text fz="sm" c="dimmed" pr={80}>
{t("Search")}
</Text>
<Text fw={700} className={classes.shortcut}>
Ctrl + K
</Text>
</Group>
</UnstyledButton>
);
}
interface SearchMobileControlProps {
onSearch: () => void;
}
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
const { t } = useTranslation();
return (
<Tooltip label={t("Search")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={onSearch}
size="sm"
>
<IconSearch size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}

View File

@ -0,0 +1,7 @@
import { createSpotlight } from '@mantine/spotlight';
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
createSpotlight();

View File

@ -1,6 +1,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchPage,
searchShare,
searchSuggestions,
} from "@/features/search/services/search-service";
import {
@ -30,3 +31,13 @@ export function useSearchSuggestionsQuery(
enabled: !!params.query,
});
}
export function useShareSearchQuery(
params: IPageSearchParams,
): UseQueryResult<IPageSearch[], Error> {
return useQuery({
queryKey: ["share-search", params],
queryFn: () => searchShare(params),
enabled: !!params.query,
});
}

View File

@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "./constants";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const {
data: searchResults,
isLoading,
error,
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
const { data: searchResults } = usePageSearchQuery({
query: debouncedSearchQuery,
spaceId,
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
onClick={() =>
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
}
component={Link}
//@ts-ignore
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return (
<>
<Spotlight.Root
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable

View File

@ -19,3 +19,10 @@ export async function searchSuggestions(
const req = await api.post<ISuggestionResult>("/search/suggest", params);
return req.data;
}
export async function searchShare(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data;
}

View File

@ -0,0 +1,87 @@
import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { useShareSearchQuery } from "@/features/search/queries/search-query";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
interface ShareSearchSpotlightProps {
shareId?: string;
}
export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const { data: searchResults } = useShareSearchQuery({
query: debouncedSearchQuery,
shareId,
});
const pages = (
searchResults && searchResults.length > 0 ? searchResults : []
).map((page) => (
<Spotlight.Action
key={page.id}
component={Link}
//@ts-ignore
to={buildSharedPageUrl({
shareId: shareId,
pageTitle: page.title,
pageSlugId: page.slugId,
})}
style={{ userSelect: "none" }}
>
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>
{page?.highlight && (
<Text
opacity={0.6}
size="xs"
dangerouslySetInnerHTML={{ __html: page.highlight }}
/>
)}
</div>
</Group>
</Spotlight.Action>
));
return (
<>
<Spotlight.Root
store={shareSearchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}

View File

@ -35,4 +35,5 @@ export interface ISuggestionResult {
export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
}

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

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
import {
ActionIcon,
Affix,
@ -30,7 +30,13 @@ import {
import { IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import { useClickOutside } from "@mantine/hooks";
import {
SearchControl,
SearchMobileControl,
} 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);
@ -54,21 +60,9 @@ export default function ShareShell({
const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
const [navbarOutside, setNavbarOutside] = useState<HTMLElement | null>(null);
useClickOutside(
() => {
if (mobileOpened) {
toggleMobile();
}
},
null,
[navbarOutside],
);
return (
<AppShell
header={{ height: 48 }}
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
@ -91,7 +85,7 @@ export default function ShareShell({
>
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group>
<Group wrap="nowrap">
{data?.pageTree?.length > 1 && (
<>
<Tooltip label={t("Sidebar toggle")}>
@ -116,8 +110,21 @@ export default function ShareShell({
</>
)}
</Group>
{shareId && (
<Group visibleFrom="sm">
<SearchControl onClick={shareSearchSpotlight.open} />
</Group>
)}
<Group>
<>
{shareId && (
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
</Group>
)}
<Tooltip label={t("Table of contents")} withArrow>
<ActionIcon
variant="default"
@ -149,11 +156,7 @@ export default function ShareShell({
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar
p="md"
className={classes.navbar}
ref={setNavbarOutside}
>
<AppShell.Navbar p="md" className={classes.navbar}>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
@ -161,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
@ -186,6 +180,8 @@ export default function ShareShell({
</div>
</ScrollArea>
</AppShell.Aside>
<ShareSearchSpotlight shareId={shareId} />
</AppShell>
);
}

View File

@ -15,6 +15,7 @@ import clsx from "clsx";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { ActionIcon, Box } from "@mantine/core";
@ -23,6 +24,7 @@ import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import classes from "@/features/page/tree/styles/tree.module.css";
import styles from "./share.module.css";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
interface SharedTree {
sharedPageTree: ISharedPageTree;
@ -141,6 +143,20 @@ function Node({ node, style, tree }: NodeRendererProps<any>) {
}}
>
<PageArrow node={node} />
<div style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={() => {}}
icon={
node.data.icon ? (
node.data.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={true}
removeEmojiAction={() => {}}
/>
</div>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
</Box>
</>

View File

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

View File

@ -11,11 +11,13 @@ export type SharedPageTreeNode = {
parentPageId: string;
hasChildren: boolean;
children: SharedPageTreeNode[];
label: string,
value: string,
label: string;
value: string;
};
export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode[] {
export function buildSharedPageTree(
pages: Partial<IPage[]>,
): SharedPageTreeNode[] {
const pageMap: Record<string, SharedPageTreeNode> = {};
// Initialize each page as a tree node and store it in a map.
@ -30,7 +32,7 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
hasChildren: false,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
label: page.title || 'untitled',
label: page.title || "untitled",
value: page.id,
children: [],
};
@ -55,6 +57,12 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
}
});
// Return the sorted tree.
return sortPositionKeys(tree);
function sortTree(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
return sortPositionKeys(nodes).map((node: SharedPageTreeNode) => ({
...node,
children: sortTree(node.children),
}));
}
return sortTree(tree);
}

View File

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

View File

@ -6,7 +6,6 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconArrowDown,
IconDots,
@ -16,9 +15,8 @@ import {
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react";
import React from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
@ -40,6 +38,7 @@ import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { searchSpotlight } from "@/features/search/constants";
export function SpaceSidebar() {
const { t } = useTranslation();
@ -51,7 +50,7 @@ export function SpaceSidebar() {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
@ -100,7 +99,10 @@ export function SpaceSidebar() {
</div>
</UnstyledButton>
<UnstyledButton className={classes.menu} onClick={spotlight.open}>
<UnstyledButton
className={classes.menu}
onClick={searchSpotlight.open}
>
<div className={classes.menuItemInner}>
<IconSearch
size={18}

View File

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

View File

@ -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") {

View File

@ -74,6 +74,10 @@ export function getDrawioUrl() {
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
}
export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS");
}
function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

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

View File

@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => {
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
} = loadEnv(mode, envPath, "");
return {
@ -23,6 +24,7 @@ export default defineConfig(({ mode }) => {
CLOUD,
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.20.0",
"version": "0.20.4",
"description": "",
"author": "",
"private": true,

View File

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

View File

@ -46,7 +46,7 @@ export const tiptapExtensions = [
codeBlock: false,
}),
Comment,
TextAlign,
TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList,
TaskItem,
Underline,

View File

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

View File

@ -1,6 +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;
@ -58,7 +64,6 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
return pageMentionList as MentionNode[];
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
@ -94,4 +99,31 @@ export function getAttachmentIds(prosemirrorJson: any) {
});
return attachmentIds;
}
}
export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
const { schema } = doc.type;
const markType = schema.marks[markName];
if (!markType) {
return doc;
}
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;
}

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,11 @@ import {
IsOptional,
IsString,
} from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
export class SearchDTO {
@IsNotEmpty()
@IsString()
query: string;
@ -14,6 +17,10 @@ export class SearchDTO {
@IsString()
spaceId: string;
@IsOptional()
@IsString()
shareId?: string;
@IsOptional()
@IsString()
creatorId?: string;
@ -27,6 +34,16 @@ export class SearchDTO {
offset?: number;
}
export class SearchShareDTO extends SearchDTO {
@IsNotEmpty()
@IsString()
shareId: string;
@IsOptional()
@IsString()
spaceId: string;
}
export class SearchSuggestionDTO {
@IsString()
query: string;

View File

@ -1,15 +1,19 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotImplementedException,
Post,
UseGuards,
} from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import {
SearchDTO,
SearchShareDTO,
SearchSuggestionDTO,
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
@ -19,6 +23,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
@UseGuards(JwtAuthGuard)
@Controller('search')
@ -30,7 +35,13 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
@ -40,12 +51,12 @@ export class SearchController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.searchService.searchPage(searchDto.query, searchDto);
}
// TODO: search all spaces user is a member of if no spaceId provided
throw new NotImplementedException();
return this.searchService.searchPage(searchDto.query, searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@ -57,4 +68,21 @@ export class SearchController {
) {
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('share-search')
async searchShare(
@Body() searchDto: SearchShareDTO,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
if (!searchDto.shareId) {
throw new BadRequestException('shareId is required');
}
return this.searchService.searchPage(searchDto.query, searchDto, {
workspaceId: workspace.id,
});
}
}

View File

@ -6,6 +6,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -15,19 +16,24 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
query: string,
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> {
if (query.length < 1) {
return;
}
const searchQuery = tsquery(query.trim() + '*');
const queryResults = await this.db
let queryResults = this.db
.selectFrom('pages')
.select([
'id',
@ -43,18 +49,71 @@ export class SearchService {
'highlight',
),
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('spaceId', '=', searchParams.spaceId)
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.orderBy('rank', 'desc')
.limit(searchParams.limit | 20)
.offset(searchParams.offset || 0)
.execute();
.offset(searchParams.offset || 0);
const searchResults = queryResults.map((result) => {
if (!searchParams.shareId) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
if (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{
includeContent: false,
},
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
if (pageIdsToSearch.length > 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else {
return [];
}
//@ts-ignore
queryResults = await queryResults.execute();
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')

View File

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

View File

@ -15,6 +15,7 @@ import {
getAttachmentIds,
getProsemirrorContent,
isAttachmentNode,
removeMarkTypeFromDoc,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@ -223,11 +224,7 @@ export class ShareService {
.end()
.as('found'),
])
.where(
isValidUUID(childPageId) ? 'id' : 'slugId',
'=',
childPageId,
)
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
@ -292,6 +289,7 @@ export class ShareService {
updateAttachmentAttr(node, 'url', token);
});
return doc.toJSON();
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
return removeCommentMarks.toJSON();
}
}

View File

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

View File

@ -244,7 +244,7 @@ export class PageRepo {
'p.spaceId',
'p.workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),
)

View File

@ -70,7 +70,7 @@ export class UserTokenRepo {
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', tokenType)
.orderBy('expiresAt desc')
.orderBy('expiresAt', 'desc')
.execute();
}

View File

@ -70,7 +70,7 @@ export class WorkspaceRepo {
return await this.db
.selectFrom('workspaces')
.selectAll()
.orderBy('createdAt asc')
.orderBy('createdAt', 'asc')
.limit(1)
.executeTakeFirst();
}

View File

@ -68,7 +68,7 @@ function taskList(turndownService: TurndownService) {
) as HTMLInputElement;
const isChecked = checkbox.checked;
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
},
});
}

View File

@ -42,6 +42,9 @@ export class StaticModule implements OnModuleInit {
? this.environmentService.getSubdomainHost()
: undefined,
COLLAB_URL: this.environmentService.getCollabUrl(),
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays()
: undefined,
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.20.0",
"version": "0.20.4",
"private": true,
"scripts": {
"build": "nx run-many -t build",

8
pnpm-lock.yaml generated
View File

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