This commit is contained in:
Philipinho
2025-04-18 20:46:55 +01:00
parent c20931bd95
commit 5ccea524c7
14 changed files with 220 additions and 85 deletions

View File

@ -86,7 +86,7 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"shares"} element={<Shares />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}

View File

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

View File

@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
<ShareModal/>
<ShareModal readOnly={readOnly}/>
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon

View File

@ -8,7 +8,6 @@ import {
} from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import { ISharedItem } from "@/features/share/types/share.types.ts";
import {
buildPageUrl,
@ -17,15 +16,16 @@ import {
import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
interface Props {
share: ISharedItem;
}
export default function ShareActionMenu({ share }: Props) {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const navigate = useNavigate();
const clipboard = useClipboard();
const deleteShareMutation = useDeleteShareMutation();
const openPage = () => {
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: (
<Text size="sm">
{t("Are you sure you want to unshare this page?")}
{t("Are you sure you want to revoke this public link?")}
</Text>
),
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={<IconTrash size={16} />}
disabled={!isAdmin}
disabled={share.space?.userRole === "reader"}
>
{t("Unshare")}
{t("Revoke")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -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)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.page.title}
{share.page.title || t("untitled")}
</Text>
</div>
</Group>

View File

@ -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<boolean>(false);
useEffect(() => {
if (share) {
@ -54,9 +55,20 @@ export default function ShareModal() {
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Popover width={350} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button
variant="default"
style={{ border: "none" }}
leftSection={<IconWorld size={20} stroke={1.5} />}
>
Share
</Button>
<Tooltip label={t("Share")} openDelay={250} withArrow>
<Indicator
color="green"
offset={7}
disabled={!isPagePublic}
withBorder
>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconWorld size={20} stroke={1.5} />
</ActionIcon>
</Indicator>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{isDescendantShared ? (
<Text>
{t("This page was shared via")} {share.sharedPage.title}
</Text>
<>
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap" my="sm">
{getPageIcon(share.sharedPage.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</div>
</Group>
</Anchor>
<Group my="sm" grow>
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
/>
</Group>
</>
) : (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text>Share page</Text>
<Text size="sm">
{isPagePublic ? t("Publicly shared") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
Make it public to the internet
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
defaultChecked={isPagePublic}
size="sm"
disabled={readOnly}
size="xs"
/>
</Group>
@ -124,29 +178,32 @@ export default function ShareModal() {
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text>{t("Include sub pages")}</Text>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
Include children of this page
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text>{t("Enable search indexing")}</Text>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
Allow search engine indexing
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>

View File

@ -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 (
<AppShell
header={{ height: 48 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
},
}}
})}
aside={{
width: 300,
breakpoint: "sm",
@ -77,7 +78,7 @@ export default function ShareShell({
<AppShell.Header>
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
<Group>
{data?.pageTree?.length > 0 && (
{data?.pageTree?.length > 1 && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
@ -133,7 +134,7 @@ export default function ShareShell({
</Group>
</AppShell.Header>
{data?.pageTree?.length > 0 && (
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md">
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>

View File

@ -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) {
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
<Tree
initialData={treeData}
data={treeData}
disableDrag={true}
disableDrop={true}
disableEdit={true}

View File

@ -9,12 +9,13 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
ICreateShare,
ISharedItem, ISharedPage,
ISharedItem,
ISharedPage,
ISharedPageTree,
IShareForPage,
IShareInfoInput,
IUpdateShare,
} from '@/features/share/types/share.types.ts';
} from "@/features/share/types/share.types.ts";
import {
createShare,
deleteShare,
@ -26,6 +27,7 @@ import {
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery(
params?: QueryParams,
@ -56,7 +58,8 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId],
queryFn: () => 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<any, Error, ICreateShare>({
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<any, Error, IUpdateShare>({
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",
});
},

View File

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

View File

@ -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() {
<>
<Helmet>
<title>
{t("Shares")} - {getAppName()}
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Shares")} />
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
{t(
"Publicly shared pages from spaces you are a member of will appear here",
)}
</Alert>
<ShareList />
</>
);

View File

@ -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 (
<div>
<Helmet>
<title>{`${data?.page?.icon || ""} ${data?.page?.title || t("untitled")}`}</title>
<title>{`${data?.page?.title || t("untitled")}`}</title>
{!data?.share.searchIndexing && <meta name="robots" content="noindex" />}
</Helmet>
<Container size={900}>