diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 7f809a4e..135fc9ea 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -353,5 +353,9 @@ "Word count: {{wordCount}}": "Word count: {{wordCount}}", "Character count: {{characterCount}}": "Character count: {{characterCount}}", "New update": "New update", - "{{latestVersion}} is available": "{{latestVersion}} is available" + "{{latestVersion}} is available": "{{latestVersion}} is available", + "Move": "Move", + "Move page": "Move page", + "Move page to a different space.": "Move page to a different space.", + "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying..." } diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 5d15a4ce..a53094d4 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -65,11 +65,12 @@ export default function ExportModal({ yOffset="10vh" xOffset={0} mah={400} + onClick={(e) => e.stopPropagation()} > - Export {type} + {t(`Export ${type}`)} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index ef1748ea..4883b52b 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -1,5 +1,6 @@ import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core"; import { + IconArrowRight, IconArrowsHorizontal, IconDots, IconFileExport, @@ -31,11 +32,13 @@ import { yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts"; +import MovePageModal from "@/features/page/components/move-page-modal.tsx"; interface PageHeaderMenuProps { readOnly?: boolean; } export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { + const { t } = useTranslation(); const toggleAside = useToggleAside(); const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); @@ -43,7 +46,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { <> {yjsConnectionStatus === "disconnected" && ( @@ -83,6 +86,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { const [tree] = useAtom(treeApiAtom); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); + const [ + movePageModalOpened, + { open: openMovePageModal, close: closeMoveSpaceModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const handleCopyLink = () => { @@ -147,6 +154,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { + {!readOnly && ( + } + onClick={openMovePageModal} + > + {t("Move")} + + )} + } onClick={openExportModal} @@ -217,6 +233,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { open={exportOpened} onClose={closeExportModal} /> + + ); } diff --git a/apps/client/src/features/page/components/move-page-modal.tsx b/apps/client/src/features/page/components/move-page-modal.tsx new file mode 100644 index 00000000..4788951b --- /dev/null +++ b/apps/client/src/features/page/components/move-page-modal.tsx @@ -0,0 +1,98 @@ +import { Modal, Button, Group, Text } from "@mantine/core"; +import { movePageToSpace } 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 MovePageModalProps { + pageId: string; + slugId: string; + currentSpaceSlug: string; + open: boolean; + onClose: () => void; +} + +export default function MovePageModal({ + pageId, + slugId, + currentSpaceSlug, + open, + onClose, +}: MovePageModalProps) { + const { t } = useTranslation(); + const [targetSpace, setTargetSpace] = useState(null); + const navigate = useNavigate(); + + const handlePageMove = async () => { + if (!targetSpace) return; + + try { + await movePageToSpace({ pageId, spaceId: targetSpace.id }); + queryClient.removeQueries({ + predicate: (item) => + ["pages", "sidebar-pages", "root-sidebar-pages"].includes( + item.queryKey[0] as string, + ), + }); + + const pageUrl = buildPageUrl(targetSpace.slug, slugId, undefined); + navigate(pageUrl); + notifications.show({ + message: t("Page moved successfully"), + }); + onClose(); + } catch (err) { + notifications.show({ + message: err.response?.data.message || "An error occurred", + color: "red", + }); + console.log(err); + } + setTargetSpace(null); + }; + + const handleChange = (space: ISpace) => { + setTargetSpace(space); + }; + + return ( + e.stopPropagation()} + > + + + + {t("Move page")} + + + + {t("Move page to a different space.")} + + + + + + + + + + ); +} diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index e2f50321..29bff87f 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -2,6 +2,7 @@ import api from "@/lib/api-client"; import { IExportPageParams, IMovePage, + IMovePageToSpace, IPage, IPageInput, SidebarPagesParams, @@ -34,6 +35,10 @@ export async function movePage(data: IMovePage): Promise { await api.post("/pages/move", data); } +export async function movePageToSpace(data: IMovePageToSpace): Promise { + await api.post("/pages/move-to-space", data); +} + export async function getSidebarPages( params: SidebarPagesParams, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 03a0685f..c099691b 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -7,11 +7,12 @@ import { usePageQuery, useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; -import React, { useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; import { ActionIcon, Menu, rem } from "@mantine/core"; import { + IconArrowRight, IconChevronDown, IconChevronRight, IconDotsVertical, @@ -56,6 +57,7 @@ import { extractPageSlugId } from "@/lib"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; +import MovePageModal from "../../components/move-page-modal.tsx"; interface SpaceTreeProps { spaceId: string; @@ -234,6 +236,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const emit = useQueryEmit(); const { spaceSlug } = useParams(); const timerRef = useRef(null); + const { t } = useTranslation(); const prefetchPage = () => { timerRef.current = setTimeout(() => { @@ -369,7 +372,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { /> - {node.data.name || "untitled"} + {node.data.name || t("untitled")}
@@ -434,6 +437,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { const { openDeleteModal } = useDeletePageModal(); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); + const [ + movePageModalOpened, + { open: openMovePageModal, close: closeMoveSpaceModal }, + ] = useDisclosure(false); const handleCopyLink = () => { const pageUrl = @@ -486,8 +493,18 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { {!(treeApi.props.disableEdit as boolean) && ( <> - + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openMovePageModal(); + }} + > + {t("Move")} + + } @@ -504,6 +521,14 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { + + void; + onChange: (value: ISpace) => void; value?: string; label?: string; + width?: number; + opened?: boolean; + clearable?: boolean; } const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
- {option.label} + + {option.label} +
); -export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) { +export function SpaceSelect({ + onChange, + label, + value, + width, + opened, + clearable, +}: SpaceSelectProps) { const { t } = useTranslation(); const [searchValue, setSearchValue] = useState(""); const [debouncedQuery] = useDebouncedValue(searchValue, 500); @@ -42,8 +54,8 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) { }); const filteredSpaceData = spaceData.filter( - (user) => - !data.find((existingUser) => existingUser.value === user.value), + (space) => + !data.find((existingSpace) => existingSpace.value === space.value), ); setData((prevData) => [...prevData, ...filteredSpaceData]); } @@ -59,14 +71,18 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) { searchable searchValue={searchValue} onSearchChange={setSearchValue} - clearable + clearable={clearable} variant="filled" - onChange={onChange} + onChange={(slug) => + onChange(spaces.items?.find((item) => item.slug === slug)) + } + // duct tape + onClick={(e) => e.stopPropagation()} nothingFoundMessage={t("No space found")} limit={50} checkIconPosition="right" - comboboxProps={{ width: 300, withinPortal: false }} - dropdownOpened + comboboxProps={{ width, withinPortal: false }} + dropdownOpened={opened} /> ); } diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index dfc92530..dc47b778 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -55,7 +55,9 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) { handleSelect(space.slug)} + width={300} + opened={true} /> diff --git a/apps/client/src/features/user/components/change-email.tsx b/apps/client/src/features/user/components/change-email.tsx index f5ad36e8..873d0744 100644 --- a/apps/client/src/features/user/components/change-email.tsx +++ b/apps/client/src/features/user/components/change-email.tsx @@ -70,7 +70,6 @@ function ChangeEmailForm() { function handleSubmit(data: FormValues) { setIsLoading(true); - console.log(data); } return ( diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts index 83ff080c..236a4044 100644 --- a/apps/server/src/core/page/dto/move-page.dto.ts +++ b/apps/server/src/core/page/dto/move-page.dto.ts @@ -13,3 +13,11 @@ export class MovePageDto { @IsString() parentPageId?: string | null; } + +export class MovePageToSpaceDto { + @IsString() + pageId: string; + + @IsString() + spaceId: string; +} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index ec2a086d..79424509 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -7,11 +7,12 @@ import { UseGuards, ForbiddenException, NotFoundException, + BadRequestException, } from '@nestjs/common'; import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; -import { MovePageDto } from './dto/move-page.dto'; +import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto'; import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; @@ -93,11 +94,7 @@ export class PageController { throw new ForbiddenException(); } - return this.pageService.update( - page, - updatePageDto, - user.id, - ); + return this.pageService.update(page, updatePageDto, user.id); } @HttpCode(HttpStatus.OK) @@ -210,6 +207,36 @@ export class PageController { return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId); } + @HttpCode(HttpStatus.OK) + @Post('move-to-space') + async movePageToSpace( + @Body() dto: MovePageToSpaceDto, + @AuthUser() user: User, + ) { + const movedPage = await this.pageRepo.findById(dto.pageId); + if (!movedPage) { + throw new NotFoundException('Page to move not found'); + } + if (movedPage.spaceId === dto.spaceId) { + throw new BadRequestException('Page is already in this space'); + } + + const abilities = await Promise.all([ + this.spaceAbility.createForUser(user, movedPage.spaceId), + this.spaceAbility.createForUser(user, dto.spaceId), + ]); + + if ( + abilities.some((ability) => + ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), + ) + ) { + throw new ForbiddenException(); + } + + return this.pageService.movePageToSpace(movedPage, dto.spaceId); + } + @HttpCode(HttpStatus.OK) @Post('move') async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 7ca54197..43d8f1d2 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -19,11 +19,14 @@ import { MovePageDto } from '../dto/move-page.dto'; import { ExpressionBuilder } from 'kysely'; 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'; @Injectable() export class PageService { constructor( private pageRepo: PageRepo, + private attachmentRepo: AttachmentRepo, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -60,12 +63,31 @@ export class PageService { parentPageId = parentPage.id; } + const createdPage = await this.pageRepo.insertPage({ + slugId: generateSlugId(), + title: createPageDto.title, + position: await this.nextPagePosition( + createPageDto.spaceId, + parentPageId, + ), + icon: createPageDto.icon, + parentPageId: parentPageId, + spaceId: createPageDto.spaceId, + creatorId: userId, + workspaceId: workspaceId, + lastUpdatedById: userId, + }); + + return createdPage; + } + + async nextPagePosition(spaceId: string, parentPageId?: string) { let pagePosition: string; const lastPageQuery = this.db .selectFrom('pages') - .select(['id', 'position']) - .where('spaceId', '=', createPageDto.spaceId) + .select(['position']) + .where('spaceId', '=', spaceId) .orderBy('position', 'desc') .limit(1); @@ -96,19 +118,7 @@ export class PageService { } } - const createdPage = await this.pageRepo.insertPage({ - slugId: generateSlugId(), - title: createPageDto.title, - position: pagePosition, - icon: createPageDto.icon, - parentPageId: parentPageId, - spaceId: createPageDto.spaceId, - creatorId: userId, - workspaceId: workspaceId, - lastUpdatedById: userId, - }); - - return createdPage; + return pagePosition; } async update( @@ -192,6 +202,36 @@ export class PageService { return result; } + async movePageToSpace(rootPage: Page, spaceId: string) { + await executeTx(this.db, async (trx) => { + // Update root page + const nextPosition = await this.nextPagePosition(spaceId); + await this.pageRepo.updatePage( + { spaceId, parentPageId: null, position: nextPosition }, + rootPage.id, + trx, + ); + const pageIds = await this.pageRepo + .getPageAndDescendants(rootPage.id) + .then((pages) => pages.map((page) => page.id)); + // The first id is the root page id + if (pageIds.length > 1) { + // Update sub pages + await this.pageRepo.updatePages( + { spaceId }, + pageIds.filter((id) => id !== rootPage.id), + trx, + ); + } + // Update attachments + await this.attachmentRepo.updateAttachmentsByPageId( + { spaceId }, + pageIds, + trx, + ); + }); + } + async movePage(dto: MovePageDto, movedPage: Page) { // validate position value by attempting to generate a key try { diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index 59df7fe2..784e6c84 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -55,6 +55,18 @@ export class AttachmentRepo { .execute(); } + updateAttachmentsByPageId( + updatableAttachment: UpdatableAttachment, + pageIds: string[], + trx?: KyselyTransaction, + ) { + return dbOrTx(this.db, trx) + .updateTable('attachments') + .set(updatableAttachment) + .where('pageId', 'in', pageIds) + .executeTakeFirst(); + } + async updateAttachment( updatableAttachment: UpdatableAttachment, attachmentId: string, diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 317c3e07..d6fff24d 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -96,18 +96,19 @@ export class PageRepo { pageId: string, trx?: KyselyTransaction, ) { - const db = dbOrTx(this.db, trx); - let query = db + return this.updatePages(updatablePage, [pageId], trx); + } + + async updatePages( + updatePageData: UpdatablePage, + pageIds: string[], + trx?: KyselyTransaction, + ) { + return dbOrTx(this.db, trx) .updateTable('pages') - .set({ ...updatablePage, updatedAt: new Date() }); - - if (isValidUUID(pageId)) { - query = query.where('id', '=', pageId); - } else { - query = query.where('slugId', '=', pageId); - } - - return query.executeTakeFirst(); + .set({ ...updatePageData, updatedAt: new Date() }) + .where(pageIds.some(pageId => !isValidUUID(pageId)) ? "slugId" : "id", "in", pageIds) + .executeTakeFirst(); } async insertPage(