This commit is contained in:
Philipinho
2025-04-16 20:19:16 +01:00
parent 418e61614c
commit 5bdefda9c7
16 changed files with 412 additions and 151 deletions

View File

@ -9,17 +9,15 @@ import classes from "./theme-toggle.module.css";
export function ThemeToggle() { export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme(); const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light", { const computedColorScheme = useComputedColorScheme();
getInitialValueInEffect: true,
});
return ( return (
<Tooltip label="Toggle Color Scheme"> <Tooltip label="Toggle Color Scheme">
<ActionIcon <ActionIcon
variant="default" variant="default"
onClick={() => onClick={() => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light") setColorScheme(computedColorScheme === "light" ? "dark" : "light");
} }}
aria-label="Toggle color scheme" aria-label="Toggle color scheme"
> >
<IconSun className={classes.light} size={18} stroke={1.5} /> <IconSun className={classes.light} size={18} stroke={1.5} />

View File

@ -70,6 +70,10 @@
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
} }
.row:focus .node:global(.isFocused) {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.row { .row {
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;

View File

@ -0,0 +1,13 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from 'jotai/index';
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
"showTOC",
true,
);
export const mobileTableOfContentAsideAtom = atom<boolean>(false);
const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@ -1,31 +1,83 @@
import { import {
Button, Button,
Group, Group,
MantineSize,
Popover, Popover,
Switch, Switch,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { IconWorld } from "@tabler/icons-react"; import { IconWorld } from "@tabler/icons-react";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useShareStatusQuery } from "@/features/share/queries/share-query.ts"; import {
useCreateShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
export default function ShareModal() { export default function ShareModal() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { data } = useShareStatusQuery(extractPageSlugId(pageSlug)); const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
// pageIsShared means that the share exists and its level equals zero.
const pageIsShared = share && share.level === 0;
// if level is greater than zero, then it is a descendant page from a shared page
const isDescendantShared = share && share.level > 0;
const publicLink = const publicLink = `${getAppUrl()}/share/${share?.key}/${pageSlug}`;
window.location.protocol +'//' + window.location.host +
"/share/" +
data?.["share"]?.["key"] + // TODO: think of permissions
"/" + // controls should be read only for non space editors.
pageSlug;
// we could use the same shared content but have it have a share status
// when you unshare, we hide the rest menu
// todo, is public only if this is the shared page
// if this is not the shared page and include chdilren == false, then set it to false
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
if (share) {
setIsPagePublic(true);
} else {
setIsPagePublic(false);
}
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
createShareMutation.mutateAsync({ pageId: pageId });
setIsPagePublic(value);
// on create refetch share
};
const handleSubPagesChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
return ( return (
<Popover width={350} position="bottom" withArrow shadow="md"> <Popover width={350} position="bottom" withArrow shadow="md">
@ -39,50 +91,69 @@ export default function ShareModal() {
</Button> </Button>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<Group justify="space-between" wrap="nowrap" gap="xl"> {isDescendantShared ? (
<div> <Text>
<Text size="md">{t("Make page public")}</Text> {t("This page was shared via")} {share.sharedPage.title}
</div> </Text>
<ToggleShare isChecked={true}></ToggleShare> ) : (
</Group> <>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text>Share page</Text>
<Text size="xs" c="dimmed">
Make it public to the internet
</Text>
</div>
<Switch
onChange={handleChange}
defaultChecked={isPagePublic}
size="sm"
/>
</Group>
<Group my="sm" grow> {pageIsShared && (
<TextInput <>
variant="filled" <Group my="sm" grow>
value={publicLink} <TextInput
pointer variant="filled"
readOnly value={publicLink}
rightSection={<CopyTextButton text={publicLink} />} readOnly
/> rightSection={<CopyTextButton text={publicLink} />}
</Group> />
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text>{t("Include sub pages")}</Text>
<Text size="xs" c="dimmed">
Include children of this page
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text>{t("Enable search indexing")}</Text>
<Text size="xs" c="dimmed">
Allow search engine indexing
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
/>
</Group>
</>
)}
</>
)}
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
} }
interface PageWidthToggleProps {
isChecked: boolean;
size?: MantineSize;
label?: string;
}
export function ToggleShare({ isChecked, size, label }: PageWidthToggleProps) {
const { t } = useTranslation();
const [checked, setChecked] = useState(isChecked);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
setChecked(value);
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle share")}
/>
);
}

View File

@ -2,13 +2,12 @@ import React from "react";
import { import {
Affix, Affix,
AppShell, AppShell,
Burger,
Button, Button,
Group, Group,
ScrollArea, ScrollArea,
Text, Text,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import SharedTree from "@/features/share/components/shared-tree.tsx"; import SharedTree from "@/features/share/components/shared-tree.tsx";
@ -16,6 +15,18 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx"; import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
const MemoizedSharedTree = React.memo(SharedTree); const MemoizedSharedTree = React.memo(SharedTree);
@ -24,7 +35,15 @@ export default function ShareShell({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [opened, { toggle }] = useDisclosure(); const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [tocOpened] = useAtom(tableOfContentAsideAtom);
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const { shareId } = useParams(); const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId); const { data } = useGetSharedPageTreeQuery(shareId);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom); const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
@ -35,19 +54,51 @@ export default function ShareShell({
navbar={{ navbar={{
width: 300, width: 300,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !opened, desktop: false }, collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}} }}
aside={{ aside={{
width: 300, width: 300,
breakpoint: "sm", breakpoint: "md",
collapsed: { mobile: true, desktop: false }, collapsed: {
mobile: mobileTocOpened,
desktop: tocOpened,
},
}} }}
padding="md" padding="md"
> >
<AppShell.Header> <AppShell.Header>
<Group wrap="nowrap" justify="space-between" p="sm"> <Group wrap="nowrap" justify="space-between" p="sm">
<Burger opened={opened} onClick={toggle} size="sm" /> <Group>
<ThemeToggle /> {data?.pageTree?.length > 0 && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
</Group>
<Group>
<ThemeToggle />
</Group>
</Group> </Group>
</AppShell.Header> </AppShell.Header>

View File

@ -22,6 +22,8 @@ import { extractPageSlugId } from "@/lib";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import classes from "@/features/page/tree/styles/tree.module.css"; import classes from "@/features/page/tree/styles/tree.module.css";
import styles from "./share.module.css"; import styles from "./share.module.css";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
interface SharedTree { interface SharedTree {
sharedPageTree: ISharedPageTree; sharedPageTree: ISharedPageTree;
@ -40,6 +42,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>( const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
openSharedTreeNodesAtom, openSharedTreeNodesAtom,
); );
const currentNodeId = extractPageSlugId(pageSlug); const currentNodeId = extractPageSlugId(pageSlug);
const treeData: SharedPageTreeNode[] = useMemo(() => { const treeData: SharedPageTreeNode[] = useMemo(() => {
@ -99,6 +102,11 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
setOpenTreeNodes(tree?.openState); setOpenTreeNodes(tree?.openState);
}} }}
initialOpenState={openTreeNodes} initialOpenState={openTreeNodes}
onClick={(e) => {
if (tree && tree.focusedNode) {
tree.select(tree.focusedNode);
}
}}
> >
{Node} {Node}
</Tree> </Tree>
@ -108,9 +116,9 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
} }
function Node({ node, style, tree }: NodeRendererProps<any>) { function Node({ node, style, tree }: NodeRendererProps<any>) {
const navigate = useNavigate();
const { shareId } = useParams(); const { shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
const pageUrl = buildSharedPageUrl({ const pageUrl = buildSharedPageUrl({
shareId: shareId, shareId: shareId,
@ -125,6 +133,9 @@ function Node({ node, style, tree }: NodeRendererProps<any>) {
className={clsx(classes.node, node.state, styles.treeNode)} className={clsx(classes.node, node.state, styles.treeNode)}
component={Link} component={Link}
to={pageUrl} to={pageUrl}
onClick={() => {
setMobileSidebarState(false);
}}
> >
<PageArrow node={node} /> <PageArrow node={node} />
<span className={classes.text}>{node.data.name || t("untitled")}</span> <span className={classes.text}>{node.data.name || t("untitled")}</span>

View File

@ -2,23 +2,26 @@ import {
keepPreviousData, keepPreviousData,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient,
UseQueryResult, UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ICreateShare, ICreateShare,
ISharedItem, ISharedItem, ISharedPage,
ISharedPageTree, ISharedPageTree,
IShareForPage,
IShareInfoInput, IShareInfoInput,
} from "@/features/share/types/share.types.ts"; IUpdateShare,
} from '@/features/share/types/share.types.ts';
import { import {
createShare, createShare,
deleteShare, deleteShare,
getSharedPageTree, getSharedPageTree,
getShareForPage,
getShareInfo, getShareInfo,
getShares, getShares,
getShareStatus,
updateShare, updateShare,
} from "@/features/share/services/share-service.ts"; } from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
@ -36,7 +39,7 @@ export function useGetSharesQuery(
export function useShareQuery( export function useShareQuery(
shareInput: Partial<IShareInfoInput>, shareInput: Partial<IShareInfoInput>,
): UseQueryResult<IPage, Error> { ): UseQueryResult<ISharedPage, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["shares", shareInput], queryKey: ["shares", shareInput],
queryFn: () => getShareInfo(shareInput), queryFn: () => getShareInfo(shareInput),
@ -46,12 +49,12 @@ export function useShareQuery(
return query; return query;
} }
export function useShareStatusQuery( export function useShareForPageQuery(
pageId: string, pageId: string,
): UseQueryResult<IPage, Error> { ): UseQueryResult<IShareForPage, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["share-status", pageId], queryKey: ["share-for-page", pageId],
queryFn: () => getShareStatus(pageId), queryFn: () => getShareForPage(pageId),
enabled: !!pageId, enabled: !!pageId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
@ -63,7 +66,6 @@ export function useCreateShareMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation<any, Error, ICreateShare>({ return useMutation<any, Error, ICreateShare>({
mutationFn: (data) => createShare(data), mutationFn: (data) => createShare(data),
onSuccess: (data) => {},
onError: (error) => { onError: (error) => {
notifications.show({ message: t("Failed to share page"), color: "red" }); notifications.show({ message: t("Failed to share page"), color: "red" });
}, },
@ -71,8 +73,15 @@ export function useCreateShareMutation() {
} }
export function useUpdateShareMutation() { export function useUpdateShareMutation() {
return useMutation<any, Error, Partial<IShareInfoInput>>({ const queryClient = useQueryClient();
return useMutation<any, Error, IUpdateShare>({
mutationFn: (data) => updateShare(data), mutationFn: (data) => updateShare(data),
onSuccess: (data) => {
queryClient.refetchQueries({
predicate: (item) =>
["share-for-page"].includes(item.queryKey[0] as string),
});
},
}); });
} }

View File

@ -3,10 +3,12 @@ import { IPage } from "@/features/page/types/page.types";
import { import {
ICreateShare, ICreateShare,
ISharedItem, ISharedItem, ISharedPage,
ISharedPageTree, ISharedPageTree,
IShareForPage,
IShareInfoInput, IShareInfoInput,
} from "@/features/share/types/share.types.ts"; IUpdateShare,
} from '@/features/share/types/share.types.ts';
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getShares( export async function getShares(
@ -21,22 +23,20 @@ export async function createShare(data: ICreateShare): Promise<any> {
return req.data; return req.data;
} }
export async function getShareStatus(pageId: string): Promise<any> { export async function updateShare(data: IUpdateShare): Promise<any> {
const req = await api.post<any>("/shares/status", { pageId }); const req = await api.post<any>("/shares/update", data);
return req.data;
}
export async function getShareForPage(pageId: string): Promise<IShareForPage> {
const req = await api.post<any>("/shares/for-page", { pageId });
return req.data; return req.data;
} }
export async function getShareInfo( export async function getShareInfo(
shareInput: Partial<IShareInfoInput>, shareInput: Partial<IShareInfoInput>,
): Promise<IPage> { ): Promise<ISharedPage> {
const req = await api.post<IPage>("/shares/info", shareInput); const req = await api.post<ISharedPage>("/shares/page-info", shareInput);
return req.data;
}
export async function updateShare(
data: Partial<IShareInfoInput>,
): Promise<any> {
const req = await api.post<any>("/shares/update", data);
return req.data; return req.data;
} }

View File

@ -5,6 +5,7 @@ export interface IShare {
key: string; key: string;
pageId: string; pageId: string;
includeSubPages: boolean; includeSubPages: boolean;
searchIndexing: boolean;
creatorId: string; creatorId: string;
spaceId: string; spaceId: string;
workspaceId: string; workspaceId: string;
@ -32,11 +33,36 @@ export interface ISharedItem extends IShare {
}; };
} }
export interface ICreateShare { export interface ISharedPage extends IShare {
pageId: string; page: IPage;
includeSubPages?: boolean; share: IShare & {
level: number;
sharedPage: { id: string; slugId: string; title: string };
};
} }
export interface IShareForPage extends IShare {
level: number;
page: {
id: string;
title: string;
slugId: string;
};
sharedPage: {
id: string;
slugId: string;
title: string;
};
}
export interface ICreateShare {
pageId?: string;
includeSubPages?: boolean;
searchIndexing?: boolean;
}
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
export interface IShareInfoInput { export interface IShareInfoInput {
pageId: string; pageId: string;
} }

View File

@ -1,9 +1,9 @@
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useShareQuery } from "@/features/share/queries/share-query.ts"; import { useShareQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import React from "react"; import React, { useEffect } from "react";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
@ -11,19 +11,27 @@ import { Error404 } from "@/components/ui/error-404.tsx";
export default function SingleSharedPage() { export default function SingleSharedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const { const { data, isLoading, isError, error } = useShareQuery({
data: page, pageId: extractPageSlugId(pageSlug),
isLoading, });
isError,
error, useEffect(() => {
} = useShareQuery({ pageId: extractPageSlugId(pageSlug) }); if (shareId && data) {
if (data.share.key !== shareId) {
// affects parent share, what to do?
//navigate(`/share/${data.share.key}/${pageSlug}`);
}
}
}, [shareId, data]);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
} }
if (isError || !page) { if (isError || !data) {
if ([401, 403, 404].includes(error?.["status"])) { if ([401, 403, 404].includes(error?.["status"])) {
return <Error404 />; return <Error404 />;
} }
@ -33,14 +41,14 @@ export default function SingleSharedPage() {
return ( return (
<div> <div>
<Helmet> <Helmet>
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title> <title>{`${data?.page?.icon || ""} ${data?.page?.title || t("untitled")}`}</title>
</Helmet> </Helmet>
<Container size={900}> <Container size={900}>
<ReadonlyPageEditor <ReadonlyPageEditor
key={page.id} key={data.page.id}
title={page.title} title={data.page.title}
content={page.content} content={data.page.content}
/> />
</Container> </Container>
</div> </div>

View File

@ -1,10 +0,0 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateShareDto {
@IsString()
pageId: string;
@IsBoolean()
@IsOptional()
includeSubPages: boolean;
}

View File

@ -6,6 +6,30 @@ import {
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
export class CreateShareDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsBoolean()
@IsOptional()
includeSubPages: boolean;
@IsOptional()
@IsBoolean()
searchIndexing: boolean;
}
export class UpdateShareDto extends CreateShareDto {
@IsString()
@IsNotEmpty()
shareId: string;
@IsString()
@IsOptional()
pageId: string;
}
export class ShareIdDto { export class ShareIdDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateShareDto {
@IsString()
@IsNotEmpty()
shareId: string;
}

View File

@ -18,9 +18,13 @@ import {
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service'; import { ShareService } from './share.service';
import { UpdateShareDto } from './dto/update-page.dto'; import {
import { CreateShareDto } from './dto/create-share.dto'; CreateShareDto,
import { ShareIdDto, ShareInfoDto, SharePageIdDto } from './dto/share.dto'; ShareIdDto,
ShareInfoDto,
SharePageIdDto,
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
@ -48,8 +52,8 @@ export class ShareController {
@Public() @Public()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/page-info')
async getShare( async getSharedPageInfo(
@Body() dto: ShareInfoDto, @Body() dto: ShareInfoDto,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
@ -61,24 +65,40 @@ export class ShareController {
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/status') @Post('/info')
async getShareStatus( async getShare(@Body() dto: ShareIdDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(dto.shareId);
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return share;
}
@HttpCode(HttpStatus.OK)
@Post('/for-page')
async getShareForPage(
@Body() dto: SharePageIdDto, @Body() dto: SharePageIdDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const page = await this.pageRepo.findById(dto.pageId); const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
if (!page || workspace.id !== page.workspaceId) { throw new NotFoundException('Shared page not found');
throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.shareService.getShareStatus(page.id, workspace.id); return this.shareService.getShareForPage(page.id, workspace.id);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -103,7 +123,7 @@ export class ShareController {
page, page,
authUserId: user.id, authUserId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
includeSubPages: createShareDto.includeSubPages, createShareDto,
}); });
} }
@ -121,7 +141,7 @@ export class ShareController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
//return this.shareService.update(page, updatePageDto, user.id); return this.shareService.updateShare(share.id, updateShareDto);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@ -4,7 +4,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ShareInfoDto } from './dto/share.dto'; import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId } from '../../common/helpers'; import { generateSlugId } from '../../common/helpers';
@ -55,9 +55,9 @@ export class ShareService {
authUserId: string; authUserId: string;
workspaceId: string; workspaceId: string;
page: Page; page: Page;
includeSubPages: boolean; createShareDto: CreateShareDto;
}) { }) {
const { authUserId, workspaceId, page, includeSubPages } = opts; const { authUserId, workspaceId, page, createShareDto } = opts;
try { try {
const shares = await this.shareRepo.findByPageId(page.id); const shares = await this.shareRepo.findByPageId(page.id);
@ -68,19 +68,35 @@ export class ShareService {
return await this.shareRepo.insertShare({ return await this.shareRepo.insertShare({
key: generateSlugId(), key: generateSlugId(),
pageId: page.id, pageId: page.id,
includeSubPages: includeSubPages, includeSubPages: createShareDto.includeSubPages,
searchIndexing: true,
creatorId: authUserId, creatorId: authUserId,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId, workspaceId,
}); });
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new BadRequestException('Failed to create page'); throw new BadRequestException('Failed to share page');
}
}
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
try {
return this.shareRepo.updateShare(
{
includeSubPages: updateShareDto.includeSubPages,
searchIndexing: updateShareDto.searchIndexing,
},
shareId,
);
} catch (err) {
this.logger.error(err);
throw new BadRequestException('Failed to update share');
} }
} }
async getSharedPage(dto: ShareInfoDto, workspaceId: string) { async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
const share = await this.getShareStatus(dto.pageId, workspaceId); const share = await this.getShareForPage(dto.pageId, workspaceId);
if (!share) { if (!share) {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
@ -94,25 +110,33 @@ export class ShareService {
page.content = await this.updatePublicAttachments(page); page.content = await this.updatePublicAttachments(page);
if (!page) { if (!page) {
throw new NotFoundException('Page not found'); throw new NotFoundException('Shared page not found');
} }
return page; return { page, share };
} }
async getShareStatus(pageId: string, workspaceId: string) { async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor // here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const share = await this.db const share = await this.db
.withRecursive('page_hierarchy', (cte) => .withRecursive('page_hierarchy', (cte) =>
cte cte
.selectFrom('pages') .selectFrom('pages')
.select(['id', 'parentPageId', sql`0`.as('level')]) .select([
'id',
'slugId',
'pages.title',
'parentPageId',
sql`0`.as('level'),
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId) .where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.unionAll((union) => .unionAll((union) =>
union union
.selectFrom('pages as p') .selectFrom('pages as p')
.select([ .select([
'p.id', 'p.id',
'p.slugId',
'p.title',
'p.parentPageId', 'p.parentPageId',
// Increase the level by 1 for each ancestor. // Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'), sql`ph.level + 1`.as('level'),
@ -124,10 +148,13 @@ export class ShareService {
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id') .leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([ .select([
'page_hierarchy.id as sharedPageId', 'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.level as level', 'page_hierarchy.level as level',
'shares.id as shareId', 'shares.id',
'shares.key as shareKey', 'shares.key',
'shares.includeSubPages as includeSubPages', 'shares.pageId',
'shares.includeSubPages',
'shares.creatorId', 'shares.creatorId',
'shares.spaceId', 'shares.spaceId',
'shares.workspaceId', 'shares.workspaceId',
@ -147,7 +174,22 @@ export class ShareService {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
} }
return share; return {
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
pageId: share.pageId,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
},
};
} }
async getShareAncestorPage( async getShareAncestorPage(

View File

@ -98,6 +98,7 @@ export class ShareRepo {
.updateTable('shares') .updateTable('shares')
.set({ ...updatableShare, updatedAt: new Date() }) .set({ ...updatableShare, updatedAt: new Date() })
.where(!isValidUUID(shareId) ? 'key' : 'id', '=', shareId) .where(!isValidUUID(shareId) ? 'key' : 'id', '=', shareId)
.returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }