diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bbc6a702..094a0e4c 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -362,5 +362,22 @@ "Move page to a different space.": "Move page to a different space.", "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", "Table of contents": "Table of contents", - "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents." + "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.", + "Share": "Share", + "Public sharing": "Public sharing", + "Shared by": "Shared by", + "Shared at": "Shared at", + "Inherits public sharing from": "Inherits public sharing from", + "Publicly shared": "Publicly shared", + "Share to web": "Share to web", + "Anyone with the link can view this page": "Anyone with the link can view this page", + "Make this page publicly accessible": "Make this page publicly accessible", + "Include sub-pages": "Include sub-pages", + "Make sub-pages public too": "Make sub-pages public too", + "Allow search engines to index page": "Allow search engines to index page", + "Open page": "Open page", + "Page": "Page", + "Revoke public link": "Revoke public link", + "Are you sure you want to revoke this public link?": "Are you sure you want to revoke this public link?", + "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" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index b5ad0877..6bc5a778 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -86,7 +86,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index ef586c37..1a0835a4 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -82,7 +82,7 @@ const groupedData: DataGroup[] = [ }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, - { label: "Sharing", icon: IconWorld, path: "/settings/shares" }, + { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, ], }, @@ -172,7 +172,7 @@ export default function SettingsSidebar() { case "Security & SSO": prefetchHandler = prefetchSsoProviders; break; - case "Sharing": + case "Public sharing": prefetchHandler = prefetchShares; break; default: 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 5f7bfc26..9267ad9e 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 @@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} - + { const pageLink = buildPageUrl( @@ -38,7 +38,7 @@ export default function ShareActionMenu({ share }: Props) { const copyLink = () => { const shareLink = buildSharedPageUrl({ - shareId: share.includeSubPages ? share.key : undefined, + shareId: share.key, pageTitle: share.page.title, pageSlugId: share.page.slugId, }); @@ -47,19 +47,19 @@ export default function ShareActionMenu({ share }: Props) { notifications.show({ message: t("Link copied") }); }; const onRevoke = async () => { - // + deleteShareMutation.mutateAsync(share.key); }; const openRevokeModal = () => modals.openConfirmModal({ - title: t("Unshare page"), + title: t("Revoke public link"), children: ( - {t("Are you sure you want to unshare this page?")} + {t("Are you sure you want to revoke this public link?")} ), centered: true, - labels: { confirm: t("Unshare"), cancel: t("Don't") }, + labels: { confirm: t("Revoke"), cancel: t("Don't") }, confirmProps: { color: "red" }, onConfirm: onRevoke, }); @@ -95,9 +95,9 @@ export default function ShareActionMenu({ share }: Props) { c="red" onClick={openRevokeModal} leftSection={} - disabled={!isAdmin} + disabled={share.space?.userRole === "reader"} > - {t("Unshare")} + {t("Revoke")} diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx index b76de332..d5acbbd6 100644 --- a/apps/client/src/features/share/components/share-list.tsx +++ b/apps/client/src/features/share/components/share-list.tsx @@ -43,7 +43,7 @@ export default function ShareList() { component={Link} target="_blank" to={buildSharedPageUrl({ - shareId: share.includeSubPages ? share.key : undefined, + shareId: share.key, pageTitle: share.page.title, pageSlugId: share.page.slugId, })} @@ -52,7 +52,7 @@ export default function ShareList() { {getPageIcon(share.page.icon)}
- {share.page.title} + {share.page.title || t("untitled")}
diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx index ece6864d..4b3f4d55 100644 --- a/apps/client/src/features/share/components/share-modal.tsx +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -1,31 +1,42 @@ import { - Button, + ActionIcon, + Anchor, Group, + Indicator, Popover, Switch, Text, TextInput, + Tooltip, } from "@mantine/core"; import { IconWorld } from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; import { useCreateShareMutation, + useDeleteShareMutation, useShareForPageQuery, useUpdateShareMutation, } from "@/features/share/queries/share-query.ts"; -import { useParams } from "react-router-dom"; -import { extractPageSlugId } from "@/lib"; +import { Link, useParams } from "react-router-dom"; +import { extractPageSlugId, getPageIcon } from "@/lib"; import { useTranslation } from "react-i18next"; import CopyTextButton from "@/components/common/copy.tsx"; import { getAppUrl } from "@/lib/config.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import classes from "@/features/share/components/share.module.css"; -export default function ShareModal() { +interface ShareModalProps { + readOnly: boolean; +} +export default function ShareModal({ readOnly }: ShareModalProps) { const { t } = useTranslation(); const { pageSlug } = useParams(); const pageId = extractPageSlugId(pageSlug); const { data: share } = useShareForPageQuery(pageId); + const { spaceSlug } = useParams(); const createShareMutation = useCreateShareMutation(); const updateShareMutation = useUpdateShareMutation(); + const deleteShareMutation = useDeleteShareMutation(); // 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 @@ -33,16 +44,6 @@ export default function ShareModal() { const publicLink = `${getAppUrl()}/share/${share?.key}/${pageSlug}`; - - // TODO: think of permissions - // controls should be read only for non space editors. - - - // 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(false); useEffect(() => { if (share) { @@ -54,9 +55,20 @@ export default function ShareModal() { const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; - createShareMutation.mutateAsync({ pageId: pageId }); - setIsPagePublic(value); - // on create refetch share + + if (value) { + createShareMutation.mutateAsync({ + pageId: pageId, + includeSubPages: true, + searchIndexing: true, + }); + setIsPagePublic(value); + } else { + if (share && share.id) { + deleteShareMutation.mutateAsync(share.id); + setIsPagePublic(value); + } + } }; const handleSubPagesChange = async ( @@ -82,32 +94,74 @@ export default function ShareModal() { return ( - + + + + + + + {isDescendantShared ? ( - - {t("This page was shared via")} {share.sharedPage.title} - + <> + {t("Inherits public sharing from")} + + + {getPageIcon(share.sharedPage.icon)} +
+ + {share.sharedPage.title || t("untitled")} + +
+
+
+ + + } + /> + + ) : ( <>
- Share page + + {isPagePublic ? t("Publicly shared") : t("Share to web")} + - Make it public to the internet + {isPagePublic + ? t("Anyone with the link can view this page") + : t("Make this page publicly accessible")}
@@ -124,29 +178,32 @@ export default function ShareModal() {
- {t("Include sub pages")} + {t("Include sub-pages")} - Include children of this page + {t("Make sub-pages public too")}
+
- {t("Enable search indexing")} + {t("Search engine indexing")} - Allow search engine indexing + {t("Allow search engines to index page")}
diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index 7df03c07..6e0d9aed 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -6,7 +6,6 @@ import { Button, Group, ScrollArea, - Text, Tooltip, } from "@mantine/core"; import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; @@ -56,14 +55,16 @@ export default function ShareShell({ return ( 1 && { + navbar: { + width: 300, + breakpoint: "sm", + collapsed: { + mobile: !mobileOpened, + desktop: !desktopOpened, + }, }, - }} + })} aside={{ width: 300, breakpoint: "sm", @@ -77,7 +78,7 @@ export default function ShareShell({ - {data?.pageTree?.length > 0 && ( + {data?.pageTree?.length > 1 && ( <> - {data?.pageTree?.length > 0 && ( + {data?.pageTree?.length > 1 && ( diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index b7550e02..5e85ab57 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -7,7 +7,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { useElementSize, useMergedRef } from "@mantine/hooks"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { atom, useAtom } from "jotai/index"; import { useTranslation } from "react-i18next"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; @@ -22,7 +22,6 @@ import { extractPageSlugId } from "@/lib"; 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 { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; interface SharedTree { @@ -54,12 +53,16 @@ export default function SharedTree({ sharedPageTree }: SharedTree) { const parentNodeId = treeData?.[0]?.slugId; if (parentNodeId && tree) { + const parentNode = tree.get(parentNodeId); + setTimeout(() => { - tree.openSiblings(tree.get(parentNodeId)); + if (parentNode) { + tree.openSiblings(parentNode); + } }); // open direct children of parent node - tree.get(parentNodeId).children.forEach((node) => { + parentNode?.children.forEach((node) => { tree.openSiblings(node); }); } @@ -84,7 +87,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
{rootElement.current && ( getShareForPage(pageId), enabled: !!pageId, - staleTime: 5 * 60 * 1000, + staleTime: 0, + retry: false, }); return query; @@ -64,8 +67,16 @@ export function useShareForPageQuery( export function useCreateShareMutation() { const { t } = useTranslation(); + const queryClient = useQueryClient(); + return useMutation({ mutationFn: (data) => createShare(data), + onSuccess: (data) => { + queryClient.invalidateQueries({ + predicate: (item) => + ["share-for-page", "share-list"].includes(item.queryKey[0] as string), + }); + }, onError: (error) => { notifications.show({ message: t("Failed to share page"), color: "red" }); }, @@ -77,9 +88,22 @@ export function useUpdateShareMutation() { return useMutation({ mutationFn: (data) => updateShare(data), onSuccess: (data) => { - queryClient.refetchQueries({ + queryClient.invalidateQueries({ predicate: (item) => - ["share-for-page"].includes(item.queryKey[0] as string), + ["share-for-page", "share-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error, params) => { + if (error?.["status"] === 404) { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + } + + notifications.show({ + message: error?.["response"]?.data?.message || "Share share not found", + color: "red", }); }, }); @@ -87,14 +111,33 @@ export function useUpdateShareMutation() { export function useDeleteShareMutation() { const { t } = useTranslation(); + const queryClient = useQueryClient(); + return useMutation({ mutationFn: (shareId: string) => deleteShare(shareId), - onSuccess: () => { + onSuccess: (data) => { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + + queryClient.invalidateQueries({ + predicate: (item) => + ["share-list"].includes(item.queryKey[0] as string), + }); + notifications.show({ message: t("Share deleted successfully") }); }, onError: (error) => { + if (error?.["status"] === 404) { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + } + notifications.show({ - message: t("Failed to delete share"), + message: error?.["response"]?.data?.message || "Failed to delete share", color: "red", }); }, diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts index 2228bf9d..8163fa06 100644 --- a/apps/client/src/features/share/types/share.types.ts +++ b/apps/client/src/features/share/types/share.types.ts @@ -25,6 +25,7 @@ export interface ISharedItem extends IShare { id: string; name: string; slug: string; + userRole: string; }; creator: { id: string; @@ -37,21 +38,17 @@ export interface ISharedPage extends IShare { page: IPage; share: IShare & { level: number; - sharedPage: { id: string; slugId: string; title: string }; + sharedPage: { id: string; slugId: string; title: string; icon: string }; }; } export interface IShareForPage extends IShare { level: number; - page: { - id: string; - title: string; - slugId: string; - }; sharedPage: { id: string; slugId: string; title: string; + icon: string; }; } diff --git a/apps/client/src/pages/settings/shares/shares.tsx b/apps/client/src/pages/settings/shares/shares.tsx index f071737a..1a5a118e 100644 --- a/apps/client/src/pages/settings/shares/shares.tsx +++ b/apps/client/src/pages/settings/shares/shares.tsx @@ -3,6 +3,9 @@ import { Helmet } from "react-helmet-async"; import { getAppName } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; import ShareList from "@/features/share/components/share-list.tsx"; +import { Alert, Text } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import React from "react"; export default function Shares() { const { t } = useTranslation(); @@ -11,10 +14,17 @@ export default function Shares() { <> - {t("Shares")} - {getAppName()} + {t("Public sharing")} - {getAppName()} - + + + }> + {t( + "Publicly shared pages from spaces you are a member of will appear here", + )} + + ); diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 0741b221..76f80819 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -22,7 +22,7 @@ export default function SingleSharedPage() { if (shareId && data) { if (data.share.key !== shareId) { // affects parent share, what to do? - //navigate(`/share/${data.share.key}/${pageSlug}`); + navigate(`/share/${data.share.key}/${pageSlug}`); } } }, [shareId, data]); @@ -41,7 +41,8 @@ export default function SingleSharedPage() { return (
- {`${data?.page?.icon || ""} ${data?.page?.title || t("untitled")}`} + {`${data?.page?.title || t("untitled")}`} + {!data?.share.searchIndexing && } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 854c5e59..c8d9adac 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -69,7 +69,7 @@ export class ShareService { key: generateSlugId(), pageId: page.id, includeSubPages: createShareDto.includeSubPages, - searchIndexing: true, + searchIndexing: createShareDto.searchIndexing, creatorId: authUserId, spaceId: page.spaceId, workspaceId, @@ -126,6 +126,7 @@ export class ShareService { 'id', 'slugId', 'pages.title', + 'pages.icon', 'parentPageId', sql`0`.as('level'), ]) @@ -137,6 +138,7 @@ export class ShareService { 'p.id', 'p.slugId', 'p.title', + 'p.icon', 'p.parentPageId', // Increase the level by 1 for each ancestor. sql`ph.level + 1`.as('level'), @@ -150,11 +152,13 @@ export class ShareService { 'page_hierarchy.id as sharedPageId', 'page_hierarchy.slugId as sharedPageSlugId', 'page_hierarchy.title as sharedPageTitle', + 'page_hierarchy.icon as sharedPageIcon', 'page_hierarchy.level as level', 'shares.id', 'shares.key', 'shares.pageId', 'shares.includeSubPages', + 'shares.searchIndexing', 'shares.creatorId', 'shares.spaceId', 'shares.workspaceId', @@ -166,18 +170,19 @@ export class ShareService { .executeTakeFirst(); if (!share || share.workspaceId != workspaceId) { - throw new NotFoundException('Shared page not found'); + return undefined; } if (share.level === 1 && !share.includeSubPages) { // we can only show a page if its shared ancestor permits it - throw new NotFoundException('Shared page not found'); + return undefined; } return { id: share.id, key: share.key, includeSubPages: share.includeSubPages, + searchIndexing: share.searchIndexing, pageId: share.pageId, creatorId: share.creatorId, spaceId: share.spaceId, @@ -188,6 +193,7 @@ export class ShareService { id: share.sharedPageId, slugId: share.sharedPageSlugId, title: share.sharedPageTitle, + icon: share.sharedPageIcon, }, }; }