Compare commits

..

8 Commits

Author SHA1 Message Date
549d9e225c cleanup 2025-04-30 14:39:10 +01:00
6f6d0ec82f sync 2025-04-30 14:32:19 +01:00
7e1ef7e84e fix type 2025-04-29 13:32:18 +01:00
ea1ec7cbe7 Copy page - WIP 2025-04-28 21:17:21 +01:00
73c2555e27 feat: copy attachments too 2025-04-27 22:10:36 +01:00
338d9dad56 copy function 2025-04-27 15:36:18 +01:00
0296d97d72 copy storage function 2025-04-27 15:34:43 +01:00
ed30f69023 Add copy page to space endpoint 2025-04-26 18:10:01 +01:00
24 changed files with 52 additions and 160 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.20.4", "version": "0.20.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",

View File

@ -15,7 +15,7 @@ export default function EnforceSso() {
<Text size="md">{t("Enforce SSO")}</Text> <Text size="md">{t("Enforce SSO")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t( {t(
"Once enforced, members will not be able to login with email and password.", "Once enforced, members will not able able to login with email and password.",
)} )}
</Text> </Text>
</div> </div>

View File

@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps { interface CommentDialogProps {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@ -36,8 +35,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation; const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => { const handleDialogClose = () => {
setShowCommentPopup(false); setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run(); editor.chain().focus().unsetCommentDecoration().run();
@ -66,23 +63,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run(); .run();
setActiveCommentId(createdComment.id); 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 }); setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => { setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`; const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); commentElement?.scrollIntoView();
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
}); });
} finally { } finally {
setShowCommentPopup(false); setShowCommentPopup(false);

View File

@ -53,10 +53,6 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
if (autofocus) { if (autofocus) {

View File

@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core"; import { Group, Text, Box } from "@mantine/core";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@ -15,14 +15,12 @@ import {
import { IComment } from "@/features/comment/types/comment.types"; import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentListItemProps { interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string;
} }
function CommentListItem({ comment, pageId }: CommentListItemProps) { function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -31,11 +29,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
useEffect(() => {
setContent(comment.content)
}, [comment]);
async function handleUpdateComment() { async function handleUpdateComment() {
try { try {
@ -46,11 +39,6 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false); setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to update comment:", error); console.error("Failed to update comment:", error);
} finally { } finally {
@ -62,27 +50,11 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
try { try {
await deleteCommentMutation.mutateAsync(comment.id); await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id); editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) { } catch (error) {
console.error("Failed to delete comment:", 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() { function handleEditToggle() {
setIsEditing(true); setIsEditing(true);
} }
@ -127,7 +99,7 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
<div> <div>
{!comment.parentCommentId && comment?.selection && ( {!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}> <Box className={classes.textSelection}>
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
)} )}

View File

@ -14,7 +14,6 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
function CommentList() { function CommentList() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -27,7 +26,6 @@ function CommentList() {
} = useCommentsQuery({ pageId: page?.id, limit: 100 }); } = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const handleAddReply = useCallback( const handleAddReply = useCallback(
async (commentId: string, content: string) => { async (commentId: string, content: string) => {
@ -40,11 +38,6 @@ function CommentList() {
}; };
await createCommentMutation.mutateAsync(commentData); await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) { } catch (error) {
console.error("Failed to post comment:", error); console.error("Failed to post comment:", error);
} finally { } finally {
@ -66,8 +59,8 @@ function CommentList() {
data-comment-id={comment.id} data-comment-id={comment.id}
> >
<div> <div>
<CommentListItem comment={comment} pageId={page?.id} /> <CommentListItem comment={comment} />
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} /> <MemoizedChildComments comments={comments} parentId={comment.id} />
</div> </div>
<Divider my={4} /> <Divider my={4} />
@ -106,9 +99,8 @@ function CommentList() {
interface ChildCommentsProps { interface ChildCommentsProps {
comments: IPagination<IComment>; comments: IPagination<IComment>;
parentId: string; parentId: string;
pageId: string;
} }
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => { const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
const getChildComments = useCallback( const getChildComments = useCallback(
(parentId: string) => (parentId: string) =>
comments.items.filter( comments.items.filter(
@ -121,11 +113,10 @@ const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
<div> <div>
{getChildComments(parentId).map((childComment) => ( {getChildComments(parentId).map((childComment) => (
<div key={childComment.id}> <div key={childComment.id}>
<CommentListItem comment={childComment} pageId={pageId} /> <CommentListItem comment={childComment} />
<MemoizedChildComments <MemoizedChildComments
comments={comments} comments={comments}
parentId={childComment.id} parentId={childComment.id}
pageId={pageId}
/> />
</div> </div>
))} ))}

View File

@ -11,7 +11,6 @@
border-left: 2px solid var(--mantine-color-gray-6); border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px; padding: 8px;
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
cursor: pointer;
} }
.commentEditor { .commentEditor {

View File

@ -219,12 +219,9 @@ export default function PageEditor({
setActiveCommentId(commentId); setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
//wait if aside is closed
setTimeout(() => {
const selector = `div[data-comment-id="${commentId}"]`; const selector = `div[data-comment-id="${commentId}"]`;
const commentElement = document.querySelector(selector); const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); commentElement?.scrollIntoView();
}, 400);
}; };
useEffect(() => { useEffect(() => {

View File

@ -144,19 +144,6 @@
border-bottom: 2px solid rgb(166, 158, 12); 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 { .resize-cursor {
cursor: ew-resize; cursor: ew-resize;
cursor: col-resize; cursor: col-resize;

View File

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

View File

@ -10,7 +10,7 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query"; import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -38,7 +38,7 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation(); const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
@ -94,7 +94,7 @@ export function TitleEditor({
return; return;
} }
updateTitlePageMutationAsync({ updatePageMutationAsync({
pageId: pageId, pageId: pageId,
title: titleEditor.getText(), title: titleEditor.getText(),
}).then((page) => { }).then((page) => {
@ -106,10 +106,6 @@ export function TitleEditor({
payload: { title: page.title, slugId: page.slugId }, payload: { title: page.title, slugId: page.slugId },
}; };
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event); localEmitter.emit("message", event);
emit(event); emit(event);
}); });

View File

@ -63,7 +63,12 @@ export function useCreatePageMutation() {
}); });
} }
export function updatePageData(data: IPage) { export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
const pageBySlug = queryClient.getQueryData<IPage>([ const pageBySlug = queryClient.getQueryData<IPage>([
"pages", "pages",
data.slugId, data.slugId,
@ -80,19 +85,6 @@ export function updatePageData(data: IPage) {
if (pageById) { if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); 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) => {
updatePage(data);
}, },
}); });
} }

View File

@ -81,7 +81,7 @@ export function SpaceSelect({
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom" }} comboboxProps={{ width, withinPortal: false }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );

View File

@ -7,11 +7,6 @@ export type InvalidateEvent = {
id?: string; id?: string;
}; };
export type InvalidateCommentsEvent = {
operation: "invalidateComment";
pageId: string;
};
export type UpdateEvent = { export type UpdateEvent = {
operation: "updateOne"; operation: "updateOne";
spaceId: string; spaceId: string;
@ -57,4 +52,4 @@ export type DeleteTreeNodeEvent = {
} }
}; };
export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent; export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;

View File

@ -3,7 +3,6 @@ import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { WebSocketEvent } from "@/features/websocket/types"; import { WebSocketEvent } from "@/features/websocket/types";
import { RQ_KEY } from "../comment/queries/comment-query";
export const useQuerySubscription = () => { export const useQuerySubscription = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -22,11 +21,6 @@ export const useQuerySubscription = () => {
queryKey: [...data.entity, data.id].filter(Boolean), queryKey: [...data.entity, data.id].filter(Boolean),
}); });
break; break;
case "invalidateComment":
queryClient.invalidateQueries({
queryKey: RQ_KEY(data.pageId),
});
break;
case "updateOne": case "updateOne":
entity = data.entity[0]; entity = data.entity[0];
if (entity === "pages") { if (entity === "pages") {

View File

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

View File

@ -1,4 +1,4 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension'; import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension'; import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway'; import { CollaborationGateway } from './collaboration.gateway';
@ -22,7 +22,6 @@ import { LoggerExtension } from './extensions/logger.extension';
imports: [TokenModule], imports: [TokenModule],
}) })
export class CollaborationModule implements OnModuleInit, OnModuleDestroy { export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CollaborationModule.name);
private collabWsAdapter: CollabWsAdapter; private collabWsAdapter: CollabWsAdapter;
private path = '/collab'; private path = '/collab';
@ -39,15 +38,7 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
wss.on('connection', (client: WebSocket, request: IncomingMessage) => { wss.on('connection', (client: WebSocket, request: IncomingMessage) => {
this.collaborationGateway.handleConnection(client, request); 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> { async onModuleDestroy(): Promise<void> {

View File

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

View File

@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
); );
this.contributors.delete(documentName); this.contributors.delete(documentName);
} catch (err) { } catch (err) {
this.logger.debug('Contributors error:' + err?.['message']); this.logger.log('Contributors error:' + err?.['message']);
} }
await this.pageRepo.updatePage( await this.pageRepo.updatePage(

View File

@ -387,14 +387,14 @@ export class WorkspaceService {
.replace(/[^a-z0-9]/g, '') .replace(/[^a-z0-9]/g, '')
.substring(0, 20); .substring(0, 20);
// Ensure we leave room for a random suffix. // Ensure we leave room for a random suffix.
const maxSuffixLength = 6; const maxSuffixLength = 3;
if (subdomain.length < 4) { if (subdomain.length < 4) {
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`; subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
} }
if (DISALLOWED_HOSTNAMES.includes(subdomain)) { if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`; subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
} }
let uniqueHostname = subdomain; let uniqueHostname = subdomain;

View File

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

View File

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

View File

@ -4,7 +4,12 @@ import {
FastifyAdapter, FastifyAdapter,
NestFastifyApplication, NestFastifyApplication,
} from '@nestjs/platform-fastify'; } from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common'; import {
Logger,
NotFoundException,
RequestMethod,
ValidationPipe,
} from '@nestjs/common';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor'; import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter'; import { InternalLogFilter } from './common/logger/internal-log-filter';
@ -87,14 +92,6 @@ async function bootstrap() {
const logger = new Logger('NestApplication'); 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; const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => { await app.listen(port, '0.0.0.0', () => {
logger.log( logger.log(

View File

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