Merge branch 'main' into base

This commit is contained in:
Philipinho
2026-03-08 01:57:17 +00:00
284 changed files with 15522 additions and 3285 deletions
+3
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/pages/base/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
export default function App() {
const { t } = useTranslation();
@@ -105,6 +106,8 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -130,7 +130,7 @@ export default function AvatarUploader({
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
zIndex: 200,
}}
>
<Loader size="sm" />
@@ -0,0 +1,27 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M7.5 3v18" />
<path d="M12 3v18" />
<path d="M16.5 3v18" />
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M6.6 3v18" />
<path d="M10.2 3v18" />
<path d="M13.8 3v18" />
<path d="M17.4 3v18" />
</svg>
);
}
@@ -31,7 +31,7 @@ export default function Aside() {
}
return (
<Box p="md">
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
<Text mb="md" fw={500}>
@@ -11,6 +11,7 @@ import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -80,3 +81,11 @@ export const prefetchApiKeyManagement = () => {
queryFn: () => getApiKeys({ adminView: true }),
});
};
export const prefetchAuditLogs = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
});
};
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconHistory,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -31,6 +32,7 @@ import {
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
prefetchAuditLogs,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -44,6 +46,7 @@ interface DataItem {
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isOwner?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
@@ -116,6 +119,15 @@ const groupedData: DataGroup[] = [
path: "/settings/ai",
isAdmin: true,
},
{
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
isEnterprise: true,
isOwner: true,
isSelfhosted: true,
showDisabledInNonEE: true,
},
],
},
{
@@ -135,7 +147,7 @@ export default function SettingsSidebar() {
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin } = useUserRole();
const { isAdmin, isOwner } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -144,34 +156,36 @@ export default function SettingsSidebar() {
setActive(location.pathname);
}, [location.pathname]);
const hasRoleAccess = (item: DataItem) => {
if (item.isOwner) return isOwner;
if (item.isAdmin) return isAdmin;
return true;
};
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
// Check admin permission regardless of license
return item.isAdmin ? isAdmin : true;
if (item.isSelfhosted && isCloud()) return false;
return hasRoleAccess(item);
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
return hasRoleAccess(item);
}
if (item.isCloud) {
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
return isCloud() ? hasRoleAccess(item) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
return !isCloud() ? hasRoleAccess(item) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
return hasRoleAccess(item);
};
const isItemDisabled = (item: DataItem) => {
@@ -227,6 +241,9 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
default:
break;
}
@@ -4,7 +4,7 @@ import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
avatarUrl?: string;
name: string;
color?: string;
size?: string | number;
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -14,7 +14,7 @@ import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -52,16 +52,34 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
if (!editor || !showAiMenu) return;
const { view } = editor;
const { to } = editor.state.selection;
const { from, to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const cursorCoords = view.coordsAtPos(to);
const fromCoords = view.coordsAtPos(from);
const toCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
const anchorBottom =
toCoords.bottom > 0 && toCoords.bottom < window.innerHeight
? toCoords.bottom
: fromCoords.bottom;
const menuMaxWidth = 600;
const editorLeft = editorRect.left + editorPadding;
const editorRight = editorRect.right - editorPadding;
const availableWidth = editorRight - editorLeft;
const menuWidth = Math.min(menuMaxWidth, availableWidth);
let menuLeft = Math.max(editorLeft, fromCoords.left);
if (menuLeft + menuWidth > editorRight) {
menuLeft = editorRight - menuWidth;
}
menuLeft = Math.max(editorLeft, menuLeft);
setMenuPlacement({
top: cursorCoords.bottom + topOffset + window.scrollY,
left: editorRect.left + editorPadding + window.scrollX,
width: editorRect.width - editorPadding * 2,
top: anchorBottom + topOffset + window.scrollY,
left: menuLeft + window.scrollX,
width: menuWidth,
});
}, [editor, showAiMenu, isSmBreakpoint]);
const resetMenu = useCallback(() => {
@@ -110,6 +128,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setPrompt("");
setIsLoading(false);
setActiveCommandSet("result");
},
@@ -146,13 +165,18 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
}
const html = (marked.parse(output) as string).trim();
// Strip <p> wrapper for single-paragraph output to preserve inline context
const content =
const isSingleParagraph =
html.startsWith("<p>") &&
html.endsWith("</p>") &&
html.lastIndexOf("<p>") === 0
? html.slice(3, -4)
: html;
html.lastIndexOf("<p>") === 0;
// Strip <p> wrapper for single-paragraph output to preserve inline context,
// then decode HTML entities via DOMParser since TipTap would otherwise
// treat the tagless string as plain text and insert entities literally.
const content = isSingleParagraph
? new DOMParser().parseFromString(html.slice(3, -4), "text/html")
.body.innerHTML
: html;
chain.insertContent(content).run();
@@ -169,7 +193,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-copy") {
navigator.clipboard.writeText(output);
copyToClipboard(output);
return setShowAiMenu(false);
}
@@ -271,7 +295,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return createPortal(
<div
style={{
zIndex: 200,
zIndex: 199,
position: "absolute",
top: menuPlacement.top,
left: menuPlacement.left,
@@ -0,0 +1,138 @@
import {
Anchor,
Group,
List,
Text,
Switch,
TextInput,
ActionIcon,
Tooltip,
Stack,
Alert,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE();
const mcpUrl = `${getAppUrl()}/mcp`;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ mcpEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Stack gap="lg">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Model Context Protocol (MCP)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "}
{t("View the")}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("MCP documentation")}
</Anchor>
.
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
{checked && (
<div>
<Text size="sm" fw={500} mb={4}>
{t("MCP Server URL")}
</Text>
<Group gap="xs">
<TextInput
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")}
</Text>
<List size="sm" spacing={2}>
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item>
</List>
</div>
</div>
)}
</Stack>
);
}
+49 -18
View File
@@ -6,44 +6,75 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import { Alert, Stack } from "@mantine/core";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai";
if (!isAdmin) {
return null;
}
const handleTabChange = (value: string | null) => {
if (value === "mcp") {
navigate("/settings/ai/mcp");
} else {
navigate("/settings/ai");
}
};
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
<title>AI settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="ai">
{t("AI")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="mcp">
{t("MCP")}
</Tabs.Tab>
</Tabs.List>
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
<Tabs.Panel value="ai" pt="md">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="mcp" pt="md">
<McpSettings />
</Tabs.Panel>
</Tabs>
</>
);
}
@@ -1,8 +1,8 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
@@ -36,7 +36,7 @@ export function CreateApiKeyModal({
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
@@ -0,0 +1,68 @@
import { Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
export default function RestrictApiToAdmins() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true,
);
const hasAccess = useEnterpriseAccess();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
restrictApiToAdmins: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">
{t("Restrict API key creation to admins")}
</Text>
<Text size="sm" c="dimmed">
{t(
"Only admins and owners can create new API keys. Existing member keys will continue to work.",
)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle restrict API keys to admins")}
/>
</Tooltip>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
@@ -1,7 +1,7 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
@@ -27,7 +27,7 @@ export function UpdateApiKeyModal({
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
},
@@ -1,9 +1,10 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -13,6 +14,9 @@ import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -23,6 +27,11 @@ export default function UserApiKeys() {
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
@@ -48,11 +57,50 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<Text size="sm" c="dimmed" mb="md">
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("Learn more")}
</Anchor>
</Text>
<Text size="sm" mt={4}>
{t("MCP server URL:")}{" "}
<Text size="sm" fw={500} span ff="monospace">
{`${getAppUrl()}/mcp`}
</Text>
</Text>
</Alert>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
) : null}
<ApiKeyTable
apiKeys={data?.items || []}
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
@@ -14,6 +14,7 @@ import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -54,10 +55,18 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
<Text size="sm" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace.")}{" "}
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
<RestrictApiToAdmins />
<Divider my="lg" />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
@@ -0,0 +1,333 @@
import { Fragment, useState } from "react";
import {
Table,
Text,
Group,
Skeleton,
Anchor,
Collapse,
Box,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
IconChevronRight,
IconChevronDown,
IconArrowRight,
} from "@tabler/icons-react";
import { IAuditLog } from "@/ee/audit/types/audit.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import classes from "./audit-logs.module.css";
type AuditLogsTableProps = {
items?: IAuditLog[];
isLoading: boolean;
};
function hasDetails(entry: IAuditLog): boolean {
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
}
function getResourceUrl(entry: IAuditLog): string | null {
if (!entry.resource) return null;
switch (entry.resourceType) {
case "group":
return `/settings/groups/${entry.resource.id}`;
case "space":
case "space_member":
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
default:
return null;
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
const { t } = useTranslation();
if (!changes) return null;
const { before, after } = changes;
const allKeys = new Set([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
]);
if (allKeys.size === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Changes")}
</Text>
{[...allKeys].map((key) => {
const hasBefore = before && key in before;
const hasAfter = after && key in after;
return (
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
<Text
fz="xs"
c="dimmed"
fw={500}
style={{ minWidth: "fit-content" }}
>
{key}:
</Text>
{hasBefore && (
<Text fz="xs" component="span">
{formatValue(before[key])}
</Text>
)}
{hasBefore && hasAfter && (
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
)}
{hasAfter && (
<Text fz="xs" component="span">
{formatValue(after[key])}
</Text>
)}
</Group>
);
})}
</Box>
);
}
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
const { t } = useTranslation();
const entries = Object.entries(metadata);
if (entries.length === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Metadata")}
</Text>
{entries.map(([key, value]) => (
<Group key={key} gap={6} mb={2} wrap="nowrap">
<Text fz="xs" c="dimmed" fw={500}>
{key}:
</Text>
<Text fz="xs">{formatValue(value)}</Text>
</Group>
))}
</Box>
);
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Skeleton circle height={36} />
<div>
<Skeleton height={14} width={120} mb={4} />
<Skeleton height={10} width={160} />
</div>
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
function ResourceCell({ entry }: { entry: IAuditLog }) {
if (!entry.resource?.name) {
return (
<Text fz="sm" c="dimmed">
</Text>
);
}
const url = getResourceUrl(entry);
if (url) {
return (
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={url}
>
<div className={classes.resourceLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.resource.name}
</Text>
</div>
</Anchor>
);
}
return (
<Text fz="sm" lineClamp={1}>
{entry.resource.name}
</Text>
);
}
export default function AuditLogsTable({
items,
isLoading,
}: AuditLogsTableProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<Table.ScrollContainer minWidth={700}>
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Actor")}</Table.Th>
<Table.Th>{t("Event")}</Table.Th>
<Table.Th>{t("Resource")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items && items.length > 0 ? (
items.map((entry) => {
const expandable = hasDetails(entry);
const isExpanded = expanded.has(entry.id);
return (
<Fragment key={entry.id}>
<Table.Tr
onClick={
expandable ? () => toggleExpanded(entry.id) : undefined
}
style={{ cursor: expandable ? "pointer" : undefined }}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{expandable ? (
isExpanded ? (
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
/>
) : (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
/>
)
) : (
<Box w={16} />
)}
{entry.actor ? (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={entry.actor.avatarUrl}
name={entry.actor.name}
size={36}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.actor.name}
</Text>
<Text fz="xs" c="dimmed">
{entry.actor.email}
</Text>
</div>
</Group>
) : (
<Text fz="sm" c="dimmed" fs="italic">
{entry.actorType === "system"
? t("System")
: t("System")}
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
</Table.Td>
<Table.Td>
<ResourceCell entry={entry} />
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(entry.createdAt))}
</Text>
</Table.Td>
</Table.Tr>
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Box
px="md"
py="sm"
className={classes.detailContent}
>
<Group gap="xl" align="flex-start">
{entry.changes && (
<ChangesDiff changes={entry.changes} />
)}
{entry.metadata && (
<MetadataDisplay metadata={entry.metadata} />
)}
</Group>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})
) : (
<NoTableResults colSpan={4} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,33 @@
.table {
--table-border-color: var(--mantine-color-gray-2);
@mixin dark {
--table-border-color: var(--mantine-color-dark-5);
}
}
.resourceLinkText {
width: fit-content;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.detailRow {
&:hover {
background: none !important;
}
}
.detailContent {
@mixin light {
background: var(--mantine-color-gray-0);
}
@mixin dark {
background: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,170 @@
type EventOption = {
value: string;
label: string;
};
type EventGroup = {
group: string;
items: EventOption[];
};
export const auditEventLabels: Record<string, string> = {
"workspace.created": "Created workspace",
"workspace.updated": "Updated workspace",
"workspace.invite_created": "Created invitation",
"workspace.invite_resent": "Resent invitation",
"workspace.invite_revoked": "Revoked invitation",
"user.created": "Created user",
"user.deleted": "Deleted user",
"user.login": "Logged in",
"user.logout": "Logged out",
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.updated": "Updated user",
"user.deactivated": "Deactivated user",
"user.activated": "Activated user",
"user.mfa_enabled": "Enabled MFA",
"user.mfa_disabled": "Disabled MFA",
"user.mfa_backup_code_generated": "Generated MFA backup codes",
"api_key.created": "Created API key",
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
"space.member_added": "Added space member",
"space.member_removed": "Removed space member",
"space.member_role_changed": "Changed space member role",
"space.exported": "Exported space",
"group.created": "Created group",
"group.updated": "Updated group",
"group.deleted": "Deleted group",
"group.member_added": "Added group member",
"group.member_removed": "Removed group member",
"comment.deleted": "Deleted comment",
"page.trashed": "Trashed page",
"page.deleted": "Deleted page",
"page.restored": "Restored page",
"page.imported": "Imported page",
"page.exported": "Exported page",
"page.restricted": "Restricted page",
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"share.created": "Created share link",
"share.deleted": "Deleted share link",
"sso.provider_created": "Created SSO provider",
"sso.provider_updated": "Updated SSO provider",
"sso.provider_deleted": "Deleted SSO provider",
"license.activated": "Activated license",
"license.removed": "Removed license",
};
export function getEventLabel(event: string): string {
return auditEventLabels[event] ?? event;
}
export const eventFilterOptions: EventGroup[] = [
{
group: "Workspace",
items: [
{ value: "workspace.updated", label: "Updated workspace" },
{ value: "workspace.invite_created", label: "Created invitation" },
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
],
},
{
group: "User",
items: [
{ value: "user.login", label: "Logged in" },
{ value: "user.logout", label: "Logged out" },
{ value: "user.created", label: "Created user" },
{ value: "user.deleted", label: "Deleted user" },
{ value: "user.deactivated", label: "Deactivated user" },
{ value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" },
{ value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" },
],
},
{
group: "Space",
items: [
{ value: "space.created", label: "Created space" },
{ value: "space.updated", label: "Updated space" },
{ value: "space.deleted", label: "Deleted space" },
{ value: "space.member_added", label: "Added space member" },
{ value: "space.member_removed", label: "Removed space member" },
],
},
{
group: "Group",
items: [
{ value: "group.created", label: "Created group" },
{ value: "group.updated", label: "Updated group" },
{ value: "group.deleted", label: "Deleted group" },
{ value: "group.member_added", label: "Added group member" },
{ value: "group.member_removed", label: "Removed group member" },
],
},
{
group: "Comment",
items: [
{ value: "comment.deleted", label: "Deleted comment" },
],
},
{
group: "Page",
items: [
{ value: "page.trashed", label: "Trashed page" },
{ value: "page.deleted", label: "Deleted page" },
{ value: "page.restored", label: "Restored page" },
{ value: "page.imported", label: "Imported page" },
{ value: "page.exported", label: "Exported page" },
{ value: "page.restricted", label: "Restricted page" },
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
],
},
{
group: "Share",
items: [
{ value: "share.created", label: "Created share link" },
{ value: "share.deleted", label: "Deleted share link" },
],
},
{
group: "SSO",
items: [
{ value: "sso.provider_created", label: "Created SSO provider" },
{ value: "sso.provider_updated", label: "Updated SSO provider" },
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
],
},
{
group: "API key",
items: [
{ value: "api_key.created", label: "Created API key" },
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "License",
items: [
{ value: "license.activated", label: "Activated license" },
{ value: "license.removed", label: "Removed license" },
],
},
];
@@ -0,0 +1,223 @@
import { useState, useMemo, useEffect } from "react";
import {
ActionIcon,
Button,
Group,
NumberInput,
Popover,
Select,
Space,
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
useAuditLogsQuery,
useAuditRetentionQuery,
useUpdateAuditRetentionMutation,
} from "@/ee/audit/queries/audit-query";
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = useAuditRetentionQuery();
const updateRetention = useUpdateAuditRetentionMutation();
const currentDays = retentionData?.retentionDays ?? 365;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
useEffect(() => {
if (retentionData) {
const { amount, unit } = daysToRetention(retentionData.retentionDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
}
}, [retentionData?.retentionDays]);
const resetRetentionForm = () => {
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
};
const params: IAuditLogParams = useMemo(
() => ({
cursor,
limit: 50,
event: eventFilter ?? undefined,
}),
[cursor, eventFilter],
);
const { data, isLoading } = useAuditLogsQuery(params);
if (!isOwner) {
return null;
}
const handleEventChange = (value: string | null) => {
setEventFilter(value);
resetCursor();
};
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} />
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -0,0 +1,51 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAuditLogs,
getAuditRetention,
updateAuditRetention,
} from "@/ee/audit/services/audit-service";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useAuditLogsQuery(
params?: IAuditLogParams,
): UseQueryResult<IPagination<IAuditLog>, Error> {
return useQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
placeholderData: keepPreviousData,
});
}
export function useAuditRetentionQuery() {
return useQuery({
queryKey: ["audit-retention"],
queryFn: () => getAuditRetention(),
});
}
export function useUpdateAuditRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { auditRetentionDays: number }) =>
updateAuditRetention(data),
onSuccess: () => {
notifications.show({ message: t("Audit retention updated") });
queryClient.invalidateQueries({ queryKey: ["audit-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,22 @@
import api from "@/lib/api-client";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
export async function getAuditLogs(
params?: IAuditLogParams,
): Promise<IPagination<IAuditLog>> {
const req = await api.post("/audit", { ...params });
return req.data;
}
export async function getAuditRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention");
return req.data;
}
export async function updateAuditRetention(data: {
auditRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention/update", data);
return req.data;
}
@@ -0,0 +1,40 @@
export type IAuditLog = {
id: string;
workspaceId: string;
actorId?: string;
actorType: string;
event: string;
resourceType: string;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
ipAddress?: string;
createdAt: string;
actor?: {
id: string;
name: string;
email: string;
avatarUrl?: string;
};
resource?: {
id: string;
name: string;
slug?: string;
slugId?: string;
};
};
export type IAuditLogParams = {
event?: string;
resourceType?: string;
actorId?: string;
spaceId?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
};
@@ -1,6 +1,7 @@
import {
useMutation,
useQueryClient,
InfiniteData,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
@@ -10,41 +11,54 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
function updateCommentInCache(
cache: InfiniteData<IPagination<IComment>>,
commentId: string,
updater: (comment: IComment) => IComment,
): InfiniteData<IPagination<IComment>> {
return {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === commentId ? updater(comment) : comment,
),
})),
};
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
const cache = previousCache as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? "optimistic" : null,
resolvedBy: variables.resolved
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
: null,
})),
);
return {
...old,
items: updatedItems,
};
});
return { previousComments };
}
return { previousCache };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
onError: (_err, variables, context) => {
if (context?.previousCache) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache);
}
notifications.show({
message: t("Failed to resolve comment"),
@@ -52,35 +66,26 @@ export function useResolveCommentMutation() {
});
},
onSuccess: (data: IComment, variables) => {
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
const cache = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(data.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
});
},
});
@@ -1,5 +1,6 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Container,
Title,
@@ -30,7 +31,7 @@ export function CloudLoginForm() {
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
hostname: "",
},
@@ -1,13 +1,13 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
@@ -34,7 +34,7 @@ export function LdapLoginModal({
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
username: "",
password: "",
@@ -59,13 +59,13 @@ export function LdapLoginModal({
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
onClose();
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err: any) {
setIsLoading(false);
@@ -1,16 +1,17 @@
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
import * as z from "zod";
import { z } from "zod/v4";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { getSubdomainHost } from "@/lib/config.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { getHostnameUrl } from "@/ee/utils.ts";
import { useAtom } from "jotai/index";
import { useAtom } from "jotai";
import {
currentUserAtom,
workspaceAtom,
@@ -66,7 +67,7 @@ function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
hostname: currentUser?.workspace?.hostname,
},
@@ -1,7 +1,8 @@
import * as z from "zod";
import { z } from "zod/v4";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
@@ -49,7 +50,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const activateLicenseMutation = useActivateMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
licenseKey: "",
},
@@ -1,39 +1,76 @@
import { Group, Table, ThemeIcon } from "@mantine/core";
import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
const enterpriseFeatures = [
"SSO (SAML, OIDC, LDAP)",
"AI Integration (Search & Assistant)",
"Page-level Permissions",
"Audit Logs",
"API Keys",
"MCP Support",
"Multi-factor Authentication (2FA)",
"Enterprise Controls",
"Advanced Search Engine Support",
"Full-text Search in Attachments (PDF, DOCX)",
"Resolve Comments",
"Confluence Import",
"DOCX Import",
];
export default function OssDetails() {
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Stack gap="lg">
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Stack gap="md">
<Text fw={500}>Upgrade to the Enterprise Edition to unlock:</Text>
<List
spacing={4}
size="sm"
icon={
<ThemeIcon size={20} color={"gray"} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{enterpriseFeatures.map((feature) => (
<List.Item key={feature}>{feature}</List.Item>
))}
</List>
<Text size="sm" c="dimmed">
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license.
</Text>
</Stack>
</Stack>
);
}
@@ -23,8 +23,8 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps {
@@ -51,7 +51,7 @@ export function MfaBackupCodesModal({
});
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
confirmPassword: "",
},
@@ -12,15 +12,15 @@ import {
ThemeIcon,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { z } from "zod/v4";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
const formSchema = z.object({
@@ -43,7 +43,7 @@ export function MfaChallenge() {
const [useBackupCode, setUseBackupCode] = useState(false);
const form = useForm<MfaChallengeFormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
code: "",
},
@@ -53,7 +53,7 @@ export function MfaChallenge() {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} catch (error: any) {
setIsLoading(false);
notifications.show({
@@ -9,11 +9,11 @@ import {
} from "@mantine/core";
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { z } from "zod/v4";
import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -41,7 +41,7 @@ export function MfaDisableModal({
});
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
confirmPassword: "",
},
@@ -63,7 +63,7 @@ export function MfaDisableModal({
const handleSubmit = async (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
@@ -36,8 +36,8 @@ import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { setupMfa, enableMfa } from "@/ee/mfa";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
interface MfaSetupModalProps {
opened: boolean;
@@ -71,7 +71,7 @@ export function MfaSetupModal({
const [manualEntryOpen, setManualEntryOpen] = useState(false);
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
verificationCode: "",
},
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
};
return (
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
@@ -13,8 +13,10 @@ export function useMfaPageProtection() {
const checkAccess = async () => {
const result = await validateMfaAccess();
const search = location.search;
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN);
navigate(APP_ROUTE.AUTH.LOGIN + search);
return;
}
@@ -26,17 +28,17 @@ export function useMfaPageProtection() {
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} else {
setIsValid(true);
}
@@ -0,0 +1,112 @@
import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
import {
IconChevronDown,
IconLock,
IconShieldLock,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./page-permission.module.css";
type AccessLevel = "open" | "restricted";
type GeneralAccessSelectProps = {
value: AccessLevel;
onChange: (value: AccessLevel) => void;
disabled?: boolean;
hasInheritedRestriction?: boolean;
};
export function GeneralAccessSelect({
value,
onChange,
disabled,
hasInheritedRestriction,
}: GeneralAccessSelectProps) {
const { t } = useTranslation();
const isDirectlyRestricted = value === "restricted";
const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted;
const currentLabel = showInheritedState
? t("Restricted by parent")
: isDirectlyRestricted
? t("Restricted")
: t("Open");
const currentDescription = showInheritedState
? t("Inherits restrictions from ancestor page")
: isDirectlyRestricted
? t("Only people listed below can access this page")
: t("Everyone in this space can access");
const CurrentIcon = showInheritedState
? IconShieldLock
: isDirectlyRestricted
? IconLock
: IconShieldLock;
const accessOptions = [
{
value: "open" as const,
label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"),
description: hasInheritedRestriction
? t("Use only inherited restrictions")
: t("No additional restrictions on this page"),
icon: IconShieldLock,
},
{
value: "restricted" as const,
label: t("Restricted"),
description: hasInheritedRestriction
? t("Add restrictions on top of inherited")
: t("Only specific people can access"),
icon: IconLock,
},
];
return (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
<div
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
>
<CurrentIcon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentLabel}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentDescription}
</Text>
</div>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{accessOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() => onChange(option.value)}
leftSection={<option.icon size={16} stroke={1.5} />}
rightSection={
option.value === value ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</div>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,107 @@
import { Menu, Text, UnstyledButton, Group } from "@mantine/core";
import { IconChevronDown, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { formatMemberCount } from "@/lib";
import {
IPagePermissionMember,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
pagePermissionRoleData,
getPagePermissionRoleLabel,
} from "@/ee/page-permission/types/page-permission-role-data";
import classes from "./page-permission.module.css";
type PagePermissionItemProps = {
member: IPagePermissionMember;
onRoleChange: (memberId: string, type: "user" | "group", role: string) => void;
onRemove: (memberId: string, type: "user" | "group") => void;
disabled?: boolean;
};
export function PagePermissionItem({
member,
onRoleChange,
onRemove,
disabled,
}: PagePermissionItemProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const isCurrentUser = member.type === "user" && member.id === currentUser?.id;
const roleLabel = getPagePermissionRoleLabel(member.role);
return (
<div className={classes.permissionItem}>
<div className={classes.permissionItemInfo}>
{member.type === "user" && (
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
)}
{member.type === "group" && <IconGroupCircle />}
<div className={classes.permissionItemDetails}>
<AutoTooltipText
fz="sm"
fw={500}
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
>
{member.name}
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
<div className={classes.permissionItemRole}>
{isCurrentUser || disabled ? (
<Text size="sm" c="dimmed">
{t(roleLabel)}
</Text>
) : (
<Menu withArrow position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={4}>
<Text size="sm">{t(roleLabel)}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{pagePermissionRoleData.map((role) => (
<Menu.Item
key={role.value}
onClick={() => onRoleChange(member.id, member.type, role.value)}
rightSection={
role.value === member.role ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{t(role.label)}</Text>
<Text size="xs" c="dimmed">
{t(role.description)}
</Text>
</div>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
color="red"
onClick={() => onRemove(member.id, member.type)}
>
{t("Remove access")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { useEffect, useRef } from "react";
import { modals } from "@mantine/modals";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types";
import {
usePagePermissionsQuery,
useRemovePagePermissionMutation,
useUpdatePagePermissionRoleMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionItem } from "@/ee/page-permission";
import classes from "./page-permission.module.css";
type PagePermissionListProps = {
pageId: string;
canManage: boolean;
onRemoveAll?: () => void;
};
export function PagePermissionList({
pageId,
canManage,
onRemoveAll,
}: PagePermissionListProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
const removeMutation = useRemovePagePermissionMutation();
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
usePagePermissionsQuery(pageId);
const sentinelRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ root: viewportRef.current, threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleRoleChange = async (
memberId: string,
type: "user" | "group",
newRole: string,
) => {
await updateRoleMutation.mutateAsync({
pageId,
role: newRole as PagePermissionRole,
...(type === "user" ? { userId: memberId } : { groupId: memberId }),
});
};
const handleRemove = (memberId: string, type: "user" | "group") => {
modals.openConfirmModal({
title: t("Remove access"),
children: (
<Text size="sm">
{t(
"Are you sure you want to remove this member's access to the page?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: async () => {
await removeMutation.mutateAsync({
pageId,
...(type === "user"
? { userIds: [memberId] }
: { groupIds: [memberId] }),
});
},
});
};
const handleRemoveAll = () => {
modals.openConfirmModal({
title: t("Remove all access"),
children: (
<Text size="sm">
{t(
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Remove all"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemoveAll?.(),
});
};
const members = data?.pages.flatMap((page) => page.items) ?? [];
const sortedMembers = [...members].sort((a, b) => {
if (a.type === "user" && a.id === currentUser?.id) return -1;
if (b.type === "user" && b.id === currentUser?.id) return 1;
if (a.type === "group" && b.type === "user") return -1;
if (a.type === "user" && b.type === "group") return 1;
return 0;
});
if (isLoading) {
return (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
if (members.length === 0) {
return null;
}
return (
<>
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t("People with access")}
</Text>
{canManage && members.length > 0 && (
<Text className={classes.removeAllLink} onClick={handleRemoveAll}>
{t("Remove all")}
</Text>
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</>
);
}
@@ -0,0 +1,189 @@
import { useState } from "react";
import {
Box,
Button,
Divider,
Group,
Paper,
Select,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react";
import { MultiMemberSelect } from "@/features/space/components/multi-member-select";
import {
IPageRestrictionInfo,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
useAddPagePermissionMutation,
useRestrictPageMutation,
useUnrestrictPageMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
import { GeneralAccessSelect } from "@/ee/page-permission";
import { PagePermissionList } from "@/ee/page-permission";
import classes from "./page-permission.module.css";
import { buildPageUrl } from "@/features/page/page.utils";
type PagePermissionTabProps = {
pageId: string;
restrictionInfo: IPageRestrictionInfo;
};
export function PagePermissionTab({
pageId,
restrictionInfo,
}: PagePermissionTabProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(PagePermissionRole.WRITER);
const restrictMutation = useRestrictPageMutation();
const unrestrictMutation = useUnrestrictPageMutation();
const addPermissionMutation = useAddPagePermissionMutation();
const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction;
const hasDirectRestriction = restrictionInfo.hasDirectRestriction;
const canManage = restrictionInfo.userAccess.canManage;
const handleDirectAccessChange = async (value: "open" | "restricted") => {
if (value === "restricted" && !hasDirectRestriction) {
await restrictMutation.mutateAsync(pageId);
} else if (value === "open" && hasDirectRestriction) {
await unrestrictMutation.mutateAsync(pageId);
}
};
const handleAddMembers = async () => {
if (memberIds.length === 0) return;
const userIds = memberIds
.filter((id) => id.startsWith("user-"))
.map((id) => id.replace("user-", ""));
const groupIds = memberIds
.filter((id) => id.startsWith("group-"))
.map((id) => id.replace("group-", ""));
await addPermissionMutation.mutateAsync({
pageId,
role: role as PagePermissionRole,
...(userIds.length > 0 && { userIds }),
...(groupIds.length > 0 && { groupIds }),
});
setMemberIds([]);
};
const handleRemoveAll = async () => {
await unrestrictMutation.mutateAsync(pageId);
};
return (
<Stack gap="md">
{hasInheritedRestriction && (
<Paper className={classes.inheritedSection} p="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="lg"
radius="sm"
variant="light"
color="orange"
>
<IconShieldLock size={18} stroke={1.5} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Inherited restriction")}
</Text>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("Access limited by")}
</Text>
{restrictionInfo.inheritedFrom && (
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.inheritedFrom.slugId,
restrictionInfo.inheritedFrom.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={2}>
<Text size="xs" fw={500} c="blue">
{restrictionInfo.inheritedFrom.title || t("Untitled")}
</Text>
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
</Group>
</Link>
)}
</Group>
</Box>
</Group>
</Paper>
)}
<Box>
<GeneralAccessSelect
value={hasDirectRestriction ? "restricted" : "open"}
onChange={handleDirectAccessChange}
disabled={!canManage}
hasInheritedRestriction={hasInheritedRestriction}
/>
{!hasDirectRestriction && !hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Restrict access to control who can view and edit this page")}
</Text>
)}
{!hasDirectRestriction && hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Add additional restrictions specific to this page")}
</Text>
)}
</Box>
{hasDirectRestriction && (
<>
<Divider />
{canManage && (
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
</Box>
<Select
data={pagePermissionRoleData.map((r) => ({
label: t(r.label),
value: r.value,
}))}
value={role}
onChange={(value) => value && setRole(value)}
allowDeselect={false}
variant="filled"
w={120}
/>
<Button
onClick={handleAddMembers}
disabled={memberIds.length === 0}
loading={addPermissionMutation.isPending}
>
{t("Add")}
</Button>
</Group>
)}
<PagePermissionList
pageId={pageId}
canManage={canManage}
onRemoveAll={handleRemoveAll}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,128 @@
.generalAccessBox {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) 0;
}
.generalAccessIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--mantine-radius-sm);
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.generalAccessIconRestricted {
@mixin light {
background-color: var(--mantine-color-red-0);
color: var(--mantine-color-red-6);
}
@mixin dark {
background-color: rgba(250, 82, 82, 0.1);
color: var(--mantine-color-red-5);
}
}
.permissionItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-xs) 0;
gap: var(--mantine-spacing-sm);
}
.permissionItemInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
flex: 1;
min-width: 0;
overflow: hidden;
}
.permissionItemDetails {
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.permissionItemRole {
flex-shrink: 0;
}
.avatarStack {
display: flex;
align-items: center;
}
.avatarStackItem {
margin-left: -8px;
border: 2px solid var(--mantine-color-body);
border-radius: 50%;
}
.avatarStackItem:first-child {
margin-left: 0;
}
.specificAccessHeader {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-md);
margin-bottom: var(--mantine-spacing-xs);
}
.removeAllLink {
cursor: pointer;
font-size: var(--mantine-font-size-sm);
@mixin light {
color: var(--mantine-color-gray-6);
}
@mixin dark {
color: var(--mantine-color-dark-2);
}
&:hover {
text-decoration: underline;
}
}
.inheritedInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
margin-bottom: var(--mantine-spacing-sm);
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
.inheritedSection {
@mixin light {
background-color: var(--mantine-color-orange-0);
border: 1px solid var(--mantine-color-orange-2);
}
@mixin dark {
background-color: rgba(255, 146, 43, 0.08);
border: 1px solid rgba(255, 146, 43, 0.2);
}
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import {
Button,
Indicator,
Loader,
Modal,
Stack,
Tabs,
Text,
Center,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconWorld, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query";
type PageShareModalProps = {
readOnly?: boolean;
};
export function PageShareModal({ readOnly }: PageShareModalProps) {
const { t } = useTranslation();
const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE();
const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish",
);
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
const spaceSharingDisabled = space?.settings?.sharing?.disabled === true;
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const isRestricted = page?.permissions?.hasRestriction ?? false;
const { data: share } = useShareForPageQuery(pageId);
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
return (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
isRestricted ? (
<Indicator color="red" offset={5} withBorder>
<IconLock size={20} stroke={1.5} />
</Indicator>
) : isPubliclyShared ? (
<Indicator color="green" offset={5} withBorder>
<IconWorld size={20} stroke={1.5} />
</Indicator>
) : null
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
<Tabs.Tab
value="publish"
rightSection={
isPubliclyShared ? (
<Indicator color="green" size={8} processing />
) : null
}
>
{t("Publish")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="access">
{!isCloudEE ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Page permissions")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Control who can view and edit individual pages. Available with an enterprise license.",
)}
</Text>
</Stack>
) : restrictionLoading || !pageId || !restrictionInfo ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab
pageId={pageId}
readOnly={readOnly}
isRestricted={isRestricted}
workspaceSharingDisabled={workspaceSharingDisabled}
spaceSharingDisabled={spaceSharingDisabled}
/>
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Anchor,
Button,
Group,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink, IconLock } from "@tabler/icons-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getPageIcon } from "@/lib";
import CopyTextButton from "@/components/common/copy";
import { getAppUrl, isCloud } from "@/lib/config";
import { buildPageUrl } from "@/features/page/page.utils";
import {
useCreateShareMutation,
useDeleteShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query";
import useTrial from "@/ee/hooks/use-trial";
type PublishTabProps = {
pageId: string;
readOnly?: boolean;
isRestricted?: boolean;
workspaceSharingDisabled?: boolean;
spaceSharingDisabled?: boolean;
};
export function PublishTab({ pageId, readOnly, isRestricted, workspaceSharingDisabled, spaceSharingDisabled }: PublishTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug, spaceSlug } = useParams();
const { isTrial } = useTrial();
const { data: share } = useShareForPageQuery(pageId);
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
const pageIsShared = share && share.level === 0;
const isDescendantShared = share && share.level > 0;
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
setIsPagePublic(!!share);
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
}
}
};
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,
});
};
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
if (isCloud() && isTrial) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button size="xs" onClick={() => navigate("/settings/billing")}>
{t("Upgrade Plan")}
</Button>
</Stack>
);
}
if (workspaceSharingDisabled || spaceSharingDisabled) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceSharingDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</Stack>
);
}
if (isRestricted) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Restricted page")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("Restricted pages cannot be shared publicly.")}
</Text>
</Stack>
);
}
if (isDescendantShared) {
return (
<Stack gap="sm">
<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">
{getPageIcon(share.sharedPage.icon)}
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</Group>
</Anchor>
{shareLink}
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
checked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{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">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</Stack>
);
}
@@ -0,0 +1,26 @@
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
export function usePagePermission(pageId: string, spaceRules: any) {
const spaceAbility = useSpaceAbility(spaceRules);
const { data: restrictionInfo, isLoading } =
usePageRestrictionInfoQuery(pageId);
if (isLoading || !restrictionInfo) {
return { canEdit: false, restrictionInfo: undefined };
}
const hasRestriction =
restrictionInfo.hasDirectRestriction ||
restrictionInfo.hasInheritedRestriction;
const canEdit = hasRestriction
? (restrictionInfo.userAccess?.canEdit ?? false)
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
return { canEdit, restrictionInfo };
}
@@ -0,0 +1,11 @@
export * from "./components/page-share-modal";
export * from "./components/page-permission-tab";
export * from "./components/publish-tab";
export * from "./components/page-permission-list";
export * from "./components/page-permission-item";
export * from "./components/general-access-select";
export * from "./hooks/use-page-permission";
export * from "./queries/page-permission-query";
export * from "./services/page-permission-service";
export * from "./types/page-permission.types";
export * from "./types/page-permission-role-data";
@@ -0,0 +1,175 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IAddPagePermission,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
addPagePermission,
getPagePermissions,
getPageRestrictionInfo,
removePagePermission,
restrictPage,
unrestrictPage,
updatePagePermissionRole,
} from "@/ee/page-permission/services/page-permission-service";
import { IPage } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function usePageRestrictionInfoQuery(
pageId: string | undefined,
): UseQueryResult<IPageRestrictionInfo, Error> {
return useQuery({
queryKey: ["page-restriction-info", pageId],
queryFn: () => getPageRestrictionInfo(pageId),
enabled: !!pageId,
});
}
export function usePagePermissionsQuery(pageId: string) {
return useInfiniteQuery({
queryKey: ["page-permissions", pageId],
queryFn: ({ pageParam }) => getPagePermissions(pageId, pageParam),
enabled: !!pageId,
//gcTime: 5000,
placeholderData: keepPreviousData,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
function updatePageRestrictionCache(
queryClient: ReturnType<typeof useQueryClient>,
pageId: string,
hasRestriction: boolean,
) {
queryClient.setQueriesData<IPage>(
{ queryKey: ["pages"] },
(old) => {
if (old?.id === pageId) {
return {
...old,
permissions: { ...old.permissions, hasRestriction },
};
}
return old;
},
);
queryClient.invalidateQueries({
queryKey: ["page-restriction-info", pageId],
});
queryClient.removeQueries({
queryKey: ["page-permissions", pageId],
});
}
export function useRestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => restrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, true);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to restrict page"),
color: "red",
});
},
});
}
export function useUnrestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => unrestrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, false);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove page restriction"),
color: "red",
});
},
});
}
export function useAddPagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IAddPagePermission>({
mutationFn: (data) => addPagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to add permission"),
color: "red",
});
},
});
}
export function useRemovePagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemovePagePermission>({
mutationFn: (data) => removePagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove permission"),
color: "red",
});
},
});
}
export function useUpdatePagePermissionRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdatePagePermissionRole>({
mutationFn: (data) => updatePagePermissionRole(data),
onSuccess: (_, variables) => {
queryClient.refetchQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update permission"),
color: "red",
});
},
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types";
import {
IAddPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
export async function restrictPage(pageId: string): Promise<void> {
await api.post("/pages/restrict", { pageId });
}
export async function addPagePermission(
data: IAddPagePermission,
): Promise<void> {
await api.post("/pages/add-permission", data);
}
export async function removePagePermission(
data: IRemovePagePermission,
): Promise<void> {
await api.post("/pages/remove-permission", data);
}
export async function updatePagePermissionRole(
data: IUpdatePagePermissionRole,
): Promise<void> {
await api.post("/pages/update-permission", data);
}
export async function unrestrictPage(pageId: string): Promise<void> {
await api.post("/pages/remove-restriction", { pageId });
}
export async function getPagePermissions(
pageId: string,
cursor?: string,
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions",
{ pageId, ...(cursor && { cursor }) },
);
return req.data;
}
export async function getPageRestrictionInfo(
pageId: string,
): Promise<IPageRestrictionInfo> {
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
pageId,
});
return req.data;
}
@@ -0,0 +1,20 @@
import { IRoleData } from "@/lib/types";
import { PagePermissionRole } from "./page-permission.types";
export const pagePermissionRoleData: IRoleData[] = [
{
label: "Can edit",
value: PagePermissionRole.WRITER,
description: "Can edit page and manage access",
},
{
label: "Can view",
value: PagePermissionRole.READER,
description: "Can only view page",
},
];
export function getPagePermissionRoleLabel(value: string): string | undefined {
const role = pagePermissionRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}
@@ -0,0 +1,61 @@
export enum PagePermissionRole {
READER = "reader",
WRITER = "writer",
}
export type IAddPagePermission = {
pageId: string;
role: PagePermissionRole;
userIds?: string[];
groupIds?: string[];
};
export type IRemovePagePermission = {
pageId: string;
userIds?: string[];
groupIds?: string[];
};
export type IUpdatePagePermissionRole = {
pageId: string;
role: PagePermissionRole;
userId?: string;
groupId?: string;
};
export type IPageRestrictionInfo = {
restrictionId?: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
inheritedFrom?: {
id: string;
slugId: string;
title: string;
};
userAccess: {
canView: boolean;
canEdit: boolean;
canManage: boolean;
};
};
type IPagePermissionBase = {
id: string;
name: string;
role: string;
createdAt: string;
};
export type IPagePermissionUser = IPagePermissionBase & {
type: "user";
email: string;
avatarUrl: string | null;
};
export type IPagePermissionGroup = IPagePermissionBase & {
type: "group";
memberCount: number;
isDefault: boolean;
};
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
@@ -1,7 +1,7 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
@@ -22,7 +22,7 @@ export default function AllowedDomains() {
const [, setDomains] = useState<string[]>([]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
emailDomains: workspace?.emailDomains || [],
},
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@@ -30,7 +30,7 @@ export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@@ -59,7 +59,7 @@ export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
@@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
@@ -39,7 +40,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@@ -49,7 +49,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
@@ -0,0 +1,138 @@
import { useState, useEffect } from "react";
import {
Group,
Text,
NumberInput,
Select,
Button,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function TrashRetention() {
const { t } = useTranslation();
const hasAccess = useEnterpriseAccess();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const [saving, setSaving] = useState(false);
useEffect(() => {
const days = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const { amount, unit } = daysToRetention(days);
setRetentionAmount(amount);
setRetentionUnit(unit);
}, [workspace?.trashRetentionDays]);
const handleSave = async () => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days === currentDays) return;
setSaving(true);
try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Trash retention updated"),
});
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message || t("Failed to update trash retention"),
color: "red",
});
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
} finally {
setSaving(false);
}
};
const isDirty = retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
return (
<div>
<Text size="md">{t("Trash retention")}</Text>
<Text size="sm" c="dimmed" mb="sm">
{t("Pages in trash will be permanently deleted after this period.")}
</Text>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
disabled={!hasAccess}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
disabled={!hasAccess}
/>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasAccess || !isDirty}
>
{t("Save")}
</Button>
</Group>
</Tooltip>
</div>
);
}
@@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
@@ -42,6 +43,13 @@ export default function Security() {
</>
)}
{!isCloud() && (
<>
<TrashRetention />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
@@ -1,8 +1,8 @@
import { useState } from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "Email is required" }),
});
type FormValues = z.infer<typeof formSchema>;
export function ForgotPasswordForm() {
const { t } = useTranslation();
@@ -21,14 +21,14 @@ export function ForgotPasswordForm() {
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
const form = useForm<IForgotPassword>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: IForgotPassword) {
async function onSubmit(data: FormValues) {
if (await forgotPassword(data)) {
setIsTokenSent(true);
}
@@ -1,5 +1,5 @@
import * as React from "react";
import * as z from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import {
@@ -11,9 +11,8 @@ import {
Box,
Stack,
} from "@mantine/core";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -40,14 +39,14 @@ export function InviteSignUpForm() {
useRedirectIfAuthenticated();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
password: "",
},
});
async function onSubmit(data: IRegister) {
async function onSubmit(data: FormValues) {
const invitationToken = searchParams.get("token");
await invitationSignup({
@@ -1,7 +1,7 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from "@/features/auth/types/auth.types";
import {
Container,
Title,
@@ -24,11 +24,11 @@ import React from "react";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
type FormValues = z.infer<typeof formSchema>;
export function LoginForm() {
const { t } = useTranslation();
@@ -41,15 +41,15 @@ export function LoginForm() {
error,
} = useWorkspacePublicDataQuery();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
email: "",
password: "",
},
});
async function onSubmit(data: ILogin) {
async function onSubmit(data: FormValues) {
await signIn(data);
}
@@ -1,7 +1,7 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { IPasswordReset } from "@/features/auth/types/auth.types";
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
@@ -12,6 +12,7 @@ const formSchema = z.object({
.string()
.min(8, { message: "Password must contain at least 8 characters" }),
});
type FormValues = z.infer<typeof formSchema>;
interface PasswordResetFormProps {
resetToken?: string;
@@ -22,14 +23,14 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IPasswordReset>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
newPassword: "",
},
});
async function onSubmit(data: IPasswordReset) {
async function onSubmit(data: FormValues) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword,
@@ -1,6 +1,7 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Container,
Title,
@@ -11,7 +12,6 @@ import {
Anchor,
Text,
} from "@mantine/core";
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
@@ -24,19 +24,19 @@ const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50),
email: z
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
export function SetupWorkspaceForm() {
const { t } = useTranslation();
const { setupWorkspace, isLoading } = useAuth();
// useRedirectIfAuthenticated();
const form = useForm<ISetupWorkspace>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
workspaceName: "",
name: "",
@@ -45,7 +45,7 @@ export function SetupWorkspaceForm() {
},
});
async function onSubmit(data: ISetupWorkspace) {
async function onSubmit(data: FormValues) {
await setupWorkspace(data);
}
@@ -23,7 +23,7 @@ import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@@ -44,11 +44,11 @@ export default function useAuth() {
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err) {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export function useRedirectIfAuthenticated() {
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
useEffect(() => {
if (data && data?.user) {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
}, [isLoading, data]);
}
@@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
@@ -37,8 +36,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
@@ -56,6 +53,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
pageId: pageId,
content: JSON.stringify(comment),
selection: selectedText,
type: "inline",
};
const createdComment =
@@ -81,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} finally {
setShowCommentPopup(false);
setDraftCommentId("");
@@ -103,6 +97,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
size="lg"
radius="md"
w={300}
zIndex={180}
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
@@ -2,7 +2,7 @@ import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
@@ -18,7 +18,6 @@ import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useTranslation } from "react-i18next";
interface CommentListItemProps {
@@ -45,8 +44,8 @@ function CommentListItem({
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => {
setContent(comment.content);
@@ -65,11 +64,6 @@ function CommentListItem({
editContentRef.current = null;
}
setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to update comment:", error);
} finally {
@@ -81,11 +75,6 @@ function CommentListItem({
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to delete comment:", error);
}
@@ -106,11 +95,6 @@ function CommentListItem({
if (editor) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
@@ -177,7 +161,7 @@ function CommentListItem({
<Group gap="xs">
<Text size="xs" fw={500} c="dimmed">
{timeAgo(comment.createdAt)}
{createdAtAgo}
</Text>
</Group>
</div>
@@ -1,6 +1,17 @@
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
import {
ActionIcon,
Center,
Divider,
Group,
Paper,
Stack,
Tabs,
Badge,
Text,
ScrollArea,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
@@ -14,14 +25,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -31,21 +36,12 @@ function CommentListWithTabs() {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
} = useCommentsQuery({ pageId: page?.id });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
const canComment = page?.permissions?.canEdit ?? false;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -54,19 +50,47 @@ function CommentListWithTabs() {
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null
(comment: IComment) => comment.parentCommentId === null,
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt
(comment: IComment) => !comment.resolvedAt,
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt
(comment: IComment) => comment.resolvedAt,
);
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
const handleAddPageComment = useCallback(
async (_commentId: string, content: string) => {
try {
setIsPageCommentLoading(true);
const createdComment = await createCommentMutation.mutateAsync({
pageId: page?.id,
content: JSON.stringify(content),
});
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 400);
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsPageCommentLoading(false);
}
},
[createCommentMutation, page?.id],
);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
@@ -78,18 +102,13 @@ function CommentListWithTabs() {
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id]
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
@@ -131,7 +150,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
[comments, handleAddReply, isLoading, space?.membership?.role],
);
if (isCommentsLoading) {
@@ -144,63 +163,32 @@ function CommentListWithTabs() {
const totalComments = activeComments.length + resolvedComments.length;
// If not cloud/enterprise, show simple list without tabs
if (!isCloudEE) {
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "200px" }}>
{comments?.items
.filter((comment: IComment) => comment.parentCommentId === null)
.map((comment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
{canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
))}
</div>
</ScrollArea>
);
}
const pageCommentInput = canComment ? (
<PageCommentInput
onSave={handleAddPageComment}
isLoading={isPageCommentLoading}
/>
) : null;
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Tabs
defaultValue="open"
variant="default"
style={{
flex: "1 1 auto",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
@@ -225,16 +213,25 @@ function CommentListWithTabs() {
</Tabs.List>
<ScrollArea
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
style={{ flex: "1 1 auto" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>
<div style={{ paddingBottom: "8px" }}>
<Tabs.Panel value="open" pt="xs">
{activeComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No open comments.")}
</Text>
<Center py="xl">
<Stack align="center" gap="xs">
<IconMessageOff
size={32}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
<Text size="sm" c="dimmed">
{t("No open comments.")}
</Text>
</Stack>
</Center>
) : (
activeComments.map(renderComments)
)}
@@ -242,9 +239,18 @@ function CommentListWithTabs() {
<Tabs.Panel value="resolved" pt="xs">
{resolvedComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No resolved comments.")}
</Text>
<Center py="xl">
<Stack align="center" gap="xs">
<IconMessageOff
size={32}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
<Text size="sm" c="dimmed">
{t("No resolved comments.")}
</Text>
</Stack>
</Center>
) : (
resolvedComments.map(renderComments)
)}
@@ -252,6 +258,7 @@ function CommentListWithTabs() {
</div>
</ScrollArea>
</Tabs>
{pageCommentInput}
</div>
);
}
@@ -273,9 +280,9 @@ const ChildComments = ({
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items]
[comments.items],
);
return (
@@ -303,7 +310,12 @@ const ChildComments = ({
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const CommentEditorWithActions = ({
commentId,
onSave,
isLoading,
placeholder = undefined,
}) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
@@ -321,10 +333,57 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={placeholder}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
const PageCommentInput = ({ onSave, isLoading }) => {
const { t } = useTranslation();
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(null, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [content, onSave]);
return (
<div
ref={ref}
style={{
flex: "0 0 auto",
borderTop: "1px solid var(--mantine-color-default-border)",
paddingTop: "var(--mantine-spacing-sm)",
paddingBottom: 25,
position: "relative",
}}
>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
{focused && (
<ActionIcon
variant="filled"
radius="xl"
size="sm"
onClick={handleSave}
loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }}
>
<IconArrowUp size={16} />
</ActionIcon>
)}
</div>
);
};
export default CommentListWithTabs;
@@ -1,8 +1,8 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import {
createComment,
@@ -17,17 +17,40 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
export function useCommentsQuery(
params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({
export function useCommentsQuery(params: ICommentParams) {
const query = useInfiniteQuery({
queryKey: RQ_KEY(params.pageId),
queryFn: () => getPageComments(params),
queryFn: ({ pageParam }) =>
getPageComments({ pageId: params.pageId, cursor: pageParam, limit: 100 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
enabled: !!params.pageId,
});
useEffect(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage();
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
const data = useMemo<IPagination<IComment> | undefined>(() => {
if (!query.data) return undefined;
return {
items: query.data.pages.flatMap((p) => p.items),
meta: query.data.pages[query.data.pages.length - 1].meta,
};
}, [query.data]);
return {
data,
isLoading: query.isLoading || query.hasNextPage,
isError: query.isError,
};
}
export function useCreateCommentMutation() {
@@ -36,18 +59,26 @@ export function useCreateCommentMutation() {
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
onSuccess: (data) => {
//const newComment = data;
// let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
// if (comments) {
//comments = prevComments => [...prevComments, newComment];
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
//}
onSuccess: (newComment) => {
const cache = queryClient.getQueryData(
RQ_KEY(newComment.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache && cache.pages.length > 0) {
const lastIdx = cache.pages.length - 1;
queryClient.setQueryData(RQ_KEY(newComment.pageId), {
...cache,
pages: cache.pages.map((page, i) =>
i === lastIdx
? { ...page, items: [...page.items, newComment] }
: page,
),
});
}
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: t("Comment created successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Error creating comment"),
color: "red",
@@ -57,14 +88,31 @@ export function useCreateCommentMutation() {
}
export function useUpdateCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => updateComment(data),
onSuccess: (data) => {
onSuccess: (updatedComment) => {
const cache = queryClient.getQueryData(
RQ_KEY(updatedComment.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(RQ_KEY(updatedComment.pageId), {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === updatedComment.id ? updatedComment : comment,
),
})),
});
}
notifications.show({ message: t("Comment updated successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Failed to update comment"),
color: "red",
@@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) {
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: (data, variables) => {
const comments = queryClient.getQueryData(
onSuccess: (_data, commentId) => {
const cache = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
) as InfiniteData<IPagination<IComment>> | undefined;
if (comments && comments.items) {
const commentId = variables;
const newComments = comments.items.filter(
(comment) => comment.id !== commentId,
);
if (cache) {
queryClient.setQueryData(RQ_KEY(pageId), {
...comments,
items: newComments,
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.filter((comment) => comment.id !== commentId),
})),
});
}
notifications.show({ message: t("Comment deleted successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Failed to delete comment"),
color: "red",
@@ -164,7 +164,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
return (
<BubbleMenu
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
style={{ zIndex: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
@@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
@@ -20,7 +21,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
editor.chain().focus().setLink({ href: url }).run();
editor
.chain()
.focus()
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
@@ -1,22 +1,25 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
if (!state) {
return false;
}
if (isTextSelected(editor)) return false;
return editor.isActive("callout");
},
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isNote: ctx.editor.isActive("callout", { type: "note" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
@@ -126,48 +131,73 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label={t("Info")}>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Info")} withinPortal={false}>
<ActionIcon
onClick={() => setCalloutType("info")}
size="lg"
aria-label={t("Info")}
variant={editorState?.isInfo ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isInfo })}
>
<IconInfoCircleFilled size={18} />
<IconInfoCircleFilled
size={18}
color="var(--mantine-color-blue-5)"
/>
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Success")}>
<Tooltip position="top" label={t("Note")} withinPortal={false}>
<ActionIcon
onClick={() => setCalloutType("note")}
size="lg"
aria-label={t("Note")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isNote })}
>
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Success")} withinPortal={false}>
<ActionIcon
onClick={() => setCalloutType("success")}
size="lg"
aria-label={t("Success")}
variant={editorState?.isSuccess ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isSuccess })}
>
<IconCircleCheckFilled size={18} />
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-green-5)"
/>
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Warning")}>
<Tooltip position="top" label={t("Warning")} withinPortal={false}>
<ActionIcon
onClick={() => setCalloutType("warning")}
size="lg"
aria-label={t("Warning")}
variant={editorState?.isWarning ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isWarning })}
>
<IconAlertTriangleFilled size={18} />
<IconAlertTriangleFilled
size={18}
color="var(--mantine-color-orange-5)"
/>
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Danger")}>
<Tooltip position="top" label={t("Danger")} withinPortal={false}>
<ActionIcon
onClick={() => setCalloutType("danger")}
size="lg"
aria-label={t("Danger")}
variant={editorState?.isDanger ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isDanger })}
>
<IconCircleXFilled size={18} />
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
</ActionIcon>
</Tooltip>
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{
size: "lg",
variant: "default",
c: undefined,
variant: "subtle",
}}
/>
</ActionIcon.Group>
</div>
</BaseBubbleMenu>
);
}
@@ -4,6 +4,7 @@ import {
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconNotes,
} from "@tabler/icons-react";
import { Alert } from "@mantine/core";
import classes from "./callout.module.css";
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
icon={getCalloutIcon(type, icon)}
p="xs"
classNames={{
root: classes.root,
message: classes.message,
icon: classes.icon,
}}
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
}
switch (type) {
case "info":
return <IconInfoCircleFilled />;
case "note":
return <IconNotes />;
case "success":
return <IconCircleCheckFilled />;
case "warning":
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
switch (type) {
case "info":
return "blue";
case "note":
return "grape";
case "success":
return "green";
case "warning":
@@ -1,9 +1,13 @@
.root {
overflow: visible;
}
.icon {
font-size: 24px;
line-height: 1;
width: 20px;
height: 20px;
margin-inline-end: var(--mantine-spacing-md);
margin-inline-end: var(--mantine-spacing-xs);
margin-top: 4px;
cursor: pointer;
}
@@ -11,18 +15,8 @@
.message {
font-size: var(--mantine-font-size-md);
color: var(--mantine-color-default-color);
white-space: nowrap;
overflow: visible;
text-overflow: unset;
word-break: break-word;
overflow-wrap: break-word;
}
/*
@mixin where-light {
color: var(--mantine-color-default-color);
}
@mixin where-dark {
color: var(--mantine-color-default-color);
}
*/
@@ -0,0 +1,361 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef, useState } from "react";
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core";
import clsx from "clsx";
import {
IconChevronDown,
IconCheck,
IconColumns2,
IconColumns3,
IconLayoutSidebar,
IconLayoutSidebarRight,
IconLayoutAlignCenter,
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
type LayoutPreset = {
layout: ColumnsLayout;
label: string;
icon: React.ElementType;
};
const twoColumnPresets: LayoutPreset[] = [
{ layout: "two_equal", label: "Equal columns", icon: IconColumns2 },
{
layout: "two_left_sidebar",
label: "Left sidebar",
icon: IconLayoutSidebar,
},
{
layout: "two_right_sidebar",
label: "Right sidebar",
icon: IconLayoutSidebarRight,
},
];
const threeColumnPresets: LayoutPreset[] = [
{ layout: "three_equal", label: "Equal columns", icon: IconColumns3 },
{
layout: "three_with_sidebars",
label: "Wide center",
icon: IconLayoutAlignCenter,
},
{
layout: "three_left_wide",
label: "Left wide",
icon: IconLayoutSidebarRight,
},
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar },
];
function getPresetsForCount(count: number): LayoutPreset[] {
if (count === 2) return twoColumnPresets;
if (count === 3) return threeColumnPresets;
return [];
}
export function ColumnsMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [isCountOpen, setIsCountOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const nodesWithMenus = [
"callout",
"image",
"video",
"drawio",
"excalidraw",
"table",
];
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
if (!parent) return false;
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement;
if (!dom) return false;
const rect = dom.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
const { selection } = ctx.editor.state;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(selection);
return {
columnCount: parent?.node.childCount || 2,
layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal",
isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }),
isWide: ctx.editor.isActive("columns", { widthMode: "wide" }),
};
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
// Columns entirely out of viewport — return real rect so menu goes off-screen
if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) {
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
// Clamp bottom so menu stays within viewport when columns extend below it
// 55px = 15px offset + ~40px menu height
const maxBottom = window.innerHeight - 55;
if (domRect.bottom > maxBottom) {
const clamped = new DOMRect(
domRect.x,
domRect.y,
domRect.width,
maxBottom - domRect.y,
);
return {
getBoundingClientRect: () => clamped,
getClientRects: () => [clamped],
};
}
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const setColumnCount = useCallback(
(count: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setColumnCount(count)
.run();
setIsCountOpen(false);
},
[editor],
);
const setLayout = useCallback(
(layout: ColumnsLayout) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setColumnsLayout(layout)
.run();
},
[editor],
);
const handleCopy = useCallback(() => {
const { state } = editor;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
if (!parent) return;
const serializer = DOMSerializer.fromSchema(state.schema);
const dom = serializer.serializeNode(parent.node);
const wrapper = document.createElement("div");
wrapper.appendChild(dom);
const onSuccess = () => {
clearTimeout(copyTimerRef.current);
setCopied(true);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
};
if (navigator.clipboard?.write) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob([parent.node.textContent], {
type: "text/plain",
}),
}),
])
.then(onSuccess)
.catch(execCommandFallback);
} else {
execCommandFallback();
}
function execCommandFallback() {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand("copy");
sel?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
onSuccess();
}
}, [editor]);
const handleDelete = useCallback(() => {
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(editor.state.selection);
if (!parent) return;
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().run();
}, [editor]);
const columnCount = editorState?.columnCount || 2;
const currentLayout = editorState?.layout || "two_equal";
const presets = getPresetsForCount(columnCount);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="columns-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
offset: {
mainAxis: 5,
},
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
<Popover.Target>
<Button
variant="subtle"
color="dark"
size="compact-sm"
rightSection={<IconChevronDown size={12} />}
onClick={() => setIsCountOpen(!isCountOpen)}
aria-label={t("Column count")}
>
{t("{{count}} Columns", { count: columnCount })}
</Button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Button.Group orientation="vertical">
{[2, 3, 4, 5].map((n) => (
<Button
key={n}
variant={n === columnCount ? "light" : "subtle"}
color={n === columnCount ? "blue" : "dark"}
justify="space-between"
fullWidth
rightSection={
n === columnCount ? <IconCheck size={14} /> : null
}
onClick={() => setColumnCount(n)}
size="xs"
>
{t("{{count}} Columns", { count: n })}
</Button>
))}
</Button.Group>
</Popover.Dropdown>
</Popover>
{presets.length > 0 && <div className={classes.divider} />}
{presets.map((preset) => (
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
<ActionIcon
onClick={() => setLayout(preset.layout)}
size="lg"
aria-label={t(preset.label)}
variant="subtle"
className={clsx({
[classes.active]: currentLayout === preset.layout,
})}
>
<preset.icon size={18} />
</ActionIcon>
</Tooltip>
))}
<div className={classes.divider} />
<Tooltip
position="top"
label={copied ? t("Copied") : t("Copy")}
withinPortal={false}
>
<ActionIcon
onClick={handleCopy}
size="lg"
aria-label={t("Copy")}
variant="subtle"
>
{copied ? (
<IconCheck size={18} color="var(--mantine-color-green-6)" />
) : (
<IconCopy size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default ColumnsMenu;
@@ -4,6 +4,20 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import {
getAttachmentInfo,
uploadFile,
} from "@/features/page/services/page-service.ts";
const ATTACHMENT_NODE_TYPES = [
"image",
"video",
"attachment",
"excalidraw",
"drawio",
];
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
export const handlePaste = (
editor: Editor,
@@ -19,7 +33,6 @@ export const handlePaste = (
const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
// pasted link must be from the same workspace/domain and must not be on a selection
if (!empty || match[2] !== window.location.host) {
@@ -27,12 +40,6 @@ export const handlePaste = (
return false;
}
// for now, we only support internal links from the same space
// compare space name
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
return false;
}
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
@@ -47,7 +54,10 @@ export const handlePaste = (
return true;
}
if (event.clipboardData?.files.length) {
const htmlData = event.clipboardData?.getData("text/html");
const hasHtmlTable = htmlData && /<table[\s>]/i.test(htmlData);
if (event.clipboardData?.files.length && !hasHtmlTable) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from;
@@ -57,9 +67,151 @@ export const handlePaste = (
}
return true;
}
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
const pasteFrom = editor.state.selection.from;
setTimeout(() => {
reuploadPastedAttachments(editor, pageId, pasteFrom);
}, 0);
}
return false;
};
async function reuploadPastedAttachments(
editor: Editor,
pageId: string,
pasteFrom: number,
) {
const pasteEnd = editor.state.selection.from;
if (pasteEnd <= pasteFrom) return;
type PastedNode = {
pos: number;
attachmentId: string;
nodeTypeName: string;
src?: string;
url?: string;
fileName?: string;
};
const pastedNodes: PastedNode[] = [];
const seenAttachmentIds = new Set<string>();
editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => {
if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
if (!attachmentId) return;
const src = node.attrs.src || node.attrs.url || "";
const match = ATTACHMENT_URL_RE.exec(src);
if (!match) return;
const fileName =
node.attrs.name || src.split("/").pop() || "file";
pastedNodes.push({
pos,
attachmentId,
nodeTypeName: node.type.name,
src: node.attrs.src,
url: node.attrs.url,
fileName,
});
seenAttachmentIds.add(attachmentId);
});
if (pastedNodes.length === 0) return;
const attachmentPageMap = new Map<string, string | null>();
await Promise.all(
[...seenAttachmentIds].map(async (id) => {
try {
const info = await getAttachmentInfo(id);
attachmentPageMap.set(id, info.pageId);
} catch {
attachmentPageMap.set(id, null);
}
}),
);
const nodesToReupload = pastedNodes.filter((n) => {
const ownerPageId = attachmentPageMap.get(n.attachmentId);
return ownerPageId !== null && ownerPageId !== pageId;
});
if (nodesToReupload.length === 0) return;
const uniqueNodes = new Map<string, (typeof nodesToReupload)[0]>();
for (const node of nodesToReupload) {
if (!uniqueNodes.has(node.attachmentId)) {
uniqueNodes.set(node.attachmentId, node);
}
}
const reuploadResults = new Map<
string,
{ id: string; fileName: string; fileSize: number; mimeType: string }
>();
await Promise.all(
[...uniqueNodes.values()].map(async (node) => {
const fileUrl = node.src || node.url;
if (!fileUrl) return;
try {
const response = await fetch(fileUrl, { credentials: "include" });
if (!response.ok) return;
const blob = await response.blob();
const file = new File([blob], node.fileName, { type: blob.type });
const newAttachment = await uploadFile(file, pageId);
reuploadResults.set(node.attachmentId, {
id: newAttachment.id,
fileName: newAttachment.fileName,
fileSize: newAttachment.fileSize,
mimeType: newAttachment.mimeType,
});
} catch {
// keep original reference on failure
}
}),
);
if (reuploadResults.size === 0) return;
editor.chain().command(({ tr }) => {
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
for (const pastedNode of sorted) {
const result = reuploadResults.get(pastedNode.attachmentId);
if (!result) continue;
const node = tr.doc.nodeAt(pastedNode.pos);
if (!node || node.attrs.attachmentId !== pastedNode.attachmentId)
continue;
const newAttrs = { ...node.attrs };
newAttrs.attachmentId = result.id;
if (newAttrs.src) {
newAttrs.src = `/api/files/${result.id}/${result.fileName}`;
}
if (newAttrs.url) {
newAttrs.url = `/api/files/${result.id}/${result.fileName}`;
}
if (pastedNode.nodeTypeName === "attachment") {
newAttrs.name = result.fileName;
newAttrs.mime = result.mimeType;
newAttrs.size = result.fileSize;
}
tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs);
}
return true;
}).run();
}
export const handleFileDrop = (
editor: Editor,
event: DragEvent,
@@ -0,0 +1,35 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
): HTMLElement {
const handle = document.createElement("div");
handle.dataset.resizeHandle = direction;
handle.style.position = "absolute";
handle.className = classes.handle;
if (direction === "left") {
handle.style.left = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
} else if (direction === "right") {
handle.style.right = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
}
const bar = document.createElement("div");
bar.className = classes.handleBar;
handle.appendChild(bar);
return handle;
}
export function buildResizeClasses(nodeClass: string) {
return {
container: `${classes.container} ${nodeClass}`,
wrapper: classes.wrapper,
resizing: classes.resizing,
};
}
@@ -0,0 +1,75 @@
.container {
display: flex;
}
.wrapper {
position: relative;
border-radius: 8px;
overflow: visible;
max-width: 100%;
}
.wrapper img,
.wrapper video {
height: auto !important;
}
.resizing {
user-select: none;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.handle[data-resize-handle="left"] {
left: -8px;
}
.handle[data-resize-handle="right"] {
right: -8px;
}
.wrapper:hover .handle {
opacity: 1;
}
.container:global(.ProseMirror-selectednode) .handle {
opacity: 1;
}
.resizing .handle {
opacity: 1;
}
.handleBar {
width: 4px;
height: 48px;
border-radius: 4px;
transition: background-color 0.15s ease;
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
}
.handle:hover .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
.resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
@media print {
.handle {
display: none !important;
}
}
@@ -1,13 +1,11 @@
.wrapper {
position: relative;
width: 100%;
overflow: hidden;
overflow: visible;
border-radius: 8px;
}
.resizing {
user-select: none;
cursor: ns-resize;
}
.overlay {
@@ -20,12 +18,118 @@
background: transparent;
}
.cornerHandle {
position: absolute;
width: 36px;
height: 36px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
touch-action: none;
-webkit-user-select: none;
user-select: none;
&::before,
&::after {
content: "";
position: absolute;
border-radius: 1px;
background-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-5)
);
transition: background-color 0.15s ease;
}
&::before {
width: 28px;
height: 3px;
}
&::after {
width: 3px;
height: 28px;
}
&:hover::before,
&:hover::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
}
.cornerHandleTL {
top: -2px;
left: -2px;
cursor: nwse-resize;
&::before {
top: 0;
left: 0;
}
&::after {
top: 0;
left: 0;
}
}
.cornerHandleTR {
top: -2px;
right: -2px;
cursor: nesw-resize;
&::before {
top: 0;
right: 0;
}
&::after {
top: 0;
right: 0;
}
}
.cornerHandleBL {
bottom: -2px;
left: -2px;
cursor: nesw-resize;
&::before {
bottom: 0;
left: 0;
}
&::after {
bottom: 0;
left: 0;
}
}
.cornerHandleBR {
bottom: -2px;
right: -2px;
cursor: nwse-resize;
&::before {
bottom: 0;
right: 0;
}
&::after {
bottom: 0;
right: 0;
}
}
.resizeHandleBottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
bottom: -4px;
left: 20px;
right: 20px;
height: 12px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
@@ -36,61 +140,53 @@
touch-action: none;
-webkit-user-select: none;
user-select: none;
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.05)
);
}
&:hover {
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.1)
);
}
}
}
.wrapper:hover .resizeHandleBottom,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizeBar {
width: 50px;
height: 4px;
height: 3px;
border-radius: 2px;
transition: background-color 0.2s ease;
@mixin light {
background-color: var(--mantine-color-gray-5);
}
@mixin dark {
background-color: var(--mantine-color-gray-6);
}
transition: background-color 0.15s ease;
background-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-5)
);
}
.resizeHandleBottom:hover .resizeBar {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
.wrapper:hover .cornerHandle,
.wrapper:hover .resizeHandleBottom,
.wrapper:global(.ProseMirror-selectednode) .cornerHandle,
.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom,
.resizing .cornerHandle,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizing .cornerHandle::before,
.resizing .cornerHandle::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
.resizeHandleBottom:hover .resizeBar,
.resizing .resizeBar {
@mixin light {
background-color: var(--mantine-color-gray-7);
}
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
@mixin dark {
background-color: var(--mantine-color-gray-4);
@media print {
.cornerHandle,
.resizeHandleBottom {
display: none !important;
}
}
@@ -2,111 +2,163 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
import clsx from "clsx";
import classes from "./resizable-wrapper.module.css";
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
br: { x: 1, y: 1 },
bl: { x: -1, y: 1 },
tr: { x: 1, y: -1 },
tl: { x: -1, y: -1 },
bottom: { x: 0, y: 1 },
};
const HANDLE_CURSOR: Record<Handle, string> = {
br: "nwse-resize",
tl: "nwse-resize",
bl: "nesw-resize",
tr: "nesw-resize",
bottom: "ns-resize",
};
const CORNER_CLASSES: Record<string, string> = {
tl: classes.cornerHandleTL,
tr: classes.cornerHandleTR,
bl: classes.cornerHandleBL,
br: classes.cornerHandleBR,
};
interface ResizableWrapperProps {
children: ReactNode;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
onResize?: (height: number) => void;
onResize?: (width: number, height: number) => void;
isEditable?: boolean;
className?: string;
showHandles?: "always" | "hover";
direction?: "vertical" | "horizontal" | "both";
selected?: boolean;
}
type DragState = {
handle: Handle;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
};
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
children,
initialWidth = 640,
initialHeight = 480,
minWidth = 200,
maxWidth = 1200,
minHeight = 200,
maxHeight = 1200,
onResize,
isEditable = true,
className,
showHandles = "hover",
direction = "vertical",
selected = false,
}) => {
const [resizeParams, setResizeParams] = useState<{
initialSize: number;
initialClientY: number;
initialClientX: number;
} | null>(null);
const [currentHeight, setCurrentHeight] = useState(initialHeight);
const [isResizing, setIsResizing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!resizeParams) return;
const dragRef = useRef<DragState | null>(null);
const widthRef = useRef(initialWidth);
const heightRef = useRef(initialHeight);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
const handleMouseMove = (e: MouseEvent) => {
if (!wrapperRef.current) return;
const handleMouseMove = useRef((e: MouseEvent) => {
const drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
if (direction === "vertical" || direction === "both") {
const deltaY = e.clientY - resizeParams.initialClientY;
const newHeight = Math.min(
Math.max(resizeParams.initialSize + deltaY, minHeight),
maxHeight
);
setCurrentHeight(newHeight);
wrapperRef.current.style.height = `${newHeight}px`;
}
const sign = HANDLE_SIGN[drag.handle];
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
const deltaY = e.clientY - drag.startY;
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
heightRef.current = newHeight;
wrapperRef.current.style.height = `${newHeight}px`;
if (sign.x !== 0) {
const deltaX = e.clientX - drag.startX;
const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
widthRef.current = newWidth;
wrapperRef.current.style.width = `${newWidth}px`;
}
}).current;
const handleMouseUp = useRef(() => {
dragRef.current = null;
setIsResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
onResizeRef.current?.(widthRef.current, heightRef.current);
}).current;
const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => {
e.preventDefault();
e.stopPropagation();
dragRef.current = {
handle,
startX: e.clientX,
startY: e.clientY,
startWidth: widthRef.current,
startHeight: heightRef.current,
};
const handleMouseUp = () => {
setResizeParams(null);
if (onResize && currentHeight !== initialHeight) {
onResize(currentHeight);
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
setIsResizing(true);
document.body.style.cursor = HANDLE_CURSOR[handle];
document.body.style.userSelect = "none";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [handleMouseMove, handleMouseUp]);
useEffect(() => {
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
}, [handleMouseMove, handleMouseUp]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizeParams({
initialSize: currentHeight,
initialClientY: e.clientY,
initialClientX: e.clientX,
});
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
}, [currentHeight]);
const shouldShowHandles =
isEditable &&
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
return (
<div
ref={wrapperRef}
className={clsx(classes.wrapper, className, {
[classes.resizing]: !!resizeParams,
[classes.resizing]: isResizing,
})}
style={{ height: currentHeight }}
style={{ width: widthRef.current, height: heightRef.current }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{!!resizeParams && <div className={classes.overlay} />}
{shouldShowHandles && direction === "vertical" && (
<div
className={classes.resizeHandleBottom}
onMouseDown={handleResizeStart}
>
<div className={classes.resizeBar} />
</div>
{isResizing && <div className={classes.overlay} />}
{shouldShowHandles && (
<>
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
<div
key={corner}
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
onMouseDown={(e) => handleResizeStart(e, corner)}
/>
))}
<div
className={classes.resizeHandleBottom}
onMouseDown={(e) => handleResizeStart(e, "bottom")}
>
<div className={classes.resizeBar} />
</div>
</>
)}
</div>
);
};
};
@@ -0,0 +1,29 @@
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 8px;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
}
.toolbar :global(.mantine-ActionIcon-root) {
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
}
.toolbar .active {
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 2px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
}
@@ -1,24 +1,46 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import {
ActionIcon,
Modal,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconEdit,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
import { uploadFile } from "@/features/page/services/page-service.ts";
import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor],
);
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme();
const editorState = useEditorState({
editor,
@@ -30,11 +52,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
src: drawioAttr?.src || null,
attachmentId: drawioAttr?.attachmentId || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
@@ -57,38 +94,222 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes("drawio", { width: `${value}%` });
const alignLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("left")
.run();
}, [editor]);
const alignCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("center")
.run();
}, [editor]);
const alignRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("right")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
editor.commands.updateAttributes("drawio", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
},
[editor],
[editor, editorState?.attachmentId, close],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
<>
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon
onClick={alignLeft}
size="lg"
aria-label={t("Align left")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip
position="top"
label={t("Align center")}
withinPortal={false}
>
<ActionIcon
onClick={alignCenter}
size="lg"
aria-label={t("Align center")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignRight}
size="lg"
aria-label={t("Align right")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
size="lg"
aria-label={t("Edit")}
variant="subtle"
>
<IconEdit size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
<div style={{ height: "100vh" }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
libraries: true,
saveAndExit: true,
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
close();
}}
/>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Card,
Image,
Modal,
Text,
useComputedColorScheme,
@@ -10,7 +9,7 @@ import {
import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
import { getDrawioUrl } from "@/lib/config.ts";
import {
DrawIoEmbed,
DrawIoEmbedRef,
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const { attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
if (!editor.isEditable) {
return;
}
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
}
} catch (err) {
console.error(err);
} finally {
open();
}
open();
};
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
// If the exit is triggered by another event, then do nothing
if (data.parentEvent) {
return;
}
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
</Modal.Content>
</Modal.Root>
{src ? (
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
) : (
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
</Card>
)}
</Card>
</NodeViewWrapper>
);
}
@@ -1,3 +1,12 @@
:global(.ProseMirror .node-embed.ProseMirror-selectednode) {
outline: none;
}
.embedContainer {
display: flex;
justify-content: center;
}
.embedWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
@@ -13,4 +22,4 @@
height: 100%;
border: none;
border-radius: 8px;
}
}
@@ -12,9 +12,9 @@ import {
TextInput,
} from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
@@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css";
const schema = z.object({
url: z
.string()
.trim()
.url({ message: i18n.t("Please enter a valid url") }),
url: z.url({ message: i18n.t("Please enter a valid url") }).trim(),
});
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { src, provider, height: nodeHeight } = node.attrs;
const { src, provider, width: nodeWidth, height: nodeHeight } = node.attrs;
const embedUrl = useMemo(() => {
if (src) {
@@ -49,12 +46,12 @@ export default function EmbedView(props: NodeViewProps) {
initialValues: {
url: "",
},
validate: zodResolver(schema),
validate: zod4Resolver(schema),
});
const handleResize = useCallback(
(newHeight: number) => {
updateAttributes({ height: newHeight });
(newWidth: number, newHeight: number) => {
updateAttributes({ width: newWidth, height: newHeight });
},
[updateAttributes],
);
@@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) {
}
return (
<NodeViewWrapper data-drag-handle>
<NodeViewWrapper data-drag-handle className={classes.embedNodeView}>
{embedUrl ? (
<ResizableWrapper
initialHeight={nodeHeight || 480}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
className={clsx(classes.embedWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
/>
</ResizableWrapper>
<div className={classes.embedContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 640}
initialHeight={nodeHeight || 480}
minWidth={200}
maxWidth={1200}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
selected={selected}
className={clsx(classes.embedWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
/>
</ResizableWrapper>
</div>
) : (
<Popover
width={300}
@@ -1,14 +1,77 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import {
ActionIcon,
Button,
Group,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconEdit,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
})),
);
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
useHandleLibrary({
excalidrawAPI,
adapter: localStorageLibraryAdapter,
});
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
src: excalidrawAttr?.src || null,
attachmentId: excalidrawAttr?.attachmentId || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@@ -22,21 +85,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
@@ -59,38 +107,252 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
},
[editor],
);
const alignLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("left")
.run();
}, [editor]);
const alignCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("center")
.run();
}, [editor]);
const alignRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("right")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(async () => {
if (!excalidrawAPI) {
return;
}
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div
<>
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon
onClick={alignLeft}
size="lg"
aria-label={t("Align left")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignLeft,
})}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip
position="top"
label={t("Align center")}
withinPortal={false}
>
<ActionIcon
onClick={alignCenter}
size="lg"
aria-label={t("Align center")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignCenter,
})}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
<ActionIcon
onClick={alignRight}
size="lg"
aria-label={t("Align right")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignRight,
})}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon
onClick={handleOpen}
size="lg"
aria-label={t("Edit")}
variant="subtle"
>
<IconEdit size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
<ReactClearModal
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 0,
zIndex: 200,
}}
isOpen={opened}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
padding: 0,
width: "90vw",
},
}}
>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
<Group
justify="flex-end"
wrap="nowrap"
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{
...excalidrawData,
scrollToContent: true,
}}
theme={computedColorScheme}
/>
</Suspense>
</div>
</ReactClearModal>
</>
);
}
@@ -4,28 +4,24 @@ import {
Button,
Card,
Group,
Image,
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
const Excalidraw = lazy(() =>
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
})),
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
export default function ExcalidrawView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const { attachmentId } = node.attrs;
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
if (!editor.isEditable) {
return;
}
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
}
} catch (err) {
console.error(err);
} finally {
open();
}
open();
};
const handleSave = async () => {
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</Group>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<Excalidraw
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{
...excalidrawData,
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
</div>
</ReactClearModal>
{src ? (
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
) : (
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
</Card>
)}
</Card>
</NodeViewWrapper>
);
}
@@ -1,22 +1,29 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const editorState = useEditorState({
editor,
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
src: imageAttrs?.src || null,
};
},
});
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageWidth(value)
.run();
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleReplace = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// @ts-ignore
const pageId = editor.storage?.pageId;
if (pageId) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
}
// Reset so the same file can be selected again
e.target.value = "";
},
[editor],
);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
@@ -118,44 +148,86 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label={t("Align left")}>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon
onClick={alignImageLeft}
size="lg"
aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align center")}>
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
<ActionIcon
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align right")}>
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
<ActionIcon
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Replace image")} withinPortal={false}>
<ActionIcon
onClick={handleReplace}
size="lg"
aria-label={t("Replace image")}
variant="subtle"
>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</BaseBubbleMenu>
);
}
@@ -0,0 +1,7 @@
import {
createResizeHandle,
buildResizeClasses,
} from "../common/node-resize-handles";
export const createImageHandle = createResizeHandle;
export const imageResizeClasses = buildResizeClasses("node-image");
@@ -0,0 +1,64 @@
.container {
display: flex;
}
.wrapper {
position: relative;
border-radius: 8px;
overflow: visible;
max-width: 100%;
}
.wrapper img {
height: auto !important;
}
.resizing {
user-select: none;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.handle[data-resize-handle="left"] {
left: -8px;
}
.handle[data-resize-handle="right"] {
right: -8px;
}
.wrapper:hover .handle {
opacity: 1;
}
.resizing .handle {
opacity: 1;
}
.handleBar {
width: 4px;
height: 48px;
border-radius: 4px;
transition: background-color 0.15s ease;
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
}
.handle:hover .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
.resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
@@ -2,6 +2,7 @@
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@@ -1,5 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
@@ -37,6 +38,10 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowEdit(false);
},
@@ -56,8 +56,11 @@ export default function MathBlockView(props: NodeViewProps) {
}, [debouncedPreview]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.text);
const pos = getPos();
const { from, to } = editor.state.selection;
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
setIsEditing(nodeSelected);
if (nodeSelected) setPreview(node.attrs.text);
}, [props.selected]);
return (
@@ -46,8 +46,11 @@ export default function MathInlineView(props: NodeViewProps) {
}, [preview, isEditing]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.text);
const pos = getPos();
const { from, to } = editor.state.selection;
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
setIsEditing(nodeSelected);
if (nodeSelected) setPreview(node.attrs.text);
}, [props.selected]);
return (
@@ -31,13 +31,17 @@ import {
MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts";
import { IPage } from "@/features/page/types/page.types";
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
import {
useCreatePageMutation,
usePageQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { extractPageSlugId } from "@/lib";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1);
@@ -59,11 +63,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includeUsers: true,
includePages: true,
spaceId: space.id,
limit: 10,
limit: props.query ? 10 : 5,
preload: true,
});
const createPageItem = (label: string) : MentionSuggestionItem => {
const createPageItem = (label: string): MentionSuggestionItem => {
return {
id: null,
label: label,
@@ -71,15 +75,15 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
entityId: null,
slugId: null,
icon: null,
}
}
};
};
useEffect(() => {
if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: t("Users") });
items.push({ entityType: "header", label: t("People") });
items = items.concat(
suggestion.users.map((user) => ({
@@ -97,11 +101,13 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
items = items.concat(
suggestion.pages.map((page) => ({
id: uuid7(),
label: page.title || "Untitled",
label: page.title || t("Untitled"),
entityType: "page",
entityId: page.id,
slugId: page.slugId,
icon: page.icon,
spaceName: page.space?.name,
spaceSlug: page.space?.slug,
})),
);
}
@@ -129,17 +135,17 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id,
});
}
if (item.entityType === "page" && item.id!==null) {
if (item.entityType === "page" && item.id !== null) {
props.command({
id: item.id,
label: item.label || "Untitled",
label: item.label || t("Untitled"),
entityType: "page",
entityId: item.entityId,
slugId: item.slugId,
creatorId: currentUser?.user.id,
});
}
if (item.entityType === "page" && item.id===null) {
if (item.entityType === "page" && item.id === null) {
createPage(item.label);
}
}
@@ -207,7 +213,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const payload: { spaceId: string; parentPageId?: string; title: string } = {
spaceId: space.id,
parentPageId: page.id || null,
title: title
title: title,
};
let createdPage: IPage;
@@ -231,7 +237,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
props.command({
id: uuid7(),
label: createdPage.title || "Untitled",
label: createdPage.title || "Untitled",
entityType: "page",
entityId: createdPage.id,
slugId: createdPage.slugId,
@@ -239,21 +245,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
});
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: space.id,
payload: {
parentId,
index: lastIndex,
data,
},
});
}, 50);
emit({
operation: "addTreeNode",
spaceId: space.id,
payload: {
parentId,
index: lastIndex,
data,
},
});
}, 50);
} catch (err) {
throw new Error("Failed to create page");
}
}
};
useEffect(() => {
viewportRef.current
@@ -267,15 +272,19 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<Text c="dimmed" size="sm" px="sm">
{ t("No results") }
{t("No results")}
</Text>
</Paper>
);
}
const hasUsers = renderItems.some((item) => item.entityType === "user");
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
const hasPages = renderItems.some(
(item) => item.entityType === "page" && item.id !== null,
);
const createPageItemData = renderItems.find(
(item) => item.entityType === "page" && item.id === null,
);
return (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
@@ -283,7 +292,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
viewportRef={viewportRef}
mah={350}
w={popupWidth}
scrollbars={"y"}
scrollbarSize={6}
styles={{ content: { minWidth: 0 } }}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
@@ -299,6 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
style={{ userSelect: "none" }}
>
{item.label}
</Text>
@@ -323,9 +335,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
<AutoTooltipText size="sm" fw={500}>
{item.label}
</Text>
</AutoTooltipText>
</div>
</Group>
</UnstyledButton>
@@ -355,9 +367,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<AutoTooltipText size="sm" fw={500} truncate>
{item.label}
</Text>
</AutoTooltipText>
{item.spaceName && (
<Text size="xs" c="dimmed" truncate>
{item.spaceName}
</Text>
)}
</div>
</Group>
</UnstyledButton>
@@ -372,9 +389,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
onClick={() =>
selectItem(renderItems.indexOf(createPageItemData))
}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
[classes.selectedItem]:
renderItems.indexOf(createPageItemData) === selectedIndex,
})}
px="sm"
>
@@ -388,7 +408,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
<Text size="sm" fw={500} truncate>
{t("Create page")}: {createPageItemData.label}
</Text>
@@ -106,7 +106,7 @@ const mentionRenderItems = () => {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
zIndex: "190",
});
});
},
@@ -54,12 +54,20 @@ export default function MentionView(props: NodeViewProps) {
</Text>
)}
{entityType === "page" && (
{entityType === "page" && isError && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor
component={Link}
fw={500}
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
isShareRoute
? shareSlugUrl
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
}
onClick={handleClick}
underline="never"
@@ -26,4 +26,6 @@ export type MentionSuggestionItem =
entityId: string;
slugId: string;
icon: string;
spaceName?: string;
spaceSlug?: string;
};
@@ -20,6 +20,9 @@ import {
IconCalendar,
IconAppWindow,
IconSitemap,
IconColumns3,
IconColumns2,
IconTag,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -31,6 +34,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
import { IconColumns4 } from "@/components/icons/icon-columns-4";
import { IconColumns5 } from "@/components/icons/icon-columns-5";
import {
AirtableIcon,
FigmaIcon,
@@ -381,6 +386,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Status",
description: "Insert inline status badge.",
searchTerms: ["status", "badge", "label", "lozenge"],
icon: IconTag,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setStatus({ text: "", color: "gray" })
.run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
@@ -390,6 +409,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "2 Columns",
description: "Split content into two columns.",
searchTerms: ["columns", "layout", "split", "side"],
icon: IconColumns2,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertColumns({ layout: "two_equal" })
.run(),
},
{
title: "3 Columns",
description: "Split content into three columns.",
searchTerms: ["columns", "layout", "split", "triple"],
icon: IconColumns3,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertColumns({ layout: "three_equal" })
.run(),
},
{
title: "4 Columns",
description: "Split content into four columns.",
searchTerms: ["columns", "layout", "split"],
icon: IconColumns4,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertColumns({ layout: "four_equal" })
.run(),
},
{
title: "5 Columns",
description: "Split content into five columns.",
searchTerms: ["columns", "layout", "split"],
icon: IconColumns5,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertColumns({ layout: "five_equal" })
.run(),
},
{
title: "Iframe embed",
description: "Embed any Iframe",
@@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Popover, TextInput, Group, Box } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./status.module.css";
import type { StatusColor } from "@docmost/editor-ext";
const STATUS_COLORS: { name: StatusColor; bg: string }[] = [
{ name: "gray", bg: "var(--mantine-color-gray-4)" },
{ name: "blue", bg: "var(--mantine-color-blue-4)" },
{ name: "green", bg: "var(--mantine-color-green-4)" },
{ name: "yellow", bg: "var(--mantine-color-yellow-4)" },
{ name: "red", bg: "var(--mantine-color-red-4)" },
{ name: "purple", bg: "var(--mantine-color-violet-4)" },
];
const colorClassMap: Record<StatusColor, string> = {
gray: classes.colorGray,
blue: classes.colorBlue,
green: classes.colorGreen,
yellow: classes.colorYellow,
red: classes.colorRed,
purple: classes.colorPurple,
};
export default function StatusView(props: NodeViewProps) {
const { node, updateAttributes, deleteNode, editor, getPos } = props;
const { text, color } = node.attrs as {
text: string;
color: StatusColor;
};
const [opened, setOpened] = useState(false);
const [inputValue, setInputValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const storage = editor.storage?.status;
if (storage?.autoOpen) {
storage.autoOpen = false;
setOpened(true);
}
}, []);
useEffect(() => {
if (opened) {
setInputValue(text);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [opened]);
const debouncedUpdateAttributes = useDebouncedCallback(
(val: string) => updateAttributes({ text: val }),
100,
);
const handleTextChange = (val: string) => {
setInputValue(val);
debouncedUpdateAttributes(val);
};
const handleColorChange = (newColor: StatusColor) => {
updateAttributes({ color: newColor });
};
const isEditable = editor.isEditable;
return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
<Popover
opened={opened}
onChange={(open) => {
if (!open && !text) {
deleteNode();
return;
}
setOpened(open);
}}
width={220}
position="bottom"
withArrow
shadow="md"
trapFocus
>
<Popover.Target>
<span
className={clsx(
"status-badge",
classes.status,
colorClassMap[color],
)}
onClick={() => isEditable && setOpened(true)}
role="button"
tabIndex={0}
>
{text || "SET STATUS"}
</span>
</Popover.Target>
<Popover.Dropdown>
<TextInput
ref={inputRef}
value={inputValue}
onChange={(e) =>
handleTextChange(e.currentTarget.value.toUpperCase())
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOpened(false);
editor.commands.focus(getPos() + node.nodeSize);
}
}}
placeholder="Status text"
size="sm"
mb="xs"
/>
<Group gap={6} justify="center">
{STATUS_COLORS.map(({ name, bg }) => (
<Box
key={name}
className={clsx(
classes.swatch,
color === name && classes.swatchActive,
)}
style={{ backgroundColor: bg }}
onClick={() => handleColorChange(name)}
>
{color === name && <IconCheck size={14} />}
</Box>
))}
</Group>
</Popover.Dropdown>
</Popover>
</NodeViewWrapper>
);
}
@@ -0,0 +1,65 @@
.status {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
line-height: 1.6;
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
user-select: none;
}
.colorGray {
background-color: light-dark(rgb(223 223 215), rgba(168, 162, 158, 0.4));
color: light-dark(#3d3d3d, var(--mantine-color-gray-3));
}
.colorBlue {
background-color: light-dark(rgb(191 227 253), rgba(37, 99, 235, 0.4));
color: light-dark(#1a4d99, var(--mantine-color-blue-3));
}
.colorGreen {
background-color: light-dark(rgb(187 240 173), rgba(0, 138, 0, 0.4));
color: light-dark(#135c13, var(--mantine-color-green-3));
}
.colorYellow {
background-color: light-dark(rgb(249 238 148), rgba(234, 179, 8, 0.4));
color: light-dark(#6b5300, var(--mantine-color-yellow-3));
}
.colorRed {
background-color: light-dark(rgb(255 200 195), rgba(224, 0, 0, 0.4));
color: light-dark(#a10000, var(--mantine-color-red-3));
}
.colorPurple {
background-color: light-dark(rgb(225 207 245), rgba(147, 51, 234, 0.4));
color: light-dark(#5b21a6, var(--mantine-color-violet-3));
}
.swatch {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.swatch:hover {
opacity: 0.8;
}
.swatchActive {
border-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
}
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
<Popover.Target>
<Tooltip label={t("Background color")} withArrow>
<ActionIcon
variant="default"
variant="subtle"
size="lg"
aria-label={t("Background color")}
onClick={() => setOpened(!opened)}
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
import classes from "../common/toolbar-menu.module.css";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<div className={classes.toolbar}>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<div className={classes.divider} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Merge cells")}
>
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Split cell")}>
<ActionIcon
onClick={splitCell}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Split cell")}
>
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete column")}
>
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete row")}
>
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon
onClick={toggleHeaderCell}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Toggle header cell")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</div>
</BubbleMenu>
);
}

Some files were not shown because too many files have changed in this diff Show More