mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 12:44:16 +08:00
Merge branch 'main' into base
This commit is contained in:
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
|
||||
import License from "@/ee/licence/pages/license.tsx";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
@@ -39,6 +40,12 @@ 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";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,6 +71,7 @@ export default function App() {
|
||||
<>
|
||||
<Route path={"/create"} element={<CreateWorkspace />} />
|
||||
<Route path={"/select"} element={<CloudLogin />} />
|
||||
<Route path={"/verify-email"} element={<VerifyEmail />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -75,12 +83,21 @@ export default function App() {
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/ai"} element={<AiChat />} />
|
||||
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||
<Route path={"/templates"} element={<TemplateList />} />
|
||||
<Route
|
||||
path={"/templates/:templateId"}
|
||||
element={<TemplateEditor />}
|
||||
/>
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
@@ -108,6 +125,7 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
@@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CopyProps {
|
||||
text: string;
|
||||
size?: MantineSize;
|
||||
color?: MantineColor;
|
||||
}
|
||||
export default function CopyTextButton({ text }: CopyProps) {
|
||||
export default function CopyTextButton({ text, size }: CopyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -22,6 +24,7 @@ export default function CopyTextButton({ text }: CopyProps) {
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
size={size}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
@@ -23,7 +24,8 @@ interface Props {
|
||||
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
@@ -33,58 +35,72 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages && pages.items.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
@@ -16,6 +29,9 @@
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
@@ -23,8 +33,11 @@ import {
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
const links = [
|
||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||
];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,10 +47,12 @@ export function AppHeader() {
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
const location = useLocation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@@ -49,39 +64,44 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt="Docmost"
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ userSelect: "none" }}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
@@ -98,6 +118,49 @@ export function AppHeader() {
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
{aiChatEnabled && (
|
||||
<>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to="/ai"
|
||||
className={classes.link}
|
||||
visibleFrom="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("AI Chat")}
|
||||
</UnstyledButton>
|
||||
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
hiddenFrom="sm"
|
||||
aria-label={t("AI Chat")}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconSparkles size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<NotificationPopover />
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
@@ -25,6 +26,10 @@ export default function Aside() {
|
||||
component = <TableOfContents editor={pageEditor} />;
|
||||
title = "Table of contents";
|
||||
break;
|
||||
case "chat":
|
||||
component = <AsideChatPanel />;
|
||||
title = "AI Chat";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
@@ -34,12 +39,14 @@ export default function Aside() {
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
{tab !== "chat" && (
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{tab === "comments" ? (
|
||||
<CommentListWithTabs />
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
component
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
@@ -72,24 +74,21 @@ export default function GlobalAppShell({
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isAiRoute = location.pathname.startsWith("/ai");
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}
|
||||
}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
@@ -102,20 +101,22 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
{isSpaceRoute && (
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
)}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={850}>{children}</Container>
|
||||
<Container size={900}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
.navbar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.spaceItem {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
IconStar,
|
||||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
];
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||
.filter((fav) => fav.space)
|
||||
.sort((a, b) => {
|
||||
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||
useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||
<div className={classes.section}>
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||
{t("Favorite spaces appear here")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||
<Link
|
||||
key={fav.id}
|
||||
className={classes.spaceItem}
|
||||
to={getSpaceUrl(fav.space!.slug)}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={fav.space!.name}
|
||||
avatarUrl={fav.space!.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{fav.space!.name}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
{sortedFavoriteSpaces.length > 10 && (
|
||||
<Link
|
||||
className={classes.spaceItem}
|
||||
to="/spaces"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("View all")}
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
<div className={classes.bottomSection}>
|
||||
<a
|
||||
className={classes.link}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openInvite();
|
||||
}}
|
||||
href="#"
|
||||
>
|
||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Invite People")}</span>
|
||||
</a>
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith("/settings") || undefined}
|
||||
to="/settings/account/profile"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="550"
|
||||
opened={inviteOpened}
|
||||
onClose={closeInvite}
|
||||
title={t("Invite new members")}
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<ScrollArea h="80%">
|
||||
<WorkspaceInviteForm onClose={closeInvite} />
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => {
|
||||
queryFn: () => getAuditLogs(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchVerifiedPages = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -21,7 +22,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import {
|
||||
prefetchApiKeyManagement,
|
||||
prefetchApiKeys,
|
||||
@@ -33,28 +36,26 @@ import {
|
||||
prefetchSsoProviders,
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
} 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";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||
|
||||
interface DataItem {
|
||||
type DataItem = {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
isCloud?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isOwner?: boolean;
|
||||
isSelfhosted?: boolean;
|
||||
showDisabledInNonEE?: boolean;
|
||||
}
|
||||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
};
|
||||
|
||||
interface DataGroup {
|
||||
type DataGroup = {
|
||||
heading: string;
|
||||
items: DataItem[];
|
||||
}
|
||||
};
|
||||
|
||||
const groupedData: DataGroup[] = [
|
||||
{
|
||||
@@ -70,9 +71,7 @@ const groupedData: DataGroup[] = [
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
showDisabledInNonEE: true,
|
||||
feature: Feature.API_KEYS,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -80,53 +79,50 @@ const groupedData: DataGroup[] = [
|
||||
heading: "Workspace",
|
||||
items: [
|
||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||
{
|
||||
label: "Members",
|
||||
icon: IconUsers,
|
||||
path: "/settings/members",
|
||||
},
|
||||
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||
{
|
||||
label: "Billing",
|
||||
icon: IconCoin,
|
||||
path: "/settings/billing",
|
||||
isCloud: true,
|
||||
isAdmin: true,
|
||||
role: "admin",
|
||||
env: "cloud",
|
||||
},
|
||||
{
|
||||
label: "Security & SSO",
|
||||
icon: IconLock,
|
||||
path: "/settings/security",
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
isAdmin: true,
|
||||
showDisabledInNonEE: true,
|
||||
feature: Feature.SECURITY_SETTINGS,
|
||||
role: "admin",
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
{
|
||||
label: "Verified pages",
|
||||
icon: IconShieldCheck,
|
||||
path: "/settings/verifications",
|
||||
feature: Feature.PAGE_VERIFICATION,
|
||||
},
|
||||
{
|
||||
label: "API management",
|
||||
icon: IconKey,
|
||||
path: "/settings/api-keys",
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
isAdmin: true,
|
||||
showDisabledInNonEE: true,
|
||||
feature: Feature.API_KEYS,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "AI settings",
|
||||
icon: IconSparkles,
|
||||
path: "/settings/ai",
|
||||
isAdmin: true,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "Audit log",
|
||||
icon: IconHistory,
|
||||
path: "/settings/audit",
|
||||
isEnterprise: true,
|
||||
isOwner: true,
|
||||
isSelfhosted: true,
|
||||
showDisabledInNonEE: true,
|
||||
feature: Feature.AUDIT_LOGS,
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -148,7 +144,8 @@ export default function SettingsSidebar() {
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const { goBack } = useSettingsNavigation();
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
@@ -156,43 +153,20 @@ export default function SettingsSidebar() {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const hasRoleAccess = (item: DataItem) => {
|
||||
if (item.isOwner) return isOwner;
|
||||
if (item.isAdmin) return isAdmin;
|
||||
const hasFeature = (f: string) =>
|
||||
entitlements?.features?.includes(f) ?? false;
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.env === "cloud" && !isCloud()) return false;
|
||||
if (item.env === "selfhosted" && isCloud()) return false;
|
||||
if (item.role === "admin" && !isAdmin) return false;
|
||||
if (item.role === "owner" && !isOwner) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
if (item.isSelfhosted && isCloud()) return false;
|
||||
return hasRoleAccess(item);
|
||||
}
|
||||
|
||||
if (item.isCloud && item.isEnterprise) {
|
||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||
return hasRoleAccess(item);
|
||||
}
|
||||
|
||||
if (item.isCloud) {
|
||||
return isCloud() ? hasRoleAccess(item) : false;
|
||||
}
|
||||
|
||||
if (item.isSelfhosted) {
|
||||
return !isCloud() ? hasRoleAccess(item) : false;
|
||||
}
|
||||
|
||||
if (item.isEnterprise) {
|
||||
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
|
||||
}
|
||||
|
||||
return hasRoleAccess(item);
|
||||
};
|
||||
|
||||
const isItemDisabled = (item: DataItem) => {
|
||||
if (item.showDisabledInNonEE && item.isEnterprise) {
|
||||
return !(isCloud() || workspace?.hasLicenseKey);
|
||||
}
|
||||
return false;
|
||||
if (!item.feature) return false;
|
||||
return !hasFeature(item.feature);
|
||||
};
|
||||
|
||||
const menuItems = groupedData.map((group) => {
|
||||
@@ -225,7 +199,7 @@ export default function SettingsSidebar() {
|
||||
prefetchHandler = prefetchBilling;
|
||||
break;
|
||||
case "License & Edition":
|
||||
if (workspace?.hasLicenseKey) {
|
||||
if (entitlements?.tier !== "free") {
|
||||
prefetchHandler = prefetchLicense;
|
||||
}
|
||||
break;
|
||||
@@ -244,6 +218,9 @@ export default function SettingsSidebar() {
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -280,7 +257,7 @@ export default function SettingsSidebar() {
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={t("Available in enterprise edition")}
|
||||
label={upgradeLabel}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function AutoTooltipText({
|
||||
disabled={!isTruncated || !label}
|
||||
multiline
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<Text
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
gap: var(--mantine-spacing-md);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track > * {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.root:hover .arrow.visible,
|
||||
.arrow.visible:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: -14px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./card-carousel.module.css";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export default function CardCarousel({ children, ariaLabel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 1);
|
||||
setCanScrollRight(el.scrollLeft < maxScroll - 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(updateScrollState);
|
||||
observer.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateScrollState, children]);
|
||||
|
||||
const scrollBy = (direction: 1 | -1) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={classes.track}
|
||||
onScroll={updateScrollState}
|
||||
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t("Scroll left")}
|
||||
tabIndex={canScrollLeft ? 0 : -1}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t("Scroll right")}
|
||||
tabIndex={canScrollRight ? 0 : -1}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
DestinationPickerModalProps,
|
||||
DestinationSelection,
|
||||
} from "./destination-picker.types";
|
||||
|
||||
export function DestinationPickerModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
actionLabel,
|
||||
onSelect,
|
||||
loading,
|
||||
excludePageId,
|
||||
pageLimit,
|
||||
}: DestinationPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setSelection(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size={550}
|
||||
padding="lg"
|
||||
yOffset="10vh"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<DestinationPicker
|
||||
onSelectionChange={setSelection}
|
||||
excludePageId={excludePageId}
|
||||
pageLimit={pageLimit}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selection && onSelect(selection)}
|
||||
disabled={!selection}
|
||||
loading={loading}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background-color 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.spaceRow {
|
||||
composes: row;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pageRow {
|
||||
composes: row;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedIndicator {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
composes: row;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { DestinationSelection } from "./destination-picker.types";
|
||||
import { SpaceRow } from "./space-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type DestinationPickerProps = {
|
||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
};
|
||||
|
||||
export function DestinationPicker({
|
||||
onSelectionChange,
|
||||
excludePageId,
|
||||
pageLimit = 15,
|
||||
}: DestinationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
|
||||
|
||||
const { data: searchData, isLoading: searchLoading } =
|
||||
useSearchSuggestionsQuery({
|
||||
query: searchEnabled ? debouncedQuery : "",
|
||||
includePages: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const isSearching = !!searchEnabled;
|
||||
|
||||
const selectedId =
|
||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: DestinationSelection | null) => {
|
||||
setSelection(next);
|
||||
onSelectionChange(next);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||
if (!page.space || !page.id) return;
|
||||
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space: page.space,
|
||||
});
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleSelectSpace = useCallback(
|
||||
(space: ISpace) => {
|
||||
updateSelection({ type: "space", spaceId: space.id, space });
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback(
|
||||
(page: Partial<IPage>, space: ISpace) => {
|
||||
if (!page.id) return;
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.spaceId ?? space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space,
|
||||
});
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("Search pages and spaces...")}
|
||||
variant="filled"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
|
||||
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
|
||||
{isSearching ? (
|
||||
searchLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||
searchData.pages.map(
|
||||
(page) =>
|
||||
page && (
|
||||
<div
|
||||
key={page.id}
|
||||
className={classes.searchResult}
|
||||
onClick={() => handleSearchResultClick(page)}
|
||||
>
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
{page.space && (
|
||||
<div className={classes.spaceName}>
|
||||
{page.space.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||
)
|
||||
) : spacesLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : (
|
||||
spacesData?.items?.map((space) => (
|
||||
<SpaceRow
|
||||
key={space.id}
|
||||
space={space}
|
||||
limit={pageLimit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectSpace={handleSelectSpace}
|
||||
onSelectPage={handleSelectPage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{selection && (
|
||||
<div className={classes.selectedIndicator}>
|
||||
{selection.type === "space"
|
||||
? selection.space.name
|
||||
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type DestinationSelection =
|
||||
| { type: "space"; spaceId: string; space: ISpace }
|
||||
| {
|
||||
type: "page";
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
page: Partial<IPage>;
|
||||
space: Partial<ISpace>;
|
||||
};
|
||||
|
||||
export type DestinationPickerModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { PageRow } from "./page-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageChildrenProps = {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectPage: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageChildren({
|
||||
spaceId,
|
||||
pageId,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectPage,
|
||||
}: PageChildrenProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({
|
||||
spaceId,
|
||||
pageId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages.map((page) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={depth}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelect={onSelectPage}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div className={classes.loadMore} onClick={() => fetchNextPage()}>
|
||||
{t("Load more")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageRowProps = {
|
||||
page: Partial<IPage>;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelect: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageRow({
|
||||
page,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelect,
|
||||
}: PageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isExcluded = page.id === excludePageId;
|
||||
const isSelected = page.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.pageRow,
|
||||
isSelected && classes.selected,
|
||||
isExcluded && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={rowClasses}
|
||||
style={{ paddingLeft: depth * 20 + 12 }}
|
||||
onClick={() => !isExcluded && onSelect(page)}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && page.hasChildren && (
|
||||
<PageChildren
|
||||
spaceId={page.spaceId}
|
||||
pageId={page.id}
|
||||
depth={depth + 1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={onSelect}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { SpaceRole } from "@/lib/types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type SpaceRowProps = {
|
||||
space: ISpace;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectSpace: (space: ISpace) => void;
|
||||
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||
};
|
||||
|
||||
export function SpaceRow({
|
||||
space,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectSpace,
|
||||
onSelectPage,
|
||||
}: SpaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const writable =
|
||||
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||
const isSelected = space.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.spaceRow,
|
||||
isSelected && classes.selected,
|
||||
!writable && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClasses}
|
||||
onClick={() => writable && onSelectSpace(space)}
|
||||
>
|
||||
{writable ? (
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
<div className={classes.pageTitle}>{space.name}</div>
|
||||
|
||||
{!writable && (
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{writable ? (
|
||||
rowContent
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t("You don't have permission to create pages here")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<div>{rowContent}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{expanded && writable && (
|
||||
<PageChildren
|
||||
spaceId={space.id}
|
||||
depth={1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={(page) => onSelectPage(page, space)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||
import { useChatStream } from "../hooks/use-chat-stream";
|
||||
import ChatMessageList from "./chat-message-list";
|
||||
import ChatEmptyState from "./chat-empty-state";
|
||||
import ChatInput from "./chat-input";
|
||||
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
export default function AiChatLayout() {
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||
|
||||
// If the URL points at a chat the user does not own, the info fetch 404s.
|
||||
// Bounce them back to /ai so they cannot interact with any chat UI (including
|
||||
// kicking off orphan uploads) tied to a chat they have no access to.
|
||||
useEffect(() => {
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
navigate("/ai", { replace: true });
|
||||
}
|
||||
}, [chatId, chatInfoQuery.isError, navigate]);
|
||||
const {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
} = useChatStream(chatId);
|
||||
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatInfoQuery.data?.messages) {
|
||||
hydrateFromServer(chatInfoQuery.data.messages);
|
||||
}
|
||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSentRef.current || chatId) return;
|
||||
const state = location.state as HomeAiPromptInitialState | null;
|
||||
if (!state?.initialContent && !state?.initialAttachments?.length) return;
|
||||
|
||||
autoSentRef.current = true;
|
||||
sendMessage(
|
||||
state.initialContent ?? "",
|
||||
state.initialMentions ?? [],
|
||||
state.initialAttachments ?? [],
|
||||
);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [chatId, location, navigate, sendMessage]);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming || !!chatId;
|
||||
|
||||
// While the redirect effect is running (or if the user is still on this
|
||||
// component for any reason) never render the chat UI for a forbidden chat.
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.main}>
|
||||
{hasMessages ? (
|
||||
<>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.inputArea}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ChatEmptyState
|
||||
isStreaming={isStreaming}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { ActionIcon, Menu, TextInput } from "@mantine/core";
|
||||
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
type Props = {
|
||||
chat: AiChat;
|
||||
isActive: boolean;
|
||||
onDelete: (chatId: string, title: string | null) => void;
|
||||
onRename: (chatId: string, title: string) => void;
|
||||
};
|
||||
|
||||
function formatChatDate(
|
||||
isoString: string | Date,
|
||||
locale: string | undefined,
|
||||
): string {
|
||||
const date = new Date(isoString);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const ts = date.getTime();
|
||||
const sameYear = date.getFullYear() === now.getFullYear();
|
||||
|
||||
if (ts >= startOfToday) {
|
||||
return date.toLocaleTimeString(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
if (sameYear) {
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AiChatSidebarItem({
|
||||
chat,
|
||||
isActive,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formattedDate = useMemo(
|
||||
() => formatChatDate(chat.updatedAt, i18n.language),
|
||||
[chat.updatedAt, i18n.language],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
// Wait for the input to be mounted before selecting.
|
||||
const id = window.setTimeout(() => inputRef.current?.select(), 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
}, [renaming]);
|
||||
|
||||
const startRename = useCallback(() => {
|
||||
setRenameValue(chat.title || "");
|
||||
setRenaming(true);
|
||||
}, [chat.title]);
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== chat.title) {
|
||||
onRename(chat.id, trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
}, [renameValue, chat.id, chat.title, onRename]);
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div className={classes.chatItem} data-active={isActive || undefined}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
placeholder={t("Chat name")}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
onBlur={submitRename}
|
||||
classNames={{ input: classes.chatItemRenameInput }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/ai/chat/${chat.id}`}
|
||||
className={classes.chatItem}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<span className={classes.chatItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</span>
|
||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||
<div className={classes.chatItemActions}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
{t("Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(chat.id, chat.title);
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Text,
|
||||
TextInput,
|
||||
Loader,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useChatsQuery,
|
||||
useDeleteChatMutation,
|
||||
useUpdateChatTitleMutation,
|
||||
useSearchChatsQuery,
|
||||
} from "../queries/ai-chat-query";
|
||||
import AiChatSidebarItem from "./ai-chat-sidebar-item";
|
||||
import { groupChatsByAge } from "../utils/group-chats-by-age";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
export default function AiChatSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const chatsQuery = useChatsQuery();
|
||||
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||
const deleteMutation = useDeleteChatMutation();
|
||||
const renameMutation = useUpdateChatTitleMutation();
|
||||
|
||||
const chats = useMemo(() => {
|
||||
if (debouncedSearch) {
|
||||
return searchQuery.data || [];
|
||||
}
|
||||
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
|
||||
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
|
||||
|
||||
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
|
||||
const isSearching = Boolean(debouncedSearch);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) return;
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate("/ai");
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, title: string | null) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete chat"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
|
||||
title: title || t("Untitled"),
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
if (chatId === id) {
|
||||
navigate("/ai");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[deleteMutation, chatId, navigate, t],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(chatId: string, title: string) => {
|
||||
renameMutation.mutate({ chatId, title });
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className={classes.sidebar}>
|
||||
<div className={classes.header}>
|
||||
<span className={classes.title}>{t("AI Chat")}</span>
|
||||
<Tooltip label={t("New chat")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/ai"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleNewChat}
|
||||
aria-label={t("New chat")}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={classes.searchInput}
|
||||
placeholder="Search chats..."
|
||||
leftSection={<IconSearch size={14} />}
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<div className={classes.chatList}>
|
||||
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className={classes.chatListEmpty}>
|
||||
<IconMessageCircle2
|
||||
size={28}
|
||||
stroke={1.5}
|
||||
className={classes.chatListEmptyIcon}
|
||||
/>
|
||||
<div className={classes.chatListEmptyTitle}>
|
||||
{isSearching ? t("No chats found") : t("No conversations yet")}
|
||||
</div>
|
||||
<div className={classes.chatListEmptyHint}>
|
||||
{isSearching
|
||||
? t("Try a different search term.")
|
||||
: t("Start a new chat to see it here.")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSearching
|
||||
? chats.map((chat) => (
|
||||
<AiChatSidebarItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === chatId}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))
|
||||
: groupedChats.map((group) => (
|
||||
<div key={group.key} className={classes.chatGroup}>
|
||||
<div className={classes.chatGroupLabel}>{group.label}</div>
|
||||
{group.chats.map((chat) => (
|
||||
<AiChatSidebarItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === chatId}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!isSearching && (
|
||||
<>
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
{isFetchingNextPage && (
|
||||
<Center py="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../styles/aside-chat-panel.module.css";
|
||||
|
||||
type Props = {
|
||||
activeChatId: string | undefined;
|
||||
onSelect: (chatId: string) => void;
|
||||
};
|
||||
|
||||
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||
|
||||
const chatsQuery = useChatsQuery();
|
||||
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||
|
||||
const isSearching = debouncedSearch.length > 0;
|
||||
const chats = isSearching
|
||||
? (searchQuery.data ?? [])
|
||||
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
|
||||
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextInput
|
||||
placeholder={t("Search chats...")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
size="xs"
|
||||
mb="xs"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{isSearching ? t("No chats found") : t("No chat history")}
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={300} scrollbars="y">
|
||||
<div className={classes.historyList}>
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={classes.historyItem}
|
||||
data-active={chat.id === activeChatId || undefined}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
>
|
||||
<span className={classes.historyItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
IconArrowsDiagonal,
|
||||
IconX,
|
||||
IconSparkles,
|
||||
IconFileText,
|
||||
IconLanguage,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useChatStream } from "../hooks/use-chat-stream";
|
||||
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||
import ChatMessageList from "./chat-message-list";
|
||||
import ChatInput from "./chat-input";
|
||||
import AsideChatHistory from "./aside-chat-history";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/aside-chat-panel.module.css";
|
||||
|
||||
type QuickAction = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export default function AsideChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [chatId, setChatId] = useState<string | undefined>(undefined);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [contextPages, setContextPages] = useState<PageMention[]>([]);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
|
||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||
const {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
} = useChatStream(chatId, {
|
||||
onChatCreated: (newChatId) => {
|
||||
setChatId(newChatId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !chatId) {
|
||||
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
|
||||
}
|
||||
}, [page, chatId]);
|
||||
|
||||
const handleRemoveContextPage = useCallback((pageId: string) => {
|
||||
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatInfoQuery.data?.messages) {
|
||||
hydrateFromServer(chatInfoQuery.data.messages);
|
||||
}
|
||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||
|
||||
// Drop the open chatId if the current user lost access to it (404/403 on
|
||||
// the info fetch). Reverts the panel to a fresh chat instead of presenting
|
||||
// an input tied to a chat the user does not own.
|
||||
useEffect(() => {
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
setChatId(undefined);
|
||||
}
|
||||
}, [chatId, chatInfoQuery.isError]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setChatId(undefined);
|
||||
if (page) {
|
||||
setContextPages([
|
||||
{ id: page.id, title: page.title || "", slugId: page.slugId },
|
||||
]);
|
||||
}
|
||||
},
|
||||
[page],
|
||||
);
|
||||
|
||||
const handleSelectChat = useCallback((selectedChatId: string) => {
|
||||
setChatId(selectedChatId);
|
||||
setHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (chatId) {
|
||||
navigate(`/ai/chat/${chatId}`);
|
||||
} else {
|
||||
navigate("/ai");
|
||||
}
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [chatId, navigate, setAsideState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [setAsideState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
|
||||
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
|
||||
sendMessage(content, mentions, attachments, contextPageId);
|
||||
},
|
||||
[sendMessage, contextPages],
|
||||
);
|
||||
|
||||
const handleQuickAction = useCallback(
|
||||
(prompt: string) => {
|
||||
handleSend(prompt, [], []);
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
|
||||
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
|
||||
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.toolbar}>
|
||||
<Popover
|
||||
opened={historyOpen}
|
||||
onChange={setHistoryOpen}
|
||||
position="bottom-start"
|
||||
width={280}
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.titleButton}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<span className={classes.titleText}>
|
||||
{chatInfoQuery.data?.chat?.title || t("New chat")}
|
||||
</span>
|
||||
<IconChevronDown size={16} stroke={1.75} />
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div className={classes.toolbarSpacer} />
|
||||
|
||||
<Tooltip label={t("New chat")} openDelay={250}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<IconPlus size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Open full page")} openDelay={250}>
|
||||
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
|
||||
<IconArrowsDiagonal size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Close")} openDelay={250}>
|
||||
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
|
||||
<IconX size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMessages ? (
|
||||
<>
|
||||
<div className={classes.messages} data-aside-chat>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
|
||||
<div className={classes.quickActions}>
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
className={classes.quickAction}
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
>
|
||||
<span className={classes.quickActionIcon}>{action.icon}</span>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.inputArea}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
placeholder={t("Ask anything...")}
|
||||
autofocus={false}
|
||||
contextPages={contextPages}
|
||||
onRemoveContextPage={handleRemoveContextPage}
|
||||
variant="flat"
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
IconSparkles,
|
||||
IconSearch,
|
||||
IconFilePlus,
|
||||
IconEdit,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChatInput from "./chat-input";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
type Suggestion = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
icon: <IconSearch size={16} />,
|
||||
text: "Search across all pages",
|
||||
prompt: "Search for pages about ",
|
||||
},
|
||||
{
|
||||
icon: <IconFilePlus size={16} />,
|
||||
text: "Create a new page",
|
||||
prompt: "Create a new page titled ",
|
||||
},
|
||||
{
|
||||
icon: <IconFileText size={16} />,
|
||||
text: "Summarize a page",
|
||||
prompt: "Summarize the page @",
|
||||
},
|
||||
{
|
||||
icon: <IconEdit size={16} />,
|
||||
text: "Update page content",
|
||||
prompt: "Update the page @",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
onSend(prompt, [], []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
|
||||
<div className={classes.emptyStateTitle}>
|
||||
{t("What can I help you with?")}
|
||||
</div>
|
||||
|
||||
<div className={classes.emptyStateInput}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
placeholder="Ask anything... Use @ to mention pages"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.suggestionsSection}>
|
||||
<div className={classes.suggestionsLabel}>Get started</div>
|
||||
<div className={classes.suggestionsGrid}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className={classes.suggestionCard}
|
||||
onClick={() => handleSuggestionClick(s.prompt)}
|
||||
>
|
||||
<span className={classes.suggestionIcon}>{s.icon}</span>
|
||||
<span className={classes.suggestionText}>{s.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { CharacterCount } from "@tiptap/extensions";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
import { uploadChatFile } from "../services/ai-chat-service";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-input.module.css";
|
||||
|
||||
type PendingAttachment = ChatAttachment & { uploading: boolean };
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
|
||||
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
|
||||
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
contextPages?: PageMention[];
|
||||
onRemoveContextPage?: (pageId: string) => void;
|
||||
variant?: "card" | "flat";
|
||||
showDisclaimer?: boolean;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
function extractMentions(json: any): PageMention[] {
|
||||
const mentions: PageMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
|
||||
if (!seen.has(node.attrs.entityId)) {
|
||||
seen.add(node.attrs.entityId);
|
||||
mentions.push({
|
||||
id: node.attrs.entityId,
|
||||
title: node.attrs.label || "",
|
||||
slugId: node.attrs.slugId || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function editorJsonToText(json: any): string {
|
||||
let text = "";
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "text") {
|
||||
text += node.text || "";
|
||||
} else if (node.type === "mention") {
|
||||
text += `@${node.attrs?.label || ""}`;
|
||||
} else if (node.type === "paragraph") {
|
||||
if (text.length > 0) text += "\n";
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
isStreaming,
|
||||
onSend,
|
||||
onStop,
|
||||
placeholder,
|
||||
autofocus = true,
|
||||
contextPages,
|
||||
onRemoveContextPage,
|
||||
variant = "card",
|
||||
showDisclaimer = true,
|
||||
chatId,
|
||||
}: Props) {
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
const { t } = useTranslation();
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
|
||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
|
||||
if (room <= 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t("You can attach up to {{max}} files per message.", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
}),
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = Array.from(files);
|
||||
const accepted = incoming.slice(0, room);
|
||||
|
||||
if (incoming.length > accepted.length) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t(
|
||||
"Only the first {{n}} file(s) were added (max {{max}} per message).",
|
||||
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of accepted) {
|
||||
const tempId = `uploading-${Date.now()}-${Math.random()}`;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const placeholder: PendingAttachment = {
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
fileExt: ext,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
uploading: true,
|
||||
};
|
||||
|
||||
setPendingAttachments((prev) => [...prev, placeholder]);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadChatFile(file, chatIdRef.current);
|
||||
setPendingAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === tempId ? { ...uploaded, uploading: false } : a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [pendingAttachments.length, t]);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!editor || isStreaming) return;
|
||||
const json = editor.getJSON();
|
||||
const text = editorJsonToText(json).trim();
|
||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||
if (!text && readyAttachments.length === 0) return;
|
||||
|
||||
const mentions = extractMentions(json);
|
||||
onSendRef.current(text, mentions, readyAttachments);
|
||||
editor.commands.clearContent();
|
||||
editor.commands.focus();
|
||||
setPendingAttachments([]);
|
||||
}, [isStreaming, pendingAttachments]);
|
||||
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || "Ask anything... Use @ to mention pages",
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 50000,
|
||||
}),
|
||||
LinkExtension,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => [],
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
|
||||
event.key,
|
||||
)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
const mentionPopup = document.querySelector("#mention");
|
||||
if (emojiCommand || mentionPopup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmitRef.current();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
setIsEmpty(!e.getText().trim());
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
|
||||
|
||||
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={wrapperClass} data-chat-input>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
|
||||
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
|
||||
<div className={classes.attachmentChips}>
|
||||
{contextPages?.map((page) => (
|
||||
<div key={page.id} className={classes.attachmentChip}>
|
||||
<IconFileText size={14} />
|
||||
<span className={classes.attachmentChipName}>
|
||||
{page.title || "Untitled"}
|
||||
</span>
|
||||
{onRemoveContextPage && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => onRemoveContextPage(page.id)}
|
||||
aria-label={`Remove ${page.title}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pendingAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
|
||||
>
|
||||
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
|
||||
<IconPhoto size={14} />
|
||||
) : (
|
||||
<IconFile size={14} />
|
||||
)}
|
||||
<span className={classes.attachmentChipName}>
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
{!attachment.uploading && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
aria-label={`Remove ${attachment.fileName}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} className={classes.editorContent} />
|
||||
<div className={classes.actions}>
|
||||
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusButton}
|
||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||
aria-label="Add content"
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
|
||||
title={
|
||||
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
|
||||
? t("Max {{max}} files per message", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IconPaperclip size={16} className={classes.plusMenuIcon} />
|
||||
{t("Add files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
editor?.commands.focus();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
Mention a page
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.stopButton}
|
||||
onClick={onStop}
|
||||
aria-label="Stop generation"
|
||||
>
|
||||
<IconPlayerStopFilled size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.sendButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconArrowUp size={16} stroke={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDisclaimer && (
|
||||
<div className={classes.disclaimer}>
|
||||
{t("AI-generated content may not be accurate.")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatMessage from "./chat-message";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
function ChatMessageErrorFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.messageErrorFallback}>
|
||||
<IconAlertTriangle size={14} />
|
||||
<span>{t("Failed to render this message.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
messages: AiChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingToolCalls: AiChatToolCall[];
|
||||
};
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 32;
|
||||
const SCROLL_UP_THRESHOLD_PX = 5;
|
||||
const SMOOTH_SCROLL_SETTLE_MS = 600;
|
||||
|
||||
export default function ChatMessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
const prevScrollTopRef = useRef(0);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
isAutoScrollingRef.current = true;
|
||||
const target = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTo({ top: target, behavior });
|
||||
prevScrollTopRef.current = target;
|
||||
isAtBottomRef.current = true;
|
||||
setShowScrollButton(false);
|
||||
|
||||
if (behavior === "smooth") {
|
||||
setTimeout(() => {
|
||||
isAutoScrollingRef.current = false;
|
||||
if (containerRef.current) {
|
||||
prevScrollTopRef.current = containerRef.current.scrollTop;
|
||||
}
|
||||
}, SMOOTH_SCROLL_SETTLE_MS);
|
||||
} else {
|
||||
isAutoScrollingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (isAutoScrollingRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const scrolledUp =
|
||||
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
|
||||
prevScrollTopRef.current = currentScrollTop;
|
||||
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - currentScrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
|
||||
if (scrolledUp) {
|
||||
isAtBottomRef.current = atBottom;
|
||||
} else if (atBottom) {
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
|
||||
setShowScrollButton(!atBottom);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// Instant scroll during streaming to keep up with rapid updates
|
||||
useEffect(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom("instant");
|
||||
}
|
||||
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
|
||||
|
||||
// Smooth scroll for new messages. Always force-scroll when the latest
|
||||
// message is from the user (they just sent it), even if they were reading
|
||||
// scrollback.
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastIsUser = lastMessage?.role === "user";
|
||||
if (lastIsUser || isAtBottomRef.current) {
|
||||
scrollToBottom("smooth");
|
||||
return;
|
||||
}
|
||||
|
||||
// No auto-scroll: recompute from actual layout so that chat switches to
|
||||
// content that doesn't overflow correctly hide the button even when no
|
||||
// scroll event fires.
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setShowScrollButton(!atBottom);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div className={classes.messageListWrapper}>
|
||||
<div ref={containerRef} className={classes.messageList}>
|
||||
{messages.map((msg) => (
|
||||
<ErrorBoundary
|
||||
key={msg.id}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage message={msg} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<ErrorBoundary
|
||||
resetKeys={[streamingContent, streamingToolCalls.length]}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
chatId: "",
|
||||
role: "assistant",
|
||||
content: null,
|
||||
toolCalls: null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}}
|
||||
isStreaming
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll to bottom"
|
||||
className={classes.scrollToBottomButton}
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
>
|
||||
<IconArrowDown size={16} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFile,
|
||||
IconLoader2,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolGroup from "./chat-tool-group";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
const chatSanitizer = DOMPurify();
|
||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
|
||||
type Props = {
|
||||
message: AiChatMessage;
|
||||
isStreaming?: boolean;
|
||||
streamingContent?: string;
|
||||
streamingToolCalls?: AiChatToolCall[];
|
||||
};
|
||||
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleContentClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
if (message.role === "tool") return null;
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const content = isStreaming ? streamingContent : message.content;
|
||||
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
|
||||
|
||||
if (isUser) {
|
||||
const displayContent = (content || "").replace(
|
||||
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
|
||||
"",
|
||||
);
|
||||
const attachments =
|
||||
(message.metadata?.attachments as {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
}[]) || [];
|
||||
|
||||
return (
|
||||
<div className={classes.userMessage}>
|
||||
<div className={classes.userBubble}>
|
||||
{attachments.length > 0 && (
|
||||
<div className={classes.messageAttachments}>
|
||||
{attachments.map((a) => (
|
||||
<span key={a.id} className={classes.messageAttachmentChip}>
|
||||
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
|
||||
<IconPhoto size={13} />
|
||||
) : (
|
||||
<IconFile size={13} />
|
||||
)}
|
||||
{a.fileName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.assistantMessage}>
|
||||
<div className={classes.messageContent}>
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
|
||||
)}
|
||||
{content && (
|
||||
<div
|
||||
onClick={handleContentClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: chatSanitizer.sanitize(
|
||||
markdownToHtml(content) as string,
|
||||
{ ADD_ATTR: ["target", "rel"] },
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<>
|
||||
{!content && (
|
||||
<span className={classes.processingIndicator}>
|
||||
<IconLoader2 size={16} className={classes.processingSpinner} />
|
||||
Thinking
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.streamingCursor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isStreaming && message.content && (
|
||||
<div className={classes.messageActions}>
|
||||
<CopyTextButton text={message?.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconChevronDown,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
type Props = {
|
||||
toolCalls: AiChatToolCall[];
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const activeCall =
|
||||
isStreaming && toolCalls.length > 0
|
||||
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
|
||||
: undefined;
|
||||
|
||||
const activeLabel = activeCall
|
||||
? TOOL_LABELS[activeCall.name] || activeCall.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={classes.toolGroup}>
|
||||
<div
|
||||
className={classes.toolGroupHeader}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{activeLabel ? (
|
||||
<IconLoader2 size={12} className={classes.processingSpinner} />
|
||||
) : expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span className={classes.toolGroupLabel}>
|
||||
{activeLabel ? `${activeLabel}…` : `Steps ${toolCalls.length}`}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolGroupSteps}>
|
||||
{toolCalls.map((tc) => (
|
||||
<ChatToolResult key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
list_spaces: "Listed spaces",
|
||||
search_pages: "Searched pages",
|
||||
get_page: "Read page",
|
||||
create_page: "Created page",
|
||||
update_page: "Updated page",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
toolCall: AiChatToolCall;
|
||||
};
|
||||
|
||||
export default function ChatToolResult({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
|
||||
|
||||
return (
|
||||
<div className={classes.toolStep}>
|
||||
<div
|
||||
className={classes.toolStepRow}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<span className={classes.toolStepBullet}>·</span>
|
||||
{expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolStepDetails}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(
|
||||
{ args: toolCall.args, result: toolCall.result },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function EnableAiChat() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="md">{t("AI Chat")}</Text>
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
{t("Beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AiChatToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AiChatToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
|
||||
const hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err: any) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI Chat")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { sendChatMessage } from "../services/ai-chat-service";
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
AiChatToolCall,
|
||||
ChatAttachment,
|
||||
PageMention,
|
||||
} from "../types/ai-chat.types";
|
||||
|
||||
type ChatStreamOptions = {
|
||||
onChatCreated?: (chatId: string) => void;
|
||||
};
|
||||
|
||||
export function useChatStream(
|
||||
chatId: string | undefined,
|
||||
options?: ChatStreamOptions,
|
||||
) {
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
|
||||
[],
|
||||
);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
||||
const [isRetryable, setIsRetryable] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const currentChatIdRef = useRef(chatId);
|
||||
currentChatIdRef.current = chatId;
|
||||
// Tracks which chatId the local `messages` state currently represents.
|
||||
// Set when we seed from a server fetch AND when we optimistically own a
|
||||
// freshly-created chat after `chat_created`. This is the single authority
|
||||
// marker that keeps server-state effects from clobbering in-flight streams.
|
||||
const hydratedChatIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Reset local state when the consumer switches to a different chat.
|
||||
// Skip the reset if the new chatId is one the hook itself already claimed
|
||||
// during a new-chat flow — in that case our optimistic state is the truth.
|
||||
useEffect(() => {
|
||||
if (chatId && chatId === hydratedChatIdRef.current) return;
|
||||
hydratedChatIdRef.current = undefined;
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
}, [chatId]);
|
||||
|
||||
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
|
||||
const forId = currentChatIdRef.current;
|
||||
if (!forId) return;
|
||||
if (hydratedChatIdRef.current === forId) return;
|
||||
hydratedChatIdRef.current = forId;
|
||||
setMessages(msgs);
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
|
||||
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
|
||||
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
setStreamingToolCalls([]);
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (mentions.length) {
|
||||
metadata.mentionedPageIds = mentions.map((m) => m.id);
|
||||
}
|
||||
if (attachments.length) {
|
||||
metadata.attachments = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
fileExt: a.fileExt,
|
||||
}));
|
||||
}
|
||||
|
||||
const userMessage: AiChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "user",
|
||||
content,
|
||||
toolCalls: null,
|
||||
metadata: Object.keys(metadata).length ? metadata : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
const attachmentIds = attachments.map((a) => a.id);
|
||||
|
||||
const abortController = sendChatMessage(
|
||||
{
|
||||
chatId: currentChatIdRef.current,
|
||||
content,
|
||||
mentionedPageIds: mentions.map((m) => m.id),
|
||||
...(contextPageId && { contextPageId }),
|
||||
...(attachmentIds.length && { attachmentIds }),
|
||||
},
|
||||
(event: AiChatStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case "chat_created":
|
||||
currentChatIdRef.current = event.chatId;
|
||||
// Claim authority over this new chatId so when the consumer's
|
||||
// prop catches up via navigation/onChatCreated, the reset effect
|
||||
// sees a match and preserves our optimistic messages.
|
||||
hydratedChatIdRef.current = event.chatId;
|
||||
if (options?.onChatCreated) {
|
||||
options.onChatCreated(event.chatId);
|
||||
} else {
|
||||
navigate(`/ai/chat/${event.chatId}`, { replace: true });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
break;
|
||||
case "content":
|
||||
setStreamingContent((prev) => prev + event.text);
|
||||
break;
|
||||
case "tool_call":
|
||||
setStreamingToolCalls((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
case "tool_result":
|
||||
setStreamingToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.id === event.id ? { ...tc, result: event.result } : tc,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "done": {
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
const assistantMessage: AiChatMessage = {
|
||||
id: event.messageId,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length
|
||||
? currentToolCalls
|
||||
: null,
|
||||
metadata: event.usage ? { tokenUsage: event.usage } : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
setIsStreaming(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["ai-chat", currentChatIdRef.current],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
setError(event.message);
|
||||
setErrorCode(event.code || null);
|
||||
setIsRetryable(event.retryable || false);
|
||||
setIsStreaming(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
(errorMsg) => {
|
||||
setError(errorMsg);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
() => {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
);
|
||||
|
||||
abortRef.current = abortController;
|
||||
},
|
||||
[isStreaming, navigate, queryClient],
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
if (currentContent || currentToolCalls.length > 0) {
|
||||
const partialMessage: AiChatMessage = {
|
||||
id: `stopped-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length ? currentToolCalls : null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, partialMessage]);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
errorCode,
|
||||
isRetryable,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AiChatLayout from "../components/ai-chat-layout";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
export default function AiChat() {
|
||||
const { t } = useTranslation();
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
|
||||
return (
|
||||
<div className={classes.layout}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[chatId]}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<EmptyState
|
||||
icon={IconAlertTriangle}
|
||||
title={t("Failed to load chat. An error occurred.")}
|
||||
action={
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
mt="xs"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
{t("Try again")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<AiChatLayout />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
listChats,
|
||||
getChatInfo,
|
||||
deleteChat,
|
||||
updateChatTitle,
|
||||
searchChats,
|
||||
} from "../services/ai-chat-service";
|
||||
|
||||
export function useChatsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["ai-chats"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listChats({ cursor: pageParam, limit: 30 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChatInfoQuery(chatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chat", chatId],
|
||||
queryFn: () => getChatInfo(chatId!),
|
||||
enabled: !!chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chatId: string) => deleteChat(chatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChatTitleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
|
||||
updateChatTitle(chatId, title),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchChatsQuery(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chats-search", query],
|
||||
queryFn: () => searchChats(query),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import type {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
ChatAttachment,
|
||||
} from "../types/ai-chat.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function createChat(): Promise<AiChat> {
|
||||
const req = await api.post<AiChat>("/ai/chats/create");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function listChats(params?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<AiChat>> {
|
||||
const req = await api.post("/ai/chats", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getChatInfo(
|
||||
chatId: string,
|
||||
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
|
||||
const req = await api.post("/ai/chats/info", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai/chats/delete", { chatId });
|
||||
}
|
||||
|
||||
export async function updateChatTitle(
|
||||
chatId: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await api.post("/ai/chats/update", { chatId, title });
|
||||
}
|
||||
|
||||
export async function searchChats(query: string): Promise<AiChat[]> {
|
||||
const req = await api.post("/ai/chats/search", { query });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadChatFile(
|
||||
file: File,
|
||||
chatId?: string,
|
||||
): Promise<ChatAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (chatId) {
|
||||
formData.append("chatId", chatId);
|
||||
}
|
||||
return await api.post("/ai/chats/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
|
||||
export function sendChatMessage(
|
||||
params: {
|
||||
chatId?: string;
|
||||
content: string;
|
||||
mentionedPageIds?: string[];
|
||||
contextPageId?: string;
|
||||
attachmentIds?: string[];
|
||||
},
|
||||
onEvent: (event: AiChatStreamEvent) => void,
|
||||
onError?: (error: string) => void,
|
||||
onComplete?: () => void,
|
||||
): AbortController {
|
||||
const abortController = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/ai/chats/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: abortController.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = `HTTP error ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
errorMessage = parsed.message || errorMessage;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
onError?.("Response body is not readable");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as AiChatStreamEvent;
|
||||
onEvent(parsed);
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
onError?.(error.message);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return abortController;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageListWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.messageErrorFallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.scrollToBottomButton {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scrollToBottomButton:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.scrollToBottomButton:active {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
/* Empty state - Notion AI style centered layout */
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.emptyStateBrand {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyStateInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.suggestionsSection {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.suggestionsLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestionText {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 var(--mantine-spacing-sm) 0;
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.toolbarSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
max-width: 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleButton:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.titleText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-sm) 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: var(--mantine-font-size-lg);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quickAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.quickActionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.historyList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.historyItemTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 16px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: light-dark(
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.07),
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.5)
|
||||
);
|
||||
transition:
|
||||
border-color 150ms,
|
||||
box-shadow 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
box-shadow: light-dark(
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.09),
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapperFlat {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 12px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: none;
|
||||
transition: border-color 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.attachmentChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
|
||||
.attachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.attachmentChipUploading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.attachmentChipName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentChipRemove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
border-radius: 50%;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
overflow: hidden;
|
||||
|
||||
:global(.ProseMirror) {
|
||||
outline: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 14px 18px 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-height: 24px;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
:global(.ProseMirror p) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 12px 10px;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms, opacity 150ms;
|
||||
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
|
||||
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
&:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
transition: color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
|
||||
.plusButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
transition: color 150ms, background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
.message {
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
composes: message;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.userBubble {
|
||||
max-width: 75%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[data-aside-chat] .userBubble {
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.userBubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messageAttachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.messageAttachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistantMessage {
|
||||
composes: message;
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.messageContent p {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
|
||||
.messageContent p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.messageContent ul,
|
||||
.messageContent ol {
|
||||
margin: 0.5em 0 0.75em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.messageContent li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.messageContent h1,
|
||||
.messageContent h2,
|
||||
.messageContent h3 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.messageContent h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.messageContent h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.messageContent h3 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.messageContent pre {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
overflow-x: auto;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.messageContent code {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.messageContent pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.messageContent blockquote {
|
||||
border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
margin: 0.75em 0;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.messageContent a {
|
||||
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
|
||||
text-decoration: none;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent a[href^="/s/"],
|
||||
.messageContent a[href^="/p/"] {
|
||||
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
@mixin light {
|
||||
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: none;
|
||||
@mixin light {
|
||||
border-bottom-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
@mixin dark {
|
||||
border-bottom-color: var(--mantine-color-dark-0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent hr {
|
||||
border: none;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.toolGroup {
|
||||
margin: 6px 0;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.toolGroupHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
line-height: 1.4;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.toolGroupHeader:hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.toolGroupLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolGroupSteps {
|
||||
margin-top: 4px;
|
||||
padding-left: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolStep {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.toolStepRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
line-height: 1.5;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.toolStepRow:hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.toolStepBullet {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toolStepDetails {
|
||||
margin-top: 4px;
|
||||
margin-left: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.processingIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.processingSpinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.streamingCursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.chatList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chatGroup + .chatGroup {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.chatGroupLabel {
|
||||
padding: 4px var(--mantine-spacing-xs);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chatListEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chatListEmptyIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.chatListEmptyTitle {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
|
||||
.chatListEmptyHint {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px var(--mantine-spacing-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
user-select: none;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chatItemTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatItemDate {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
white-space: nowrap;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.chatItemRenameInput {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatItem:hover .chatItemDate {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chatItemActions {
|
||||
position: absolute;
|
||||
right: var(--mantine-spacing-xs);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chatItem:hover .chatItemActions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type AiChat = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AiChatToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
export type AiChatMessage = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
toolCalls: AiChatToolCall[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AiChatStreamEvent =
|
||||
| { type: 'chat_created'; chatId: string }
|
||||
| { type: 'content'; text: string }
|
||||
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
|
||||
| { type: 'tool_result'; id: string; result: unknown }
|
||||
| { type: 'done'; messageId: string; usage?: Record<string, number> }
|
||||
| { type: 'error'; message: string; code?: string; retryable?: boolean };
|
||||
|
||||
export type PageMention = {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
spaceSlug?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
|
||||
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
||||
|
||||
export function groupChatsByAge(
|
||||
chats: AiChat[],
|
||||
t: (key: string) => string,
|
||||
): ChatGroup[] {
|
||||
if (chats.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
|
||||
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const buckets: Record<string, ChatGroup> = {
|
||||
today: { key: "today", label: t("Today"), chats: [] },
|
||||
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
|
||||
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
|
||||
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
|
||||
older: { key: "older", label: t("Older"), chats: [] },
|
||||
};
|
||||
|
||||
for (const chat of chats) {
|
||||
const ts = new Date(chat.updatedAt).getTime();
|
||||
if (ts >= startOfToday) buckets.today.chats.push(chat);
|
||||
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
|
||||
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
|
||||
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
|
||||
else buckets.older.chats.push(chat);
|
||||
}
|
||||
|
||||
return [
|
||||
buckets.today,
|
||||
buckets.yesterday,
|
||||
buckets.last7,
|
||||
buckets.last30,
|
||||
buckets.older,
|
||||
].filter((b) => b.chats.length > 0);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
||||
import { Group, Text, Switch, MantineSize, 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 { isCloud } from "@/lib/config.ts";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function EnableAiSearch() {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
||||
const hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
@@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI search")}
|
||||
/>
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI search")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Group, Text, Switch } from "@mantine/core";
|
||||
import { Group, 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 { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function EnableGenerativeAi() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
|
||||
const hasAccess = useIsCloudEE();
|
||||
const hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
@@ -38,11 +41,13 @@ export default function EnableGenerativeAi() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
/>
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
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 { Trans, 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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { CopyButton } from "@/components/common/copy-button.tsx";
|
||||
@@ -25,7 +27,8 @@ export default function McpSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
|
||||
const hasAccess = useIsCloudEE();
|
||||
const hasAccess = useHasFeature(Feature.MCP);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const mcpUrl = `${getAppUrl()}/mcp`;
|
||||
|
||||
@@ -46,11 +49,7 @@ export default function McpSettings() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{!hasAccess && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle />}
|
||||
title={t("Enterprise feature")}
|
||||
color="blue"
|
||||
>
|
||||
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
|
||||
{t(
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
||||
)}
|
||||
@@ -64,23 +63,22 @@ export default function McpSettings() {
|
||||
{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>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="View the <anchor>MCP documentation</anchor>."
|
||||
components={{
|
||||
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
/>
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{checked && (
|
||||
@@ -89,11 +87,7 @@ export default function McpSettings() {
|
||||
{t("MCP Server URL")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
|
||||
<CopyButton value={mcpUrl} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
@@ -123,12 +117,36 @@ export default function McpSettings() {
|
||||
{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.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>
|
||||
|
||||
@@ -6,17 +6,21 @@ 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 EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
|
||||
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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
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 hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -55,7 +59,7 @@ export default function AiSettings() {
|
||||
{!hasAccess && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle />}
|
||||
title={t("Enterprise feature")}
|
||||
title={upgradeLabel}
|
||||
color="blue"
|
||||
mb="lg"
|
||||
>
|
||||
@@ -68,6 +72,7 @@ export default function AiSettings() {
|
||||
<Stack gap="md">
|
||||
{!isCloud() && <EnableAiSearch />}
|
||||
<EnableGenerativeAi />
|
||||
<EnableAiChat />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import {
|
||||
ResponsiveSettingsRow,
|
||||
ResponsiveSettingsContent,
|
||||
ResponsiveSettingsControl,
|
||||
} from "@/components/ui/responsive-settings-row";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function RestrictApiToAdmins() {
|
||||
const { t } = useTranslation();
|
||||
@@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() {
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.api?.restrictToAdmins === true,
|
||||
);
|
||||
const hasAccess = useEnterpriseAccess();
|
||||
const hasAccess = useHasFeature(Feature.API_KEYS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
@@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() {
|
||||
|
||||
<ResponsiveSettingsControl>
|
||||
<Tooltip
|
||||
label={t("Requires an enterprise license")}
|
||||
label={upgradeLabel}
|
||||
disabled={hasAccess}
|
||||
refProp="rootRef"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
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 { Trans, useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { getAppName, getAppUrl } from "@/lib/config";
|
||||
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
||||
@@ -58,11 +58,12 @@ export default function UserApiKeys() {
|
||||
<SettingsTitle title={t("API keys")} />
|
||||
|
||||
<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.")}
|
||||
<Trans
|
||||
i18nKey="View the <anchor>API documentation</anchor> for usage details."
|
||||
components={{
|
||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{mcpEnabled && canCreate && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
||||
@@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() {
|
||||
<SettingsTitle title={t("API management")} />
|
||||
|
||||
<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.")}
|
||||
<Trans
|
||||
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
|
||||
components={{
|
||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<RestrictApiToAdmins />
|
||||
|
||||
@@ -58,6 +58,13 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
"page.verified": "Verified page",
|
||||
"page.approval_requested": "Requested page approval",
|
||||
"page.approval_rejected": "Rejected page approval",
|
||||
"page.marked_obsolete": "Marked page as obsolete",
|
||||
|
||||
"share.created": "Created share link",
|
||||
"share.deleted": "Deleted share link",
|
||||
@@ -136,6 +143,13 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
{ value: "page.verified", label: "Verified page" },
|
||||
{ value: "page.approval_requested", label: "Requested page approval" },
|
||||
{ value: "page.approval_rejected", label: "Rejected page approval" },
|
||||
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
|
||||
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function findWorkspacesByEmail(email: string): Promise<void> {
|
||||
await api.post("/workspace/find-by-email", { email });
|
||||
}
|
||||
|
||||
export async function verifyEmail(data: { token: string }): Promise<void> {
|
||||
await api.post("/workspace/verify-email", data);
|
||||
}
|
||||
|
||||
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
|
||||
await api.post("/workspace/resend-verification", data);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,22 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
|
||||
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
|
||||
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
|
||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
hostname: z.string().min(1, { message: "subdomain is required" }),
|
||||
});
|
||||
|
||||
const findWorkspaceSchema = z.object({
|
||||
email: z.string().email({ message: "Please enter a valid email" }),
|
||||
});
|
||||
|
||||
export function CloudLoginForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
|
||||
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
|
||||
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
|
||||
|
||||
const form = useForm<any>({
|
||||
@@ -37,6 +45,13 @@ export function CloudLoginForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const findForm = useForm<any>({
|
||||
validate: zod4Resolver(findWorkspaceSchema),
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: { hostname: string }) {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -54,8 +69,21 @@ export function CloudLoginForm() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onFindSubmit(data: { email: string }) {
|
||||
setIsFindLoading(true);
|
||||
|
||||
try {
|
||||
await findWorkspacesByEmail(data.email);
|
||||
setFindEmailSent(true);
|
||||
} catch {
|
||||
findForm.setFieldError("email", "An error occurred. Please try again.");
|
||||
}
|
||||
|
||||
setIsFindLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AuthLayout>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
@@ -83,15 +111,47 @@ export function CloudLoginForm() {
|
||||
{t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Divider my="lg" label="or" labelPosition="center" />
|
||||
|
||||
{findEmailSent ? (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
{t("We've sent you an email with your associated workspaces.")}
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
|
||||
<Text fw={600} mb="xs">
|
||||
{t("Find your workspaces")}
|
||||
</Text>
|
||||
<TextInput
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
description={t(
|
||||
"We'll send a list of your workspaces to this email.",
|
||||
)}
|
||||
withErrorStyles={false}
|
||||
{...findForm.getInputProps("email")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
mt="md"
|
||||
variant="light"
|
||||
loading={isFindLoading}
|
||||
>
|
||||
{t("Send")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Text ta="center">
|
||||
<Text ta="center" mb="xl">
|
||||
{t("Don't have a workspace?")}{" "}
|
||||
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
|
||||
{t("Create new workspace")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
|
||||
|
||||
export default function SsoLogin() {
|
||||
@@ -57,7 +56,7 @@ export default function SsoLogin() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isCloud() || data.hasLicenseKey) && (
|
||||
{data.authProviders.length > 0 && (
|
||||
<>
|
||||
<Stack align="stretch" justify="center" gap="sm">
|
||||
{data.authProviders.map((provider) => (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import type { Entitlements } from "./entitlement.types";
|
||||
|
||||
export const entitlementAtom = atomWithStorage<Entitlements | null>(
|
||||
"entitlements",
|
||||
null,
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { Entitlements } from "./entitlement.types";
|
||||
|
||||
export async function getEntitlements(): Promise<Entitlements> {
|
||||
const req = await api.post<Entitlements>("/workspace/entitlements");
|
||||
return req.data as Entitlements;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Tier = "free" | "standard" | "business" | "enterprise";
|
||||
|
||||
export type Entitlements = {
|
||||
cloud: boolean;
|
||||
tier: Tier;
|
||||
features: string[];
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getEntitlements } from "./entitlement-service";
|
||||
import { Entitlements } from "./entitlement.types";
|
||||
|
||||
export function useEntitlements(): UseQueryResult<Entitlements> {
|
||||
return useQuery({
|
||||
queryKey: ["entitlements"],
|
||||
queryFn: getEntitlements,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const Feature = {
|
||||
SSO_CUSTOM: 'sso:custom',
|
||||
SSO_GOOGLE: 'sso:google',
|
||||
MFA: 'mfa',
|
||||
API_KEYS: 'api:keys',
|
||||
COMMENT_RESOLUTION: 'comment:resolution',
|
||||
PAGE_PERMISSIONS: 'page:permissions',
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
SCIM: 'scim',
|
||||
PAGE_VERIFICATION: 'page:verification',
|
||||
AUDIT_LOGS: 'audit:logs',
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
} as const;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { isCloud } from "@/lib/config";
|
||||
import useLicense from "@/ee/hooks/use-license";
|
||||
import usePlan from "@/ee/hooks/use-plan";
|
||||
|
||||
const useEnterpriseAccess = () => {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const { isBusiness } = usePlan();
|
||||
|
||||
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
|
||||
};
|
||||
|
||||
export default useEnterpriseAccess;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
|
||||
export const useHasFeature = (feature: string): boolean => {
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
return entitlements?.features?.includes(feature) ?? false;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
export const useLicense = () => {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
|
||||
};
|
||||
|
||||
export default useLicense;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
import { isCloud } from "@/lib/config";
|
||||
|
||||
export function useUpgradeLabel(): string {
|
||||
const { t } = useTranslation();
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
|
||||
if (!isCloud()) {
|
||||
return entitlements != null && entitlements.tier !== "free"
|
||||
? t("Upgrade your license tier.")
|
||||
: t("Available with a paid license");
|
||||
}
|
||||
return t("Upgrade your plan");
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
import React from "react";
|
||||
import { Button, Group, Modal, Textarea } from "@mantine/core";
|
||||
import React, { useRef } from "react";
|
||||
import { Button, Divider, Group, Modal, Stack, Textarea } from "@mantine/core";
|
||||
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";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
|
||||
|
||||
export default function ActivateLicense() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
const hasLicense = entitlements != null && entitlements.tier !== "free";
|
||||
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap" mb="sm">
|
||||
<Button onClick={open}>
|
||||
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
|
||||
{hasLicense ? t("Update license") : t("Add license")}
|
||||
</Button>
|
||||
|
||||
{workspace?.hasLicenseKey && <RemoveLicense />}
|
||||
{hasLicense && <RemoveLicense />}
|
||||
|
||||
<Modal
|
||||
size="550"
|
||||
@@ -48,6 +49,7 @@ interface ActivateLicenseFormProps {
|
||||
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const activateLicenseMutation = useActivateMutation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
@@ -59,32 +61,71 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
||||
async function handleSubmit(data: { licenseKey: string }) {
|
||||
await activateLicenseMutation.mutateAsync(data.licenseKey);
|
||||
form.reset();
|
||||
onClose();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = (e.target?.result as string)?.trim();
|
||||
if (content) {
|
||||
form.setFieldValue("licenseKey", content);
|
||||
handleSubmit({ licenseKey: content });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Textarea
|
||||
label={t("License key")}
|
||||
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
|
||||
placeholder={t("e.g eyJhb.....")}
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
data-autofocus
|
||||
{...form.getInputProps("licenseKey")}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.license"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
hidden
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={activateLicenseMutation.isPending}
|
||||
loading={activateLicenseMutation.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Textarea
|
||||
label={t("License key")}
|
||||
placeholder={t("e.g eyJhb.....")}
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
data-autofocus
|
||||
{...form.getInputProps("licenseKey")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={activateLicenseMutation.isPending}
|
||||
loading={activateLicenseMutation.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label={t("Or")} labelPosition="center" />
|
||||
|
||||
<Group justify="center">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t("Upload license file")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export default function LicenseDetails() {
|
||||
<Table.Tr>
|
||||
<Table.Th w={160}>Edition</Table.Th>
|
||||
<Table.Td>
|
||||
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
|
||||
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
|
||||
{license.trial && <Badge color="green">Trial</Badge>}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ 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",
|
||||
"AI Integration (Chat, Search & Assistant)",
|
||||
"MCP Support",
|
||||
"SSO (SAML, OIDC, LDAP)",
|
||||
"Multi-factor Authentication (2FA)",
|
||||
"Page-level Permissions",
|
||||
"Page verification & approval workflow",
|
||||
"Audit Logs",
|
||||
"Enterprise Controls",
|
||||
"API Keys",
|
||||
"Advanced Search Engine Support",
|
||||
"Full-text Search in Attachments (PDF, DOCX)",
|
||||
"Resolve Comments",
|
||||
@@ -68,7 +69,31 @@ export default function OssDetails() {
|
||||
</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.
|
||||
Get an enterprise trial key at{" "}
|
||||
<a
|
||||
href="https://customers.docmost.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
customers.docmost.com
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Visit{" "}
|
||||
<a
|
||||
href="https://docmost.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docmost.com/pricing
|
||||
</a>{" "}
|
||||
to purchase an enterprise license.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
For inquiries, contact{" "}
|
||||
<a href="mailto:sales@docmost.com">sales@docmost.com</a>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -8,10 +8,11 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.
|
||||
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
|
||||
import OssDetails from "@/ee/licence/components/oss-details.tsx";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
|
||||
export default function License() {
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
const hasLicense = entitlements != null && entitlements.tier !== "free";
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
if (!isAdmin) {
|
||||
@@ -29,7 +30,7 @@ export default function License() {
|
||||
|
||||
<InstallationDetails />
|
||||
|
||||
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
|
||||
{hasLicense ? <LicenseDetails /> : <OssDetails />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useActivateMutation() {
|
||||
queryKey: ["license"],
|
||||
});
|
||||
queryClient.refetchQueries({ queryKey: ["currentUser"] });
|
||||
queryClient.refetchQueries({ queryKey: ["entitlements"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
@@ -47,6 +48,7 @@ export function useRemoveLicenseMutation() {
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["license"] });
|
||||
queryClient.refetchQueries({ queryKey: ["currentUser"] });
|
||||
queryClient.refetchQueries({ queryKey: ["entitlements"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export type LicenseType = 'business' | 'enterprise';
|
||||
|
||||
export interface ILicenseInfo {
|
||||
id: string;
|
||||
customerName: string;
|
||||
seatCount: number;
|
||||
licenseType: LicenseType;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
trial: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod/v4";
|
||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z
|
||||
@@ -66,6 +67,7 @@ export function MfaChallenge() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Paper radius="lg" p={40} className={classes.paper}>
|
||||
<Stack align="center" gap="xl">
|
||||
@@ -157,5 +159,6 @@ export function MfaChallenge() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import { MfaDisableModal } from "@/ee/mfa";
|
||||
import { MfaBackupCodesModal } from "@/ee/mfa";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
||||
|
||||
export function MfaSettings() {
|
||||
@@ -17,7 +18,8 @@ export function MfaSettings() {
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const canUseMfa = useHasFeature(Feature.MFA);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ["mfa-status"],
|
||||
@@ -28,8 +30,6 @@ export function MfaSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUseMfa = isCloud() || hasLicenseKey;
|
||||
|
||||
// Check if MFA is truly enabled
|
||||
const isMfaEnabled = mfaStatus?.isEnabled === true;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function MfaSettings() {
|
||||
<ResponsiveSettingsControl>
|
||||
{!isMfaEnabled ? (
|
||||
<Tooltip
|
||||
label={t("Available in enterprise edition")}
|
||||
label={upgradeLabel}
|
||||
disabled={canUseMfa}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MfaSetupModal } from "@/ee/mfa";
|
||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
||||
|
||||
export default function MfaSetupRequired() {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,6 +16,7 @@ export default function MfaSetupRequired() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||
<Stack>
|
||||
@@ -44,5 +46,6 @@ export default function MfaSetupRequired() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-p
|
||||
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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query";
|
||||
@@ -33,9 +34,9 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS);
|
||||
const [activeTab, setActiveTab] = useState<string | null>(
|
||||
isCloudEE ? "access" : "publish",
|
||||
hasPagePermissions ? "access" : "publish",
|
||||
);
|
||||
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
@@ -51,7 +52,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||
const isPubliclyShared = !!share;
|
||||
|
||||
const { data: restrictionInfo, isLoading: restrictionLoading } =
|
||||
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
|
||||
usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||
) : null
|
||||
}
|
||||
variant="default"
|
||||
onClick={open}
|
||||
onClick={() => {
|
||||
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
|
||||
open();
|
||||
}}
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
@@ -92,7 +96,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="access">
|
||||
{!isCloudEE ? (
|
||||
{!hasPagePermissions ? (
|
||||
<Stack align="center" py="md">
|
||||
<IconLock size={20} stroke={1.5} />
|
||||
<Text size="sm" ta="center" fw={500}>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Group, NumberInput, Select, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ExpirationMode,
|
||||
PeriodUnit,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
|
||||
export const PERIOD_UNIT_DAYS: Record<PeriodUnit, number> = {
|
||||
day: 1,
|
||||
week: 7,
|
||||
month: 30,
|
||||
year: 365,
|
||||
};
|
||||
|
||||
export const PERIOD_UNIT_MAX_AMOUNT: Record<PeriodUnit, number> = {
|
||||
day: 3650,
|
||||
week: 520,
|
||||
month: 120,
|
||||
year: 20,
|
||||
};
|
||||
|
||||
export const PERIOD_AMOUNT_MIN = 1;
|
||||
|
||||
export function addDays(days: number, from?: Date): Date {
|
||||
const date = from ? new Date(from) : new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatShortDate(date: Date): string {
|
||||
const crossesYear = date.getFullYear() !== new Date().getFullYear();
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(crossesYear && { year: "numeric" }),
|
||||
});
|
||||
}
|
||||
|
||||
function formatLongDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function toLocalDateString(input: Date | string): string {
|
||||
const d = typeof input === "string" ? new Date(input) : input;
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function pluralizeUnit(
|
||||
unit: PeriodUnit,
|
||||
amount: number,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (unit) {
|
||||
case "day":
|
||||
return amount === 1 ? t("day") : t("days");
|
||||
case "week":
|
||||
return amount === 1 ? t("week") : t("weeks");
|
||||
case "month":
|
||||
return amount === 1 ? t("month") : t("months");
|
||||
case "year":
|
||||
return amount === 1 ? t("year") : t("years");
|
||||
}
|
||||
}
|
||||
|
||||
function buildModeOptions(
|
||||
t: (key: string) => string,
|
||||
): { value: ExpirationMode; label: string }[] {
|
||||
return [
|
||||
{ value: "period", label: t("Period") },
|
||||
{ value: "fixed", label: t("Fixed date") },
|
||||
{ value: "indefinite", label: t("Indefinitely") },
|
||||
];
|
||||
}
|
||||
|
||||
function buildUnitOptions(
|
||||
t: (key: string) => string,
|
||||
): { value: PeriodUnit; label: string }[] {
|
||||
return [
|
||||
{ value: "day", label: t("Days") },
|
||||
{ value: "week", label: t("Weeks") },
|
||||
{ value: "month", label: t("Months") },
|
||||
{ value: "year", label: t("Years") },
|
||||
];
|
||||
}
|
||||
|
||||
type ExpirationFieldsProps = {
|
||||
mode: ExpirationMode;
|
||||
periodAmount: number;
|
||||
periodUnit: PeriodUnit;
|
||||
fixedDate: string;
|
||||
onModeChange: (mode: ExpirationMode) => void;
|
||||
onPeriodAmountChange: (amount: number) => void;
|
||||
onPeriodUnitChange: (unit: PeriodUnit) => void;
|
||||
onFixedDateChange: (iso: string) => void;
|
||||
baseDate?: Date;
|
||||
};
|
||||
|
||||
export function ExpirationFields({
|
||||
mode,
|
||||
periodAmount,
|
||||
periodUnit,
|
||||
fixedDate,
|
||||
onModeChange,
|
||||
onPeriodAmountChange,
|
||||
onPeriodUnitChange,
|
||||
onFixedDateChange,
|
||||
baseDate,
|
||||
}: ExpirationFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const modeOptions = buildModeOptions(t);
|
||||
const unitOptions = buildUnitOptions(t);
|
||||
|
||||
const unitMax = PERIOD_UNIT_MAX_AMOUNT[periodUnit];
|
||||
|
||||
const handleUnitChange = (nextUnit: PeriodUnit) => {
|
||||
const nextMax = PERIOD_UNIT_MAX_AMOUNT[nextUnit];
|
||||
if (periodAmount > nextMax) {
|
||||
onPeriodAmountChange(nextMax);
|
||||
}
|
||||
onPeriodUnitChange(nextUnit);
|
||||
};
|
||||
|
||||
const amountValid =
|
||||
Number.isInteger(periodAmount) &&
|
||||
periodAmount >= PERIOD_AMOUNT_MIN &&
|
||||
periodAmount <= unitMax;
|
||||
|
||||
const nextDueDate =
|
||||
mode === "period" && amountValid
|
||||
? addDays(periodAmount * PERIOD_UNIT_DAYS[periodUnit], baseDate)
|
||||
: null;
|
||||
|
||||
const fixedDateObj = fixedDate ? new Date(fixedDate) : null;
|
||||
|
||||
let helperText: string | null = null;
|
||||
let helperError = false;
|
||||
if (mode === "period" && !amountValid) {
|
||||
helperText = t("Maximum is {{max}} {{unit}} for this unit", {
|
||||
max: unitMax,
|
||||
unit: pluralizeUnit(periodUnit, unitMax, t),
|
||||
});
|
||||
helperError = true;
|
||||
} else if (mode === "period" && nextDueDate && amountValid) {
|
||||
helperText = t(
|
||||
"Re-verifies every {{amount}} {{unit}} · Next due {{date}}",
|
||||
{
|
||||
amount: periodAmount,
|
||||
unit: pluralizeUnit(periodUnit, periodAmount, t),
|
||||
date: formatShortDate(nextDueDate),
|
||||
},
|
||||
);
|
||||
} else if (mode === "fixed" && fixedDateObj) {
|
||||
helperText = t(
|
||||
"Expires on {{date}}. Re-verifying won't change the deadline.",
|
||||
{ date: formatLongDate(fixedDateObj) },
|
||||
);
|
||||
} else if (mode === "indefinite") {
|
||||
helperText = t("Never expires. Verifiers can re-verify at any time.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group align="flex-start" gap="xs" wrap="wrap">
|
||||
<Select
|
||||
data={modeOptions}
|
||||
value={mode}
|
||||
onChange={(val) => val && onModeChange(val as ExpirationMode)}
|
||||
variant="filled"
|
||||
allowDeselect={false}
|
||||
style={{ flex: "1 1 140px", minWidth: 140 }}
|
||||
/>
|
||||
|
||||
{mode === "period" && (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
style={{ flex: "1 1 220px", minWidth: 220 }}
|
||||
>
|
||||
<NumberInput
|
||||
value={periodAmount}
|
||||
onChange={(val) => {
|
||||
const n =
|
||||
typeof val === "number" ? val : parseInt(String(val), 10);
|
||||
if (!Number.isNaN(n)) onPeriodAmountChange(n);
|
||||
}}
|
||||
min={PERIOD_AMOUNT_MIN}
|
||||
max={unitMax}
|
||||
clampBehavior="blur"
|
||||
variant="filled"
|
||||
style={{ flex: "0 0 80px" }}
|
||||
hideControls
|
||||
/>
|
||||
<Select
|
||||
data={unitOptions}
|
||||
value={periodUnit}
|
||||
onChange={(val) => val && handleUnitChange(val as PeriodUnit)}
|
||||
variant="filled"
|
||||
allowDeselect={false}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{mode === "fixed" && (
|
||||
<DateInput
|
||||
value={fixedDate || undefined}
|
||||
onChange={(val) => onFixedDateChange(val ?? "")}
|
||||
placeholder={t("Pick a date")}
|
||||
variant="filled"
|
||||
minDate={addDays(1)}
|
||||
clearable
|
||||
style={{ flex: "1 1 200px", minWidth: 180 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{helperText && (
|
||||
<Text size="xs" c={helperError ? "red" : "dimmed"} mt={6}>
|
||||
{helperText}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useMarkObsoleteMutation,
|
||||
usePageVerificationInfoQuery,
|
||||
useRejectApprovalMutation,
|
||||
useRemoveVerificationMutation,
|
||||
useSubmitForApprovalMutation,
|
||||
useUpdateVerificationMutation,
|
||||
useVerifyPageMutation,
|
||||
} from "@/ee/page-verification/queries/page-verification-query";
|
||||
import {
|
||||
ExpirationMode,
|
||||
IPageVerificationInfo,
|
||||
PeriodUnit,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { VerifierList } from "./verifier-list";
|
||||
import {
|
||||
ExpirationFields,
|
||||
PERIOD_AMOUNT_MIN,
|
||||
PERIOD_UNIT_MAX_AMOUNT,
|
||||
toLocalDateString,
|
||||
} from "./expiration-fields";
|
||||
import { VerifierPicker } from "./verifier-picker";
|
||||
import { MAX_VERIFIERS } from "./user-option";
|
||||
|
||||
type ManageVerificationFormProps = {
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ManageVerificationForm({
|
||||
pageId,
|
||||
onClose,
|
||||
}: ManageVerificationFormProps) {
|
||||
const { data: info, isLoading } = usePageVerificationInfoQuery(pageId);
|
||||
|
||||
if (isLoading || !info) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === "qms") {
|
||||
return <QmsManageContent pageId={pageId} info={info} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpiringManageContent pageId={pageId} info={info} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
|
||||
type ManageContentProps = {
|
||||
pageId: string;
|
||||
info: IPageVerificationInfo;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const verifyMutation = useVerifyPageMutation();
|
||||
const removeMutation = useRemoveVerificationMutation();
|
||||
const updateMutation = useUpdateVerificationMutation();
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
const initialMode: ExpirationMode = (info.mode as ExpirationMode) ?? "period";
|
||||
const initialPeriodAmount = info.periodAmount ?? 1;
|
||||
const initialPeriodUnit: PeriodUnit =
|
||||
(info.periodUnit as PeriodUnit) ?? "month";
|
||||
const initialFixedDate =
|
||||
initialMode === "fixed" && info.expiresAt
|
||||
? toLocalDateString(info.expiresAt)
|
||||
: "";
|
||||
|
||||
const [mode, setMode] = useState<ExpirationMode>(initialMode);
|
||||
const [periodAmount, setPeriodAmount] = useState<number>(initialPeriodAmount);
|
||||
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>(initialPeriodUnit);
|
||||
const [fixedDate, setFixedDate] = useState<string>(initialFixedDate);
|
||||
|
||||
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
|
||||
|
||||
const hasExpirationChange =
|
||||
mode !== initialMode ||
|
||||
(mode === "period" &&
|
||||
(periodAmount !== initialPeriodAmount ||
|
||||
periodUnit !== initialPeriodUnit)) ||
|
||||
(mode === "fixed" && fixedDate !== initialFixedDate);
|
||||
|
||||
const periodValid =
|
||||
mode !== "period" ||
|
||||
(Number.isInteger(periodAmount) &&
|
||||
periodAmount >= PERIOD_AMOUNT_MIN &&
|
||||
periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]);
|
||||
const fixedDateValid =
|
||||
mode !== "fixed" ||
|
||||
(!!fixedDate && new Date(fixedDate).getTime() > Date.now());
|
||||
const canSaveExpiration = hasExpirationChange && periodValid && fixedDateValid;
|
||||
|
||||
const storedFixedExpired =
|
||||
info.mode === "fixed" &&
|
||||
!!info.expiresAt &&
|
||||
new Date(info.expiresAt).getTime() <= Date.now();
|
||||
|
||||
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
|
||||
|
||||
const handleVerify = () => {
|
||||
verifyMutation.mutate(pageId, {
|
||||
onSuccess: () => {
|
||||
setConfirmed(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Remove verification"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to remove verification from this page?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveExpiration = () => {
|
||||
if (!canSaveExpiration) return;
|
||||
updateMutation.mutate({
|
||||
pageId,
|
||||
mode,
|
||||
...(mode === "period" && {
|
||||
periodAmount,
|
||||
periodUnit,
|
||||
}),
|
||||
...(mode === "fixed" &&
|
||||
fixedDate && {
|
||||
fixedExpiresAt: new Date(fixedDate).toISOString(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveVerifier = (userId: string) => {
|
||||
if (!info.verifiers) return;
|
||||
const remaining = info.verifiers
|
||||
.filter((v) => v.id !== userId)
|
||||
.map((v) => v.id);
|
||||
updateMutation.mutate({ pageId, verifierIds: remaining });
|
||||
};
|
||||
|
||||
const handleAddVerifier = (userId: string) => {
|
||||
if (!info.verifiers) return;
|
||||
if (info.verifiers.some((v) => v.id === userId)) return;
|
||||
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
|
||||
updateMutation.mutate({ pageId, verifierIds });
|
||||
};
|
||||
|
||||
const status = info.status;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Assigned verifiers must periodically re-verify this page.")}
|
||||
</Text>
|
||||
|
||||
{info.verifiedBy && (
|
||||
<Group gap="sm">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{status === "expired"
|
||||
? t("Last verified by {{name}} {{time}} (expired)", {
|
||||
name: info.verifiedBy.name,
|
||||
time: verifiedAtAgo,
|
||||
})
|
||||
: t("Verified by {{name}} {{time}}", {
|
||||
name: info.verifiedBy.name,
|
||||
time: verifiedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
{info.expiresAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
|
||||
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{info.verifiers && info.verifiers.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
<VerifierList
|
||||
verifiers={info.verifiers}
|
||||
canManage={info.permissions?.canManage}
|
||||
onRemove={
|
||||
info.permissions?.canManage ? handleRemoveVerifier : undefined
|
||||
}
|
||||
/>
|
||||
{info.permissions?.canManage &&
|
||||
info.verifiers.length < MAX_VERIFIERS && (
|
||||
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierPicker
|
||||
excludeIds={existingVerifierIds}
|
||||
onSelect={(user) => handleAddVerifier(user.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{info.permissions?.canManage && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("Expiration")}
|
||||
</Text>
|
||||
<ExpirationFields
|
||||
mode={mode}
|
||||
periodAmount={periodAmount}
|
||||
periodUnit={periodUnit}
|
||||
fixedDate={fixedDate}
|
||||
onModeChange={setMode}
|
||||
onPeriodAmountChange={setPeriodAmount}
|
||||
onPeriodUnitChange={setPeriodUnit}
|
||||
onFixedDateChange={setFixedDate}
|
||||
baseDate={
|
||||
info.verifiedAt ? new Date(info.verifiedAt) : undefined
|
||||
}
|
||||
/>
|
||||
{hasExpirationChange && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
mt="xs"
|
||||
color="dark"
|
||||
onClick={handleSaveExpiration}
|
||||
loading={updateMutation.isPending}
|
||||
disabled={!canSaveExpiration}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{info.permissions?.canVerify && (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Confirm")}
|
||||
</Text>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
{storedFixedExpired && (
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{t("The fixed expiration date has passed.")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
{info.permissions?.canManage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={handleRemove}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
{t("Remove verification")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{info.permissions?.canVerify && (
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!confirmed || storedFixedExpired}
|
||||
loading={verifyMutation.isPending}
|
||||
color={status === "expired" ? "red" : "dark"}
|
||||
ml="auto"
|
||||
>
|
||||
{t("Verify")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function QmsManageContent({ pageId, info, onClose }: ManageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const verifyMutation = useVerifyPageMutation();
|
||||
const submitMutation = useSubmitForApprovalMutation();
|
||||
const rejectMutation = useRejectApprovalMutation();
|
||||
const obsoleteMutation = useMarkObsoleteMutation();
|
||||
const removeMutation = useRemoveVerificationMutation();
|
||||
const updateMutation = useUpdateVerificationMutation();
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [showRejectForm, setShowRejectForm] = useState(false);
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
|
||||
const requestedAtAgo = useTimeAgo(
|
||||
info.requestedAt ?? new Date().toISOString(),
|
||||
);
|
||||
const rejectedAtAgo = useTimeAgo(info.rejectedAt ?? new Date().toISOString());
|
||||
|
||||
const status = info.status;
|
||||
|
||||
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
|
||||
|
||||
const handleSubmitForApproval = () => {
|
||||
submitMutation.mutate(pageId, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
verifyMutation.mutate(pageId, {
|
||||
onSuccess: () => {
|
||||
setConfirmed(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
rejectMutation.mutate(
|
||||
{ pageId, comment: rejectComment || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowRejectForm(false);
|
||||
setRejectComment("");
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleMarkObsolete = () => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Mark as obsolete"),
|
||||
children: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to mark this page as obsolete? This action cannot be undone.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"To restore this page, you will need to remove verification and set it up again.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
labels: { confirm: t("Mark obsolete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () =>
|
||||
obsoleteMutation.mutate(pageId, { onSuccess: onClose }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Remove verification"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to remove verification from this page?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveVerifier = (userId: string) => {
|
||||
if (!info.verifiers) return;
|
||||
const remaining = info.verifiers
|
||||
.filter((v) => v.id !== userId)
|
||||
.map((v) => v.id);
|
||||
updateMutation.mutate({ pageId, verifierIds: remaining });
|
||||
};
|
||||
|
||||
const handleAddVerifier = (userId: string) => {
|
||||
if (!info.verifiers) return;
|
||||
if (info.verifiers.some((v) => v.id === userId)) return;
|
||||
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
|
||||
updateMutation.mutate({ pageId, verifierIds });
|
||||
};
|
||||
|
||||
const canManageVerifiers =
|
||||
info.permissions?.canManage && status !== "obsolete";
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Pages move through draft, approval, and approved stages.")}
|
||||
</Text>
|
||||
|
||||
{status === "draft" && (
|
||||
<>
|
||||
{info.rejectedBy && info.rejectedAt && (
|
||||
<div>
|
||||
<Text size="sm" c="red">
|
||||
{t("Returned by {{name}} {{time}}", {
|
||||
name: info.rejectedBy.name,
|
||||
time: rejectedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
{info.rejectionComment && (
|
||||
<Text size="sm" c="dimmed" mt={4} fs="italic">
|
||||
“{info.rejectionComment}”
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!info.rejectedBy && (
|
||||
<Text size="sm">{t("No approval has been requested yet.")}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "in_approval" && (
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Submitted by {{name}} {{time}}", {
|
||||
name: info.requestedBy?.name ?? t("Someone"),
|
||||
time: requestedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "approved" && info.verifiedBy && (
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Approved by {{name}} {{time}}", {
|
||||
name: info.verifiedBy.name,
|
||||
time: verifiedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "obsolete" && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("This document has been marked as obsolete.")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{info.verifiers && info.verifiers.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
<VerifierList
|
||||
verifiers={info.verifiers}
|
||||
canManage={canManageVerifiers}
|
||||
onRemove={canManageVerifiers ? handleRemoveVerifier : undefined}
|
||||
/>
|
||||
{canManageVerifiers && info.verifiers.length < MAX_VERIFIERS && (
|
||||
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierPicker
|
||||
excludeIds={existingVerifierIds}
|
||||
onSelect={(user) => handleAddVerifier(user.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "in_approval" && info.permissions?.canVerify && (
|
||||
<>
|
||||
{showRejectForm ? (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Rejection comment")}
|
||||
</Text>
|
||||
<Textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.currentTarget.value)}
|
||||
placeholder={t("Reason for returning this document...")}
|
||||
minRows={2}
|
||||
variant="filled"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm" gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
onClick={() => {
|
||||
setShowRejectForm(false);
|
||||
setRejectComment("");
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleReject}
|
||||
loading={rejectMutation.isPending}
|
||||
>
|
||||
{t("Confirm rejection")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
{info.permissions?.canManage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={handleRemove}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
{t("Remove verification")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Group gap="xs" ml="auto">
|
||||
{status === "draft" && info.permissions?.canSubmitForApproval && (
|
||||
<Button
|
||||
onClick={handleSubmitForApproval}
|
||||
loading={submitMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{t("Submit for approval")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "in_approval" &&
|
||||
info.permissions?.canVerify &&
|
||||
!showRejectForm && (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
>
|
||||
{t("Reject")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!confirmed}
|
||||
loading={verifyMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{t("Approve")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "approved" && (
|
||||
<>
|
||||
{info.permissions?.canSubmitForApproval && (
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={handleSubmitForApproval}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
{t("Re-submit for approval")}
|
||||
</Button>
|
||||
)}
|
||||
{info.permissions?.canMarkObsolete && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleMarkObsolete}
|
||||
loading={obsoleteMutation.isPending}
|
||||
>
|
||||
{t("Mark obsolete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
.chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
margin-bottom: 2px;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px 16px 12px;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 10px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
120% 90% at 100% 0%,
|
||||
light-dark(rgba(15, 15, 20, 0.035), rgba(255, 255, 255, 0.04)),
|
||||
transparent 55%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-3)
|
||||
);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 1px 0 0
|
||||
light-dark(
|
||||
rgba(15, 15, 20, 0.04),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
),
|
||||
0 18px 36px -22px
|
||||
light-dark(rgba(15, 15, 20, 0.22), rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: none;
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-3)
|
||||
);
|
||||
box-shadow: 0 0 0 3px
|
||||
light-dark(
|
||||
rgba(15, 15, 20, 0.08),
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.iconStamp {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-7),
|
||||
var(--mantine-color-gray-2)
|
||||
);
|
||||
transition:
|
||||
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card:hover .iconStamp {
|
||||
background: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-1)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-9)
|
||||
);
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.metaIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bestFor {
|
||||
font-size: 10.5px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
transition:
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.card:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.configureHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.configureEyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconRosetteDiscountCheckFilled,
|
||||
IconShieldCheck,
|
||||
} 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 { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { SetupVerificationForm } from "./setup-verification-form";
|
||||
import { ManageVerificationForm } from "./manage-verification-form";
|
||||
import { getStatusColor, getStatusLabel } from "./verification-status";
|
||||
|
||||
type PageVerificationModalProps = {
|
||||
pageId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationModal({
|
||||
pageId,
|
||||
opened,
|
||||
onClose,
|
||||
}: PageVerificationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: verificationInfo } = usePageVerificationInfoQuery(
|
||||
opened ? pageId : undefined,
|
||||
);
|
||||
|
||||
const status = verificationInfo?.status ?? "none";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck
|
||||
size={20}
|
||||
stroke={1.5}
|
||||
color={
|
||||
status === "verified" || status === "approved"
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: status === "expired"
|
||||
? "var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Text fw={600}>
|
||||
{status === "none" ? t("Set up verification") : t("Verify page")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size={520}
|
||||
>
|
||||
{status === "none" ? (
|
||||
<SetupVerificationForm pageId={pageId} onClose={onClose} />
|
||||
) : (
|
||||
<ManageVerificationForm pageId={pageId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type PageVerificationBadgeProps = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function PageVerificationBadge({
|
||||
readOnly,
|
||||
}: PageVerificationBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
|
||||
const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery(
|
||||
hasVerificationFeature ? pageId : undefined,
|
||||
);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
if (!pageId) return null;
|
||||
if (!hasVerificationFeature) {
|
||||
if (readOnly) return null;
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${t("Add verification")} — ${upgradeLabel}`}
|
||||
withArrow
|
||||
openDelay={250}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconShieldCheck size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isLoading) return null;
|
||||
|
||||
const status = verificationInfo?.status ?? "none";
|
||||
|
||||
if (status === "none" && readOnly) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{status !== "none" ? (
|
||||
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
|
||||
<Group
|
||||
gap={4}
|
||||
onClick={open}
|
||||
style={{ cursor: "pointer" }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<IconRosetteDiscountCheckFilled
|
||||
size={18}
|
||||
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
|
||||
/>
|
||||
<Text size="sm" c={getStatusColor(status)}>
|
||||
{getStatusLabel(status, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
) : !readOnly ? (
|
||||
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={open}>
|
||||
<IconShieldCheck size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<PageVerificationModal pageId={pageId} opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type PageVerificationMenuItemProps = {
|
||||
pageId?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationMenuItem({
|
||||
pageId,
|
||||
onClick,
|
||||
}: PageVerificationMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const { data: verificationInfo } = usePageVerificationInfoQuery(
|
||||
hasVerificationFeature ? pageId : undefined,
|
||||
);
|
||||
|
||||
const hasVerification =
|
||||
!!verificationInfo && verificationInfo.status !== "none";
|
||||
const label = hasVerification
|
||||
? t("Edit verification")
|
||||
: t("Add verification");
|
||||
|
||||
const menuItem = (
|
||||
<Menu.Item
|
||||
disabled={!hasVerificationFeature}
|
||||
leftSection={<IconShieldCheck size={16} />}
|
||||
onClick={hasVerificationFeature ? onClick : undefined}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
if (!hasVerificationFeature) {
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
|
||||
{menuItem}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCertificate2,
|
||||
IconCheck,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import classes from "./page-verification-modal.module.css";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useSetupVerificationMutation } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import {
|
||||
ExpirationMode,
|
||||
PeriodUnit,
|
||||
VerificationType,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import {
|
||||
ExpirationFields,
|
||||
PERIOD_AMOUNT_MIN,
|
||||
PERIOD_UNIT_MAX_AMOUNT,
|
||||
} from "./expiration-fields";
|
||||
import { VerifierPicker } from "./verifier-picker";
|
||||
import { VerifierList } from "./verifier-list";
|
||||
import { MAX_VERIFIERS, UserOptionItem } from "./user-option";
|
||||
|
||||
type WorkflowChooserProps = {
|
||||
onSelect: (type: VerificationType) => void;
|
||||
};
|
||||
|
||||
function WorkflowChooser({ onSelect }: WorkflowChooserProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text className={classes.subhead}>
|
||||
{t("Choose how this page should stay accurate.")}
|
||||
</Text>
|
||||
|
||||
<div className={classes.chooser}>
|
||||
<UnstyledButton
|
||||
component="button"
|
||||
type="button"
|
||||
className={classes.card}
|
||||
onClick={() => onSelect("expiring" as VerificationType)}
|
||||
>
|
||||
<div className={classes.titleRow}>
|
||||
<span className={classes.iconStamp}>
|
||||
<IconRefresh size={15} stroke={1.7} />
|
||||
</span>
|
||||
<h3 className={classes.title}>{t("Recurring verification")}</h3>
|
||||
</div>
|
||||
<p className={classes.description}>
|
||||
{t("Verifiers re-confirm this page on a schedule.")}
|
||||
</p>
|
||||
|
||||
<div className={classes.rule} />
|
||||
|
||||
<div className={classes.meta}>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Re-verify on a schedule (e.g every 30 days )")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.cardFooter}>
|
||||
<span className={classes.bestFor}>
|
||||
{t("Best for runbooks, FAQs, living documentation")}
|
||||
</span>
|
||||
<span className={classes.arrow}>
|
||||
<IconArrowRight size={16} stroke={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton
|
||||
component="button"
|
||||
type="button"
|
||||
className={classes.card}
|
||||
onClick={() => onSelect("qms" as VerificationType)}
|
||||
>
|
||||
<div className={classes.titleRow}>
|
||||
<span className={classes.iconStamp}>
|
||||
<IconCertificate2 size={15} stroke={1.7} />
|
||||
</span>
|
||||
<h3 className={classes.title}>{t("Approval workflow")}</h3>
|
||||
</div>
|
||||
<p className={classes.description}>
|
||||
{t("Formal document lifecycle with named approvers.")}
|
||||
</p>
|
||||
|
||||
<div className={classes.rule} />
|
||||
|
||||
<div className={classes.meta}>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Draft → In approval → Approved → Obsolete")}
|
||||
</div>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Designed for ISO 9001, ISO 13485, and FDA")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.cardFooter}>
|
||||
<span className={classes.bestFor}>
|
||||
{t("Best for SOPs and controlled documents")}
|
||||
</span>
|
||||
<span className={classes.arrow}>
|
||||
<IconArrowRight size={16} stroke={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
type SetupVerificationFormProps = {
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SetupVerificationForm({
|
||||
pageId,
|
||||
onClose,
|
||||
}: SetupVerificationFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const setupMutation = useSetupVerificationMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [type, setType] = useState<VerificationType | null>(null);
|
||||
const [mode, setMode] = useState<ExpirationMode>("period");
|
||||
const [periodAmount, setPeriodAmount] = useState<number>(1);
|
||||
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>("month");
|
||||
const [fixedDate, setFixedDate] = useState<string>("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [selectedVerifiers, setSelectedVerifiers] = useState<UserOptionItem[]>(
|
||||
[],
|
||||
);
|
||||
const didInitCurrentUser = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitCurrentUser.current && currentUser?.user) {
|
||||
didInitCurrentUser.current = true;
|
||||
const u = currentUser.user;
|
||||
setSelectedVerifiers([
|
||||
{
|
||||
value: u.id,
|
||||
label: u.name,
|
||||
email: u.email,
|
||||
avatarUrl: u.avatarUrl,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
const isQms = type === "qms";
|
||||
const canAddMore = selectedVerifiers.length < MAX_VERIFIERS;
|
||||
|
||||
if (type === null) {
|
||||
return <WorkflowChooser onSelect={setType} />;
|
||||
}
|
||||
|
||||
const handleAddVerifier = (user: UserOptionItem) => {
|
||||
setSelectedVerifiers((prev) =>
|
||||
prev.some((v) => v.value === user.value) ? prev : [...prev, user],
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVerifier = (userId: string) => {
|
||||
setSelectedVerifiers((prev) => prev.filter((v) => v.value !== userId));
|
||||
};
|
||||
|
||||
const handleSetup = () => {
|
||||
if (selectedVerifiers.length === 0) return;
|
||||
setupMutation.mutate(
|
||||
{
|
||||
pageId,
|
||||
type,
|
||||
...(!isQms && {
|
||||
mode,
|
||||
...(mode === "period" && {
|
||||
periodAmount,
|
||||
periodUnit,
|
||||
}),
|
||||
...(mode === "fixed" &&
|
||||
fixedDate && {
|
||||
fixedExpiresAt: new Date(fixedDate).toISOString(),
|
||||
}),
|
||||
}),
|
||||
verifierIds: selectedVerifiers.map((v) => v.value),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (!isQms) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const periodValid =
|
||||
mode !== "period" ||
|
||||
(Number.isInteger(periodAmount) &&
|
||||
periodAmount >= PERIOD_AMOUNT_MIN &&
|
||||
periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]);
|
||||
const fixedDateValid =
|
||||
mode !== "fixed" ||
|
||||
(!!fixedDate && new Date(fixedDate).getTime() > Date.now());
|
||||
const hasVerifiers = selectedVerifiers.length > 0;
|
||||
|
||||
const canSubmit = isQms
|
||||
? hasVerifiers
|
||||
: hasVerifiers && confirmed && periodValid && fixedDateValid;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.backButton}
|
||||
onClick={() => setType(null)}
|
||||
>
|
||||
<IconArrowLeft size={12} stroke={2.2} />
|
||||
{t("Back")}
|
||||
</button>
|
||||
<div className={classes.configureHeader}>
|
||||
<span className={classes.iconStamp}>
|
||||
{isQms ? (
|
||||
<IconCertificate2 size={16} stroke={1.6} />
|
||||
) : (
|
||||
<IconRefresh size={16} stroke={1.6} />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<span className={classes.configureEyebrow}>
|
||||
{isQms ? t("Quality management") : t("Recurring")}
|
||||
</span>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{isQms
|
||||
? t("Pages move through draft, approval, and approved stages.")
|
||||
: t(
|
||||
"Assigned verifiers must periodically re-verify this page.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
{selectedVerifiers.length > 0 && (
|
||||
<div style={{ marginBottom: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierList
|
||||
verifiers={selectedVerifiers.map((v) => ({
|
||||
id: v.value,
|
||||
name: v.label,
|
||||
email: v.email,
|
||||
avatarUrl: v.avatarUrl,
|
||||
}))}
|
||||
canManage
|
||||
onRemove={handleRemoveVerifier}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAddMore && (
|
||||
<VerifierPicker
|
||||
excludeIds={selectedVerifiers.map((v) => v.value)}
|
||||
onSelect={handleAddVerifier}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isQms && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("Expiration")}
|
||||
</Text>
|
||||
<ExpirationFields
|
||||
mode={mode}
|
||||
periodAmount={periodAmount}
|
||||
periodUnit={periodUnit}
|
||||
fixedDate={fixedDate}
|
||||
onModeChange={setMode}
|
||||
onPeriodAmountChange={setPeriodAmount}
|
||||
onPeriodUnitChange={setPeriodUnit}
|
||||
onFixedDateChange={setFixedDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Confirm")}
|
||||
</Text>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={handleSetup}
|
||||
disabled={!canSubmit}
|
||||
loading={setupMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{isQms ? t("Set up") : t("Verify")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Group, SelectProps, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
|
||||
export const MAX_VERIFIERS = 5;
|
||||
|
||||
export type UserOptionItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export function toUserOptions(users: IUser[] | undefined): UserOptionItem[] {
|
||||
return (users ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
export const renderUserSelectOption: SelectProps["renderOption"] = ({
|
||||
option,
|
||||
}) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={option["avatarUrl"]}
|
||||
size={20}
|
||||
name={option.label}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{option["email"]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
Group,
|
||||
Skeleton,
|
||||
Anchor,
|
||||
Badge,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IVerificationListItem,
|
||||
VerificationStatus,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { format } from "date-fns";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
|
||||
const MAX_VISIBLE_VERIFIERS = 5;
|
||||
|
||||
type VerificationListTableProps = {
|
||||
items?: IVerificationListItem[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function statusBadge(status: VerificationStatus | null, t: (s: string) => string) {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>;
|
||||
case "expiring":
|
||||
return <Badge color="orange" variant="light" size="sm">{t("Expiring")}</Badge>;
|
||||
case "expired":
|
||||
return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>;
|
||||
case "approved":
|
||||
return <Badge color="green" variant="light" size="sm">{t("Approved")}</Badge>;
|
||||
case "draft":
|
||||
return <Badge color="gray" variant="light" size="sm">{t("Draft")}</Badge>;
|
||||
case "in_approval":
|
||||
return <Badge color="blue" variant="light" size="sm">{t("In approval")}</Badge>;
|
||||
case "obsolete":
|
||||
return <Badge color="red" variant="light" size="sm">{t("Obsolete")}</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string {
|
||||
if (item.type === "qms") {
|
||||
if (item.status === "approved") return t("Indefinitely");
|
||||
return "—";
|
||||
}
|
||||
|
||||
if (!item.expiresAt) return t("Indefinitely");
|
||||
|
||||
const expires = new Date(item.expiresAt);
|
||||
const now = new Date();
|
||||
|
||||
if (expires <= now) return t("Expired");
|
||||
return format(expires, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Skeleton height={14} width={160} mb={4} />
|
||||
<Skeleton height={10} width={100} />
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={100} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={20} width={60} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationListTable({
|
||||
items,
|
||||
isLoading,
|
||||
}: VerificationListTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Page")}</Table.Th>
|
||||
<Table.Th>{t("Verifiers")}</Table.Th>
|
||||
<Table.Th>{t("Verified until")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : items && items.length > 0 ? (
|
||||
items.map((item) => {
|
||||
const verifiers = item.verifiers ?? [];
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
item.spaceSlug,
|
||||
item.pageSlugId,
|
||||
item.pageTitle ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{ color: "var(--mantine-color-text)" }}
|
||||
component={Link}
|
||||
to={pageUrl}
|
||||
>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{item.pageIcon ? `${item.pageIcon} ` : ""}
|
||||
{item.pageTitle || t("Untitled")}
|
||||
</Text>
|
||||
</Anchor>
|
||||
<Text fz="xs" c="dimmed" lineClamp={1}>
|
||||
{item.spaceName}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{verifiers.length === 1 ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={verifiers[0].avatarUrl}
|
||||
name={verifiers[0].name}
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{verifiers[0].name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : verifiers.length > 1 ? (
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{verifiers
|
||||
.slice(0, MAX_VISIBLE_VERIFIERS)
|
||||
.map((verifier) => (
|
||||
<Tooltip
|
||||
key={verifier.id}
|
||||
label={verifier.name}
|
||||
withArrow
|
||||
>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={verifier.avatarUrl}
|
||||
name={verifier.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{verifiers.length > MAX_VISIBLE_VERIFIERS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={verifiers
|
||||
.slice(MAX_VISIBLE_VERIFIERS)
|
||||
.map((v) => (
|
||||
<div key={v.id}>{v.name}</div>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{verifiers.length - MAX_VISIBLE_VERIFIERS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.Group>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{verifiedUntilText(item, t)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{statusBadge(item.status as VerificationStatus, t)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<NoTableResults colSpan={4} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { VerificationStatus } from "@/ee/page-verification/types/page-verification.types";
|
||||
|
||||
export function getStatusColor(status: VerificationStatus): string {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
case "approved":
|
||||
return "blue.7";
|
||||
case "expiring":
|
||||
case "in_approval":
|
||||
return "orange.8";
|
||||
case "expired":
|
||||
return "red.7";
|
||||
case "draft":
|
||||
case "obsolete":
|
||||
return "gray.6";
|
||||
default:
|
||||
return "gray.6";
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(
|
||||
status: VerificationStatus,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return t("Verified");
|
||||
case "expiring":
|
||||
return t("Review needed");
|
||||
case "expired":
|
||||
return t("Verification expired");
|
||||
case "draft":
|
||||
return t("Draft");
|
||||
case "in_approval":
|
||||
return t("In Approval");
|
||||
case "approved":
|
||||
return t("Approved");
|
||||
case "obsolete":
|
||||
return t("Obsolete");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { IVerifier } from "@/ee/page-verification/types/page-verification.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type VerifierListProps = {
|
||||
verifiers: IVerifier[];
|
||||
canManage?: boolean;
|
||||
onRemove?: (userId: string) => void;
|
||||
};
|
||||
|
||||
export function VerifierList({
|
||||
verifiers,
|
||||
canManage,
|
||||
onRemove,
|
||||
}: VerifierListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (verifiers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{verifiers.map((verifier, index) => (
|
||||
<Group
|
||||
key={verifier.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py={6}
|
||||
style={{
|
||||
borderBottom:
|
||||
index < verifiers.length - 1
|
||||
? "1px solid var(--mantine-color-gray-1)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<CustomAvatar
|
||||
avatarUrl={verifier.avatarUrl}
|
||||
name={verifier.name}
|
||||
size={28}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" truncate="end">
|
||||
{verifier.name}
|
||||
</Text>
|
||||
{verifier.email && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{verifier.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{canManage && onRemove && (
|
||||
<Tooltip label={t("Remove")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => onRemove(verifier.id)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Select } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import {
|
||||
renderUserSelectOption,
|
||||
toUserOptions,
|
||||
UserOptionItem,
|
||||
} from "./user-option";
|
||||
|
||||
type VerifierPickerProps = {
|
||||
excludeIds: string[];
|
||||
disabled?: boolean;
|
||||
onSelect: (user: UserOptionItem) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function VerifierPicker({
|
||||
excludeIds,
|
||||
disabled,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: VerifierPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 300);
|
||||
|
||||
const { data: suggestion } = useSearchSuggestionsQuery({
|
||||
query: debouncedQuery,
|
||||
includeUsers: true,
|
||||
includeGroups: false,
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const excludeSet = new Set(excludeIds);
|
||||
const options = toUserOptions(suggestion?.users).filter(
|
||||
(u) => !excludeSet.has(u.value),
|
||||
);
|
||||
|
||||
const handleChange = (userId: string | null) => {
|
||||
if (!userId) return;
|
||||
const picked = options.find((u) => u.value === userId);
|
||||
if (!picked) return;
|
||||
onSelect(picked);
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={options}
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
renderOption={renderUserSelectOption}
|
||||
placeholder={placeholder ?? t("Add verifier")}
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
filter={({ options }) => options}
|
||||
variant="filled"
|
||||
disabled={disabled}
|
||||
nothingFoundMessage={t("No user found")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./components/page-verification-modal";
|
||||
export * from "./components/verifier-list";
|
||||
export * from "./queries/page-verification-query";
|
||||
export * from "./services/page-verification-service";
|
||||
export * from "./types/page-verification.types";
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSearch } 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 { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
|
||||
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
|
||||
export default function VerifiedPages() {
|
||||
const { t } = useTranslation();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||
const [spaceFilter, setSpaceFilter] = useState<string[]>([]);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const spaceOptions = useMemo(
|
||||
() =>
|
||||
spacesData?.items?.map((space) => ({
|
||||
value: space.id,
|
||||
label: space.name,
|
||||
})) ?? [],
|
||||
[spacesData],
|
||||
);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: "expiring", label: t("Expiring") },
|
||||
{ value: "qms", label: t("QMS") },
|
||||
];
|
||||
|
||||
const params: IVerificationListParams = useMemo(
|
||||
() => ({
|
||||
cursor,
|
||||
limit: 50,
|
||||
spaceIds: spaceFilter.length > 0 ? spaceFilter : undefined,
|
||||
type: typeFilter as IVerificationListParams["type"],
|
||||
query: debouncedSearch || undefined,
|
||||
}),
|
||||
[cursor, spaceFilter, typeFilter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useVerificationListQuery(params);
|
||||
|
||||
const handleSpaceChange = (value: string[]) => {
|
||||
setSpaceFilter(value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleTypeChange = (value: string | null) => {
|
||||
setTypeFilter(value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.currentTarget.value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Verified pages")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Verified pages")} />
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder={t("Search by title")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
size="sm"
|
||||
w={220}
|
||||
/>
|
||||
|
||||
{/*
|
||||
<MultiSelect
|
||||
placeholder={t("Filter by space")}
|
||||
data={spaceOptions}
|
||||
value={spaceFilter}
|
||||
onChange={handleSpaceChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t("Filter by type")}
|
||||
data={typeOptions}
|
||||
value={typeFilter}
|
||||
onChange={handleTypeChange}
|
||||
clearable
|
||||
w={160}
|
||||
size="sm"
|
||||
/>
|
||||
*/}
|
||||
</Group>
|
||||
|
||||
<VerificationListTable 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,202 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
IPageVerificationInfo,
|
||||
ISetupVerification,
|
||||
IUpdateVerification,
|
||||
IVerificationListItem,
|
||||
IVerificationListParams,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import {
|
||||
getVerificationInfo,
|
||||
getVerificationList,
|
||||
markObsolete,
|
||||
rejectApproval,
|
||||
removeVerification,
|
||||
setupVerification,
|
||||
submitForApproval,
|
||||
updateVerification,
|
||||
verifyPage,
|
||||
} from "@/ee/page-verification/services/page-verification-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export function usePageVerificationInfoQuery(
|
||||
pageId: string | undefined,
|
||||
): UseQueryResult<IPageVerificationInfo, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
queryFn: () => getVerificationInfo(pageId!),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetupVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, ISetupVerification>({
|
||||
mutationFn: (data) => setupVerification(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification enabled") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to enable verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, IUpdateVerification>({
|
||||
mutationFn: (data) => updateVerification(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification updated") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to update verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => removeVerification(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification removed") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to remove verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyPageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => verifyPage(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Page verified") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to verify page"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitForApprovalMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => submitForApproval(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Submitted for approval") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to submit for approval"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectApprovalMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, { pageId: string; comment?: string }>({
|
||||
mutationFn: (data) => rejectApproval(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Approval rejected") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to reject approval"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkObsoleteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => markObsolete(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Page marked as obsolete") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to mark as obsolete"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerificationListQuery(
|
||||
params?: IVerificationListParams,
|
||||
): UseQueryResult<IPagination<IVerificationListItem>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IPageVerificationInfo,
|
||||
ISetupVerification,
|
||||
IUpdateVerification,
|
||||
IVerificationListItem,
|
||||
IVerificationListParams,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export async function getVerificationInfo(
|
||||
pageId: string,
|
||||
): Promise<IPageVerificationInfo> {
|
||||
const req = await api.post<IPageVerificationInfo>(
|
||||
"/pages/verification-info",
|
||||
{ pageId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupVerification(
|
||||
data: ISetupVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/pages/create-verification", data);
|
||||
}
|
||||
|
||||
export async function updateVerification(
|
||||
data: IUpdateVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/pages/update-verification", data);
|
||||
}
|
||||
|
||||
export async function removeVerification(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete-verification", { pageId });
|
||||
}
|
||||
|
||||
export async function verifyPage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/verify", { pageId });
|
||||
}
|
||||
|
||||
export async function submitForApproval(pageId: string): Promise<void> {
|
||||
await api.post("/pages/submit-for-approval", { pageId });
|
||||
}
|
||||
|
||||
export async function rejectApproval(data: {
|
||||
pageId: string;
|
||||
comment?: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/pages/reject-approval", data);
|
||||
}
|
||||
|
||||
export async function markObsolete(pageId: string): Promise<void> {
|
||||
await api.post("/pages/mark-obsolete", { pageId });
|
||||
}
|
||||
|
||||
export async function getVerificationList(
|
||||
params?: IVerificationListParams,
|
||||
): Promise<IPagination<IVerificationListItem>> {
|
||||
const req = await api.post("/pages/verifications", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type VerificationType = "expiring" | "qms";
|
||||
|
||||
export type ExpirationMode = "period" | "fixed" | "indefinite";
|
||||
|
||||
export type PeriodUnit = "day" | "week" | "month" | "year";
|
||||
|
||||
export type VerificationStatus =
|
||||
| "verified"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "draft"
|
||||
| "in_approval"
|
||||
| "approved"
|
||||
| "obsolete"
|
||||
| "none";
|
||||
|
||||
export type IUserRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type IVerifier = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type IPageVerificationInfo = {
|
||||
id?: string;
|
||||
pageId?: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode | null;
|
||||
periodAmount?: number | null;
|
||||
periodUnit?: PeriodUnit | null;
|
||||
status: VerificationStatus;
|
||||
verifiedAt?: string | null;
|
||||
verifiedBy?: IUserRef | null;
|
||||
expiresAt?: string | null;
|
||||
requestedAt?: string | null;
|
||||
requestedBy?: IUserRef | null;
|
||||
rejectedAt?: string | null;
|
||||
rejectedBy?: IUserRef | null;
|
||||
rejectionComment?: string | null;
|
||||
verifiers?: IVerifier[];
|
||||
permissions?: IPageVerificationPermissions;
|
||||
};
|
||||
|
||||
export type IPageVerificationPermissions = {
|
||||
canVerify: boolean;
|
||||
canManage: boolean;
|
||||
canSubmitForApproval: boolean;
|
||||
canMarkObsolete: boolean;
|
||||
};
|
||||
|
||||
export type ISetupVerification = {
|
||||
pageId: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds: string[];
|
||||
};
|
||||
|
||||
export type IUpdateVerification = {
|
||||
pageId: string;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds?: string[];
|
||||
};
|
||||
|
||||
export type IVerificationListItem = {
|
||||
id: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
type: VerificationType;
|
||||
status: VerificationStatus | null;
|
||||
mode: ExpirationMode | null;
|
||||
periodAmount: number | null;
|
||||
periodUnit: PeriodUnit | null;
|
||||
verifiedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
pageTitle: string | null;
|
||||
pageSlugId: string;
|
||||
pageIcon: string | null;
|
||||
spaceName: string;
|
||||
spaceSlug: string;
|
||||
verifiers: IUserRef[];
|
||||
};
|
||||
|
||||
export type IVerificationListParams = {
|
||||
spaceIds?: string[];
|
||||
verifierId?: string;
|
||||
type?: VerificationType;
|
||||
cursor?: string;
|
||||
beforeCursor?: string;
|
||||
limit?: number;
|
||||
query?: string;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { Container, Title, Text, Button, Box } from "@mantine/core";
|
||||
import classes from "../../features/auth/components/auth.module.css";
|
||||
import {
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
} from "@/ee/cloud/service/cloud-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get("token");
|
||||
const rawEmail = searchParams.get("email");
|
||||
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
|
||||
const sig = searchParams.get("sig");
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resent, setResent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
handleVerify(token);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
async function handleVerify(verifyToken: string) {
|
||||
try {
|
||||
await verifyEmail({ token: verifyToken });
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: t("Verification failed. The link may have expired."),
|
||||
color: "red",
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (!email || !sig) return;
|
||||
setIsResending(true);
|
||||
|
||||
try {
|
||||
await resendVerificationEmail({ email, sig });
|
||||
setResent(true);
|
||||
} catch {
|
||||
notifications.show({
|
||||
message: t("Failed to resend verification email. Please try again."),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
setIsResending(false);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Verifying your email")}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed">
|
||||
{t("Please wait...")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Check your email")}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed" mb="md">
|
||||
{email
|
||||
? t("We sent a verification link to {{email}}.", { email })
|
||||
: t("We sent a verification link to your email.")}
|
||||
</Text>
|
||||
<Text ta="center" size="sm" c="dimmed" mb="lg">
|
||||
{t("Click the link to verify your email and access your workspace.")}
|
||||
</Text>
|
||||
{email && sig && !resent && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
>
|
||||
{t("Resend verification email")}
|
||||
</Button>
|
||||
)}
|
||||
{resent && (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
{t("Verification email sent. Please check your inbox.")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
|
||||
type PdfRenderData = {
|
||||
pageId: string;
|
||||
title: string;
|
||||
content: any;
|
||||
};
|
||||
|
||||
export default function PdfRenderPage() {
|
||||
const { pageId } = useParams<{ pageId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [data, setData] = useState<PdfRenderData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageId || !token) {
|
||||
setError("Missing page ID or token");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/pdf-export/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pageId, token }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((result) => setData(result.data))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [pageId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.title) {
|
||||
document.title = data.title;
|
||||
}
|
||||
}, [data?.title]);
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={900} p={0}>
|
||||
<ReadonlyPageEditor
|
||||
key={data.pageId}
|
||||
title={data.title}
|
||||
content={data.content}
|
||||
pageId={data.pageId}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function AllowMemberTemplates() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Allow members to create templates")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Allow non-admin members to create and manage templates in their spaces.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AllowMemberTemplatesToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowMemberTemplatesToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.templates?.allowMemberTemplates === true,
|
||||
);
|
||||
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({
|
||||
allowMemberTemplates: value,
|
||||
});
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={hasSecuritySettings}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasSecuritySettings}
|
||||
aria-label={t("Toggle allow members to create templates")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -6,21 +6,23 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function DisablePublicSharing() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Disable public sharing")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Prevent members from sharing pages publicly.")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="md">{t("Disable public sharing")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Prevent members from sharing pages publicly.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<DisablePublicSharingToggle />
|
||||
<DisablePublicSharingToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +33,8 @@ function DisablePublicSharingToggle() {
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.sharing?.disabled === true,
|
||||
);
|
||||
const hasAccess = useEnterpriseAccess();
|
||||
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const applyChange = async (value: boolean) => {
|
||||
try {
|
||||
@@ -72,15 +75,11 @@ function DisablePublicSharingToggle() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={t("Requires an enterprise license")}
|
||||
disabled={hasAccess}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Tooltip label={upgradeLabel} disabled={hasSharingControls} refProp="rootRef">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
disabled={!hasSharingControls}
|
||||
aria-label={t("Toggle public sharing")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Text,
|
||||
Switch,
|
||||
MantineSize,
|
||||
Title,
|
||||
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 { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function EnforceMfa() {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +43,8 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceMfa);
|
||||
const hasAccess = useHasFeature(Feature.MFA);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
@@ -49,13 +61,16 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle MFA enforcement")}
|
||||
/>
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle MFA enforcement")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Group, Text, Switch, MantineSize } from "@mantine/core";
|
||||
import { Group, Text, Switch, MantineSize, 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 { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function EnforceSso() {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +36,8 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceSso);
|
||||
const hasAccess = useHasFeature(Feature.SSO_CUSTOM);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
@@ -49,13 +54,16 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle sso enforcement")}
|
||||
/>
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle sso enforcement")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type SpacePublicSharingToggleProps = {
|
||||
space: ISpace;
|
||||
@@ -17,6 +20,9 @@ export default function SpacePublicSharingToggle({
|
||||
const { t } = useTranslation();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDisabled = !hasSharingControls || workspaceDisabled;
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.sharing?.disabled === true,
|
||||
);
|
||||
@@ -68,14 +74,14 @@ export default function SpacePublicSharingToggle({
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t("Public sharing is disabled at the workspace level")}
|
||||
disabled={!workspaceDisabled}
|
||||
label={!hasSharingControls ? upgradeLabel : t("Public sharing is disabled at the workspace level")}
|
||||
disabled={!isDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={workspaceDisabled}
|
||||
disabled={isDisabled}
|
||||
aria-label={t("Toggle space public sharing")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type SpaceViewerCommentsToggleProps = {
|
||||
space: ISpace;
|
||||
};
|
||||
|
||||
export default function SpaceViewerCommentsToggle({
|
||||
space,
|
||||
}: SpaceViewerCommentsToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDisabled = !hasViewerComments;
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.allowViewerComments === true,
|
||||
);
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
allowViewerComments: value,
|
||||
});
|
||||
setChecked(value);
|
||||
} catch {
|
||||
// error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Allow viewers to comment")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Allow viewers to add comments on pages in this space.")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={!isDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
aria-label={t("Toggle viewer comments")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,18 @@ 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";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type RetentionUnit = "days" | "months" | "years";
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
|
||||
function daysToRetention(days: number): {
|
||||
amount: number;
|
||||
unit: RetentionUnit;
|
||||
} {
|
||||
if (days >= 365 && days % 365 === 0) {
|
||||
return { amount: days / 365, unit: "years" };
|
||||
}
|
||||
@@ -36,14 +41,19 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
|
||||
|
||||
export default function TrashRetention() {
|
||||
const { t } = useTranslation();
|
||||
const hasAccess = useEnterpriseAccess();
|
||||
const hasRetention = useHasFeature(Feature.RETENTION);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
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 [retentionAmount, setRetentionAmount] = useState<number | string>(
|
||||
parsed.amount,
|
||||
);
|
||||
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
|
||||
parsed.unit,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,14 +73,17 @@ export default function TrashRetention() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
|
||||
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"),
|
||||
message:
|
||||
err?.response?.data?.message || t("Failed to update trash retention"),
|
||||
color: "red",
|
||||
});
|
||||
const { amount, unit } = daysToRetention(currentDays);
|
||||
@@ -81,10 +94,11 @@ export default function TrashRetention() {
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = retentionToDays(
|
||||
typeof retentionAmount === "number" ? retentionAmount : 1,
|
||||
retentionUnit,
|
||||
) !== currentDays;
|
||||
const isDirty =
|
||||
retentionToDays(
|
||||
typeof retentionAmount === "number" ? retentionAmount : 1,
|
||||
retentionUnit,
|
||||
) !== currentDays;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -93,10 +107,7 @@ export default function TrashRetention() {
|
||||
{t("Pages in trash will be permanently deleted after this period.")}
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
label={t("Requires an enterprise license")}
|
||||
disabled={hasAccess}
|
||||
>
|
||||
<Tooltip label={upgradeLabel} disabled={hasRetention}>
|
||||
<Group gap="xs" wrap="nowrap" maw={320}>
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
@@ -105,7 +116,7 @@ export default function TrashRetention() {
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
disabled={!hasAccess}
|
||||
disabled={!hasRetention}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
@@ -121,13 +132,13 @@ export default function TrashRetention() {
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!hasAccess}
|
||||
disabled={!hasRetention}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasAccess || !isDirty}
|
||||
disabled={!hasRetention || !isDirty}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -12,14 +12,16 @@ 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";
|
||||
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const hasEnterpriseAccess = useEnterpriseAccess();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
|
||||
const hasRetention = useHasFeature(Feature.RETENTION);
|
||||
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@@ -36,39 +38,27 @@ export default function Security() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
{(!isCloud() || hasEnterpriseAccess) && (
|
||||
<>
|
||||
<DisablePublicSharing />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
<DisablePublicSharing />
|
||||
<Divider my="lg" />
|
||||
|
||||
{!isCloud() && (
|
||||
<>
|
||||
<TrashRetention />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
<TrashRetention />
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
{hasEnterpriseAccess && (
|
||||
<>
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
|
||||
{isCloudEE && (
|
||||
{(isCloud() || hasCustomSso) && (
|
||||
<>
|
||||
<AllowedDomains />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasEnterpriseAccess && (
|
||||
{hasCustomSso && (
|
||||
<>
|
||||
<CreateSsoProvider />
|
||||
<Divider size={0} my="lg" />
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Button,
|
||||
Stack,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreateTemplateMutation } from "../queries/template-query";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
type CreateTemplateModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreateTemplateModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: CreateTemplateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
||||
const createMutation = useCreateTemplateMutation();
|
||||
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [spaceId, setSpaceId] = useState<string | null>(null);
|
||||
|
||||
const scopeOptions = [
|
||||
...(isWorkspaceAdmin
|
||||
? [
|
||||
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
|
||||
]
|
||||
: []),
|
||||
...(spaces?.items?.length
|
||||
? [
|
||||
{
|
||||
group: t("Spaces"),
|
||||
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
spaceId: spaceId || undefined,
|
||||
});
|
||||
|
||||
handleClose();
|
||||
navigate(`/templates/${result.id}`);
|
||||
} catch {
|
||||
// error notification handled by mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle("");
|
||||
setSpaceId(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("New template")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Title")}
|
||||
placeholder={t("Untitled")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && title.trim() && !createMutation.isPending) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("Scope")}
|
||||
description={t("Choose which space this template belongs to")}
|
||||
data={scopeOptions}
|
||||
value={spaceId || ""}
|
||||
onChange={(val) => setSpaceId(val || null)}
|
||||
searchable
|
||||
placeholder={t("Select scope")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user