Compare commits

..
Author SHA1 Message Date
Philipinho 208e8541bc fix: increase s3 client max socket limit 2026-07-21 22:51:51 +01:00
148 changed files with 3179 additions and 6752 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ RUN chown -R node:node /app
USER node
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage
+4 -4
View File
@@ -50,9 +50,9 @@
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"nanoid": "3.3.17",
"nanoid": "3.3.8",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -61,7 +61,7 @@
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.18.2",
"react-router-dom": "7.18.0",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -86,7 +86,7 @@
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.25",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
@@ -22,7 +22,6 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Cancelled": "Cancelled",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -30,9 +29,7 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Completed": "Completed",
"Confirm": "Confirm",
"Confluence site": "Confluence site",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -60,10 +57,6 @@
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Everyone with access to this space": "Everyone with access to this space",
"Failed": "Failed",
"Import details": "Import details",
"Permissions": "Permissions",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -83,7 +76,6 @@
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load comments. An error occurred.": "Failed to load comments. An error occurred.",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Failed to create base": "Failed to create base",
@@ -158,9 +150,6 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Restricted pages": "Restricted pages",
"Restrictions": "Restrictions",
"Running": "Running",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -197,7 +186,6 @@
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Site": "Site",
"Slug": "Slug",
"Space": "Space",
"Space description": "Space description",
@@ -206,13 +194,10 @@
"Space settings": "Space settings",
"Space slug": "Space slug",
"Spaces": "Spaces",
"spaces": "spaces",
"Spaces you belong to": "Spaces you belong to",
"No space found": "No space found",
"Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...",
"Started at": "Started at",
"Started by": "Started by",
"Status": "Status",
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
@@ -226,8 +211,6 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Users": "Users",
"users": "users",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
@@ -404,8 +387,6 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -1308,16 +1289,5 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}"
"{{count}} selected_other": "{{count}} selected"
}
-5
View File
@@ -47,7 +47,6 @@ 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";
import LabelPage from "@/pages/label/label-page";
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
export default function App() {
const { t } = useTranslation();
@@ -129,10 +128,6 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
<Route
path={"import/confluence"}
element={<ConfluenceImportPage />}
/>
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -1,7 +1,6 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react";
@@ -29,11 +28,7 @@ export default function Aside() {
switch (tab) {
case "comments":
component = (
<CommentErrorBoundary>
<CommentListWithTabs />
</CommentErrorBoundary>
);
component = <CommentListWithTabs />;
title = "Comments";
break;
case "toc":
@@ -15,12 +15,11 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconFileImport,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
@@ -52,7 +51,6 @@ type DataItem = {
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
show?: () => boolean;
};
type DataGroup = {
@@ -127,14 +125,6 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Import",
icon: IconFileImport,
path: "/settings/import/confluence",
feature: Feature.CONFLUENCE_API_IMPORT,
role: "admin",
show: () => isBetaConfluenceImporter(),
},
],
},
{
@@ -168,7 +158,6 @@ export default function SettingsSidebar() {
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.show && !item.show()) return false;
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
@@ -1,45 +0,0 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -1,29 +0,0 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+4 -2
View File
@@ -1,15 +1,17 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("404 page not found")} />
<Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
@@ -255,7 +255,6 @@ export default function ChatInput({
},
content: "",
editable: true,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -13,7 +15,6 @@ 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";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AiSettings() {
const { t } = useTranslation();
@@ -39,7 +40,9 @@ export default function AiSettings() {
return (
<>
<DocumentTitle title="AI settings" />
<Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -1,9 +1,10 @@
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 { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppUrl } from "@/lib/config";
import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -16,7 +17,6 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -49,7 +49,11 @@ export default function UserApiKeys() {
return (
<>
<DocumentTitle title={t("API keys")} />
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
@@ -1,7 +1,9 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
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";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -13,7 +15,6 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -46,7 +47,11 @@ export default function WorkspaceApiKeys() {
return (
<>
<DocumentTitle title={t("API management")} />
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
@@ -10,9 +10,11 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
@@ -24,7 +26,6 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years";
@@ -96,7 +97,11 @@ export default function AuditLogs() {
return (
<>
<DocumentTitle title={t("Audit log")} />
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} />
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -7,7 +9,6 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -19,7 +20,9 @@ export default function Billing() {
return (
<>
<DocumentTitle title="Billing" />
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" />
<BillingTrial />
@@ -1,340 +0,0 @@
import { useMemo, useState } from "react";
import {
Badge,
Group,
Loader,
Modal,
Progress,
Skeleton,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
ConfluenceImportHistoryItem,
ConfluenceImportStatus,
} from "@/ee/confluence-import/types/confluence-import.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import { useConfluenceImportsQuery } from "@/ee/confluence-import/queries/confluence-import-queries";
const BADGE_STYLES = {
root: { flexShrink: 0 },
label: { overflow: "visible" as const },
};
function statusBadge(
status: ConfluenceImportStatus,
cancelled: boolean,
t: (key: string) => string,
) {
if (cancelled) {
return (
<Badge
color="gray"
variant="light"
leftSection={<IconX size={12} />}
styles={BADGE_STYLES}
>
{t("Cancelled")}
</Badge>
);
}
if (status === "processing") {
return (
<Badge
color="blue"
variant="light"
leftSection={<Loader size={10} />}
styles={BADGE_STYLES}
>
{t("Running")}
</Badge>
);
}
if (status === "success") {
return (
<Badge
color="teal"
variant="light"
leftSection={<IconCheck size={12} />}
styles={BADGE_STYLES}
>
{t("Completed")}
</Badge>
);
}
return (
<Badge
color="red"
variant="light"
leftSection={<IconAlertCircle size={12} />}
styles={BADGE_STYLES}
>
{t("Failed")}
</Badge>
);
}
function phaseLabel(phase: string | null, t: (key: string) => string): string {
if (!phase) return "—";
return t(phase.charAt(0).toUpperCase() + phase.slice(1));
}
function progressValue(item: ConfluenceImportHistoryItem) {
if (item.status === "success") return 100;
if (item.totalPages > 0) {
return Math.min(
100,
Math.round((item.importedPages / item.totalPages) * 100),
);
}
return item.status === "processing" ? 5 : 0;
}
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
const { t } = useTranslation();
const value = progressValue(item);
const color =
item.status === "failed"
? "red"
: item.status === "success"
? "teal"
: "blue";
return (
<Stack gap={4}>
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
<Group gap="xs" wrap="nowrap">
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{item.importedPages}/{item.totalPages || "?"} {t("pages")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedSpaces}/{item.totalSpaces || "?"} {t("spaces")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedUsers}/{item.totalUsers || "?"} {t("users")}
</Text>
</Group>
</Stack>
);
}
function ImportStatsModal({
item,
onClose,
}: {
item: ConfluenceImportHistoryItem | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const stats = item
? [
{
label: t("Spaces"),
imported: item.importedSpaces,
total: item.totalSpaces,
},
{
label: t("Pages"),
imported: item.importedPages,
total: item.totalPages,
},
{
label: t("Users"),
imported: item.importedUsers,
total: item.totalUsers,
},
{
label: t("Groups"),
imported: item.importedGroups,
total: item.totalGroups,
},
{
label: t("Attachments"),
imported: item.importedAttachments,
total: item.totalAttachments,
},
{
label: t("Labels"),
imported: item.importedLabels,
total: item.totalLabels,
},
{
label: t("Restricted pages"),
imported: item.importedRestrictedPages,
total: item.totalRestrictedPages,
},
]
: [];
return (
<Modal
opened={!!item}
onClose={onClose}
title={t("Import details")}
size="md"
>
{item && (
<Stack gap="sm">
<div>
<Text fz="sm" c="dimmed">
{t("Confluence site")}
</Text>
<Text fz="sm" fw={500} lineClamp={1}>
{item.siteUrl}
</Text>
</div>
<div>
<Text fz="sm" c="dimmed">
{t("Started at")}
</Text>
<Text fz="sm">{formattedDate(new Date(item.createdAt))}</Text>
</div>
<Table verticalSpacing="xs" fz="sm">
<Table.Tbody>
{stats.map((stat) => (
<Table.Tr key={stat.label}>
<Table.Td>
<Text fz="sm">{stat.label}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ta="right">
{stat.imported} / {stat.total}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
</Modal>
);
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={180} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={80} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export default function ConfluenceImportHistory() {
const { t } = useTranslation();
const { data, isLoading } = useConfluenceImportsQuery();
const [selectedItem, setSelectedItem] =
useState<ConfluenceImportHistoryItem | null>(null);
const items = useMemo(() => data?.items ?? [], [data]);
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Site")}</Table.Th>
<Table.Th>{t("Phase")}</Table.Th>
<Table.Th>{t("Progress")}</Table.Th>
<Table.Th>{t("Started by")}</Table.Th>
<Table.Th>{t("Started at")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items.length > 0 ? (
items.map((item) => (
<Table.Tr
key={item.fileTaskId}
onClick={() => setSelectedItem(item)}
style={{ cursor: "pointer" }}
>
<Table.Td>
{statusBadge(item.status, item.cancelled, t)}
{item.status === "failed" && item.errorMessage && (
<Tooltip label={item.errorMessage} multiline w={320}>
<Text fz="xs" c="red" lineClamp={1} maw={180}>
{item.errorMessage}
</Text>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" lineClamp={1} maw={240}>
{item.siteUrl}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{phaseLabel(item.currentPhase, t)}</Text>
</Table.Td>
<Table.Td>
<ProgressCell item={item} />
</Table.Td>
<Table.Td>
{item.creatorName ? (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={item.creatorAvatarUrl}
name={item.creatorName}
size={24}
/>
<Text fz="sm" lineClamp={1}>
{item.creatorName}
</Text>
</Group>
) : (
<Text fz="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(item.createdAt))}
</Text>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
<ImportStatsModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
/>
</Table.ScrollContainer>
);
}
@@ -1,445 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Checkbox,
Group,
Modal,
PasswordInput,
ScrollArea,
SegmentedControl,
Stack,
Stepper,
Text,
TextInput,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
IconCloudCheck,
IconPlug,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { useQueryClient } from "@tanstack/react-query";
import {
listConfluenceSpaces,
startConfluenceImport,
testConfluenceConnection,
} from "@/ee/confluence-import/services/confluence-import-service";
import {
ConfluenceAuthType,
ConfluenceCredentials,
ConfluenceSpaceSummary,
} from "@/ee/confluence-import/types/confluence-import.types";
import { confluenceImportsQueryKey } from "@/ee/confluence-import/queries/confluence-import-queries";
type ConfluenceEditionChoice = "cloud" | "server";
type CredentialsFormValues = {
edition: ConfluenceEditionChoice;
authType: ConfluenceAuthType;
siteUrl: string;
email: string;
token: string;
username: string;
password: string;
};
type Props = {
opened: boolean;
onClose: () => void;
};
export default function ConfluenceImportModal({ opened, onClose }: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [spaces, setSpaces] = useState<ConfluenceSpaceSummary[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [importAll, setImportAll] = useState(true);
const form = useForm<CredentialsFormValues>({
initialValues: {
edition: "server",
authType: "pat",
siteUrl: "",
email: "",
token: "",
username: "",
password: "",
},
validate: {
siteUrl: (value) =>
!value?.trim()
? t("Site URL is required")
: !/^https?:\/\//i.test(value.trim())
? t("Site URL must start with http:// or https://")
: null,
email: (value, values) =>
values.edition === "cloud" && !value?.trim()
? t("Email is required")
: null,
token: (value, values) =>
(values.authType === "cloud_token" || values.authType === "pat") &&
!value?.trim()
? t("API token is required")
: null,
username: (value, values) =>
values.authType === "basic" && !value?.trim()
? t("Username is required")
: null,
password: (value, values) =>
values.authType === "basic" && !value?.trim()
? t("Password is required")
: null,
},
});
useEffect(() => {
if (!opened) {
setActive(0);
setError(null);
setSpaces([]);
setSelectedKeys([]);
setImportAll(true);
setLoading(false);
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened]);
const credentials: ConfluenceCredentials = useMemo(() => {
const values = form.values;
return {
siteUrl: values.siteUrl.trim().replace(/\/+$/, ""),
authType: values.authType,
email: values.email?.trim() || undefined,
token: values.token?.trim() || undefined,
username: values.username?.trim() || undefined,
password: values.password || undefined,
};
}, [form.values]);
const handleEditionChange = (edition: ConfluenceEditionChoice) => {
form.setFieldValue("edition", edition);
if (edition === "cloud") {
form.setFieldValue("authType", "cloud_token");
} else if (form.values.authType === "cloud_token") {
form.setFieldValue("authType", "pat");
}
};
const handleNextFromCredentials = async () => {
if ((await form.validate()).hasErrors) return;
setLoading(true);
setError(null);
try {
const test = await testConfluenceConnection(credentials);
if (!test.success) {
setError(test.error || t("Connection failed"));
return;
}
const list = await listConfluenceSpaces(credentials);
if (!list.success || !list.spaces) {
setError(list.error || t("Failed to load spaces"));
return;
}
setSpaces(list.spaces);
setSelectedKeys(list.spaces.map((s) => s.key));
setImportAll(true);
setActive(1);
} catch (err: any) {
setError(
err?.response?.data?.message || err?.message || t("Unexpected error"),
);
} finally {
setLoading(false);
}
};
const toggleSpace = (key: string, checked: boolean) => {
setSelectedKeys((prev) =>
checked
? Array.from(new Set([...prev, key]))
: prev.filter((k) => k !== key),
);
};
const toggleAll = (checked: boolean) => {
setImportAll(checked);
setSelectedKeys(checked ? spaces.map((s) => s.key) : []);
};
const handleStartImport = async () => {
const spaceKeys = importAll ? [] : selectedKeys;
if (!importAll && spaceKeys.length === 0) {
setError(t("Select at least one space to import"));
return;
}
setLoading(true);
setError(null);
try {
const result = await startConfluenceImport({
...credentials,
spaceKeys,
});
if (!result.success || !result.fileTaskId) {
setError(result.error || t("Failed to start import"));
setLoading(false);
return;
}
await queryClient.invalidateQueries({
queryKey: confluenceImportsQueryKey,
});
notifications.show({
title: t("Confluence import started"),
message: t("Track progress below. This runs in the background."),
color: "blue",
icon: <IconCheck size={18} />,
autoClose: 4000,
});
onClose();
} catch (err: any) {
setError(
err?.response?.data?.message || err?.message || t("Unexpected error"),
);
setLoading(false);
}
};
const handleCancelFlow = async () => {
onClose();
};
const editionSegment = (
<SegmentedControl
value={form.values.edition}
onChange={(val) => handleEditionChange(val as ConfluenceEditionChoice)}
data={[
{ value: "server", label: t("Data Center / Server") },
{ value: "cloud", label: t("Cloud") },
]}
fullWidth
/>
);
const authTypeSegment = form.values.edition === "server" && (
<SegmentedControl
value={form.values.authType}
onChange={(val) =>
form.setFieldValue("authType", val as ConfluenceAuthType)
}
data={[
{ value: "pat", label: t("Personal Access Token") },
{ value: "basic", label: t("Username + password") },
]}
fullWidth
/>
);
const selectedCount = importAll ? spaces.length : selectedKeys.length;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Import from Confluence")}
size={720}
centered
closeOnClickOutside={!loading}
closeOnEscape={!loading}
>
<Stepper active={active} size="sm" mb="md" allowNextStepsSelect={false}>
<Stepper.Step
label={t("Connect")}
description={t("Credentials")}
icon={<IconPlug size={18} />}
/>
<Stepper.Step
label={t("Select spaces")}
description={t("Choose what to import")}
icon={<IconCloudCheck size={18} />}
/>
</Stepper>
{active === 0 && (
<Stack>
<Text size="sm" c="dimmed">
{t(
"Enter your Confluence URL and credentials. We'll validate the connection before continuing.",
)}
</Text>
{editionSegment}
{authTypeSegment}
<TextInput
label={t("Site URL")}
placeholder={
form.values.edition === "cloud"
? "https://your-site.atlassian.net/wiki"
: "https://confluence.example.com"
}
required
{...form.getInputProps("siteUrl")}
/>
{form.values.edition === "cloud" && (
<>
<TextInput
label={t("Email")}
placeholder="you@company.com"
required
{...form.getInputProps("email")}
/>
<PasswordInput
label={t("API token")}
description={t(
"Create at id.atlassian.com/manage-profile/security/api-tokens",
)}
required
{...form.getInputProps("token")}
/>
</>
)}
{form.values.edition === "server" &&
form.values.authType === "pat" && (
<>
<TextInput
label={t("Email")}
placeholder="you@company.com"
{...form.getInputProps("email")}
/>
<PasswordInput
label={t("Personal Access Token")}
required
{...form.getInputProps("token")}
/>
</>
)}
{form.values.edition === "server" &&
form.values.authType === "basic" && (
<>
<TextInput
label={t("Username")}
required
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("Password")}
required
{...form.getInputProps("password")}
/>
<TextInput
label={t("Email (optional)")}
placeholder="you@company.com"
{...form.getInputProps("email")}
/>
</>
)}
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button
variant="default"
onClick={handleCancelFlow}
disabled={loading}
>
{t("Cancel")}
</Button>
<Button onClick={handleNextFromCredentials} loading={loading}>
{t("Test & continue")}
</Button>
</Group>
</Stack>
)}
{active === 1 && (
<Stack>
<Text size="sm" c="dimmed">
{t(
"Pages, comments, page labels, users, groups, spaces and permissions will be imported.",
)}
</Text>
<Checkbox
label={t("Import all spaces ({{count}})", {
count: spaces.length,
})}
checked={importAll}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
<ScrollArea h={320} type="auto" offsetScrollbars>
<Stack gap="xs">
{spaces.map((space) => (
<Checkbox
key={space.id}
label={
<Group gap={6} wrap="nowrap">
<Text fw={500}>{space.name}</Text>
<Text size="xs" c="dimmed">
({space.key})
</Text>
</Group>
}
checked={importAll || selectedKeys.includes(space.key)}
disabled={importAll}
onChange={(e) =>
toggleSpace(space.key, e.currentTarget.checked)
}
/>
))}
{spaces.length === 0 && (
<Text c="dimmed" ta="center" py="lg">
{t("No spaces found for this account.")}
</Text>
)}
</Stack>
</ScrollArea>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("{{count}} selected", { count: selectedCount })}
</Text>
<Group>
<Button
variant="default"
onClick={() => setActive(0)}
disabled={loading}
>
{t("Back")}
</Button>
<Button
onClick={handleStartImport}
loading={loading}
disabled={!importAll && selectedKeys.length === 0}
>
{t("Start import")}
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -1,67 +0,0 @@
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import {
Button,
Divider,
Group,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import SettingsTitle from "@/components/settings/settings-title";
import { ConfluenceIcon } from "@/components/icons/confluence-icon";
import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal";
import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history";
import { getAppName } from "@/lib/config";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function ConfluenceImportPage() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_API_IMPORT);
const upgradeLabel = useUpgradeLabel();
return (
<>
<Helmet>
<title>
{t("Import from Confluence")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Import from Confluence")} />
<Paper withBorder p="lg" radius="md" mb="lg">
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group align="flex-start" wrap="nowrap">
<ConfluenceIcon size={32} />
<Stack gap={4}>
<Text fw={600}>{t("Confluence API import")}</Text>
<Text size="sm" c="dimmed" maw={560}>
{t(
"Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.",
)}
</Text>
</Stack>
</Group>
<Tooltip label={upgradeLabel} disabled={hasConfluenceImport}>
<Button onClick={open} disabled={!hasConfluenceImport}>
{t("Start import")}
</Button>
</Tooltip>
</Group>
</Paper>
<Divider my="md" label={t("Import history")} labelPosition="left" />
<ConfluenceImportHistory />
<ConfluenceImportModal opened={opened} onClose={close} />
</>
);
}
@@ -1,17 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service";
export const confluenceImportsQueryKey = ["confluence-imports"] as const;
export function useConfluenceImportsQuery() {
return useQuery({
queryKey: confluenceImportsQueryKey,
queryFn: listConfluenceImports,
refetchInterval: (query) => {
const hasRunning = query.state.data?.items?.some(
(i) => i.status === "processing",
);
return hasRunning ? 3000 : false;
},
});
}
@@ -1,64 +0,0 @@
import api from "@/lib/api-client";
import {
ConfluenceCredentials,
ImportStatusResponse,
ListImportsResponse,
ListSpacesResponse,
StartImportResponse,
TestConnectionResponse,
} from "@/ee/confluence-import/types/confluence-import.types";
export async function testConfluenceConnection(
data: ConfluenceCredentials,
): Promise<TestConnectionResponse> {
const req = await api.post<TestConnectionResponse>(
"/confluence-import/test-connection",
data,
);
return req.data;
}
export async function listConfluenceSpaces(
data: ConfluenceCredentials,
): Promise<ListSpacesResponse> {
const req = await api.post<ListSpacesResponse>(
"/confluence-import/spaces",
data,
);
return req.data;
}
export async function startConfluenceImport(
data: ConfluenceCredentials & { spaceKeys?: string[] },
): Promise<StartImportResponse> {
const req = await api.post<StartImportResponse>(
"/confluence-import/start",
data,
);
return req.data;
}
export async function getConfluenceImportStatus(
fileTaskId: string,
): Promise<ImportStatusResponse> {
const req = await api.post<ImportStatusResponse>(
"/confluence-import/status",
{ fileTaskId },
);
return req.data;
}
export async function listConfluenceImports(): Promise<ListImportsResponse> {
const req = await api.post<ListImportsResponse>("/confluence-import/history");
return req.data;
}
export async function cancelConfluenceImport(
fileTaskId: string,
): Promise<{ success: boolean }> {
const req = await api.post<{ success: boolean }>(
"/confluence-import/cancel",
{ fileTaskId },
);
return req.data;
}
@@ -1,92 +0,0 @@
export type ConfluenceAuthType = "cloud_token" | "pat" | "basic";
export type ConfluenceCredentials = {
siteUrl: string;
authType: ConfluenceAuthType;
email?: string;
token?: string;
username?: string;
password?: string;
};
export type ConfluenceSpaceSummary = {
id: string;
key: string;
name: string;
type?: string;
status?: string;
};
export type TestConnectionResponse = {
success: boolean;
edition?: string;
spaceCount?: number;
error?: string;
};
export type ListSpacesResponse = {
success: boolean;
spaces?: ConfluenceSpaceSummary[];
error?: string;
};
export type StartImportResponse = {
success: boolean;
fileTaskId?: string;
error?: string;
};
export type ConfluenceImportStatus = "processing" | "success" | "failed";
export type ImportStatusResponse = {
fileTaskId?: string;
status?: ConfluenceImportStatus;
errorMessage?: string | null;
currentPhase?: string | null;
totalSpaces?: number;
importedSpaces?: number;
totalPages?: number;
importedPages?: number;
totalUsers?: number;
importedUsers?: number;
totalGroups?: number;
importedGroups?: number;
totalRestrictedPages?: number;
importedRestrictedPages?: number;
createdAt?: string;
updatedAt?: string;
error?: string;
};
export type ConfluenceImportHistoryItem = {
fileTaskId: string;
siteUrl: string;
status: ConfluenceImportStatus;
errorMessage: string | null;
currentPhase: string | null;
totalSpaces: number;
importedSpaces: number;
totalPages: number;
importedPages: number;
totalUsers: number;
importedUsers: number;
totalGroups: number;
importedGroups: number;
totalAttachments: number;
importedAttachments: number;
totalLabels: number;
importedLabels: number;
totalRestrictedPages: number;
importedRestrictedPages: number;
cancelled: boolean;
spaceKeys: string[];
createdAt: string;
updatedAt: string;
creatorId: string | null;
creatorName: string | null;
creatorAvatarUrl: string | null;
};
export type ListImportsResponse = {
items: ConfluenceImportHistoryItem[];
};
-1
View File
@@ -7,7 +7,6 @@ export const Feature = {
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
CONFLUENCE_API_IMPORT: 'import:confluence-api',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -7,7 +9,6 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
@@ -20,7 +21,9 @@ export default function License() {
return (
<>
<DocumentTitle title="License" />
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" />
<ActivateLicenseForm />
@@ -53,14 +53,7 @@ export function PagePermissionItem({
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user"
? member.email
: member.isDefault
? // Page access still requires space membership, so the
// workspace-wide member count would overstate who can
// actually see the page.
t("Everyone with access to this space")
: formatMemberCount(member.memberCount, t)}
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
@@ -1,16 +1,17 @@
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";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() {
const { t } = useTranslation();
@@ -67,7 +68,11 @@ export default function VerifiedPages() {
return (
<>
<DocumentTitle title={t("Verified pages")} />
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Verified pages")} />
+7 -2
View File
@@ -1,13 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Login")} />
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm />
</>
@@ -1,11 +1,14 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { getAppName } from "@/lib/config.ts";
export default function CreateWorkspace() {
return (
<>
<DocumentTitle title="Create Workspace" />
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm />
</>
);
@@ -1,4 +1,5 @@
import { isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {
Alert,
@@ -36,7 +37,6 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5;
@@ -64,7 +64,9 @@ export default function Security() {
return (
<>
<DocumentTitle title="Security" />
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} />
<EnforceMfa />
@@ -41,7 +41,6 @@ export default function ReadonlyTemplateEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={template.content}
/>
@@ -22,6 +22,8 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
@@ -42,7 +44,6 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() {
const { t } = useTranslation();
@@ -87,7 +88,6 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
textDirection: "auto",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@@ -247,7 +247,11 @@ export default function TemplateEditor() {
return (
<>
<DocumentTitle title={t("Edit template")} />
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
@@ -13,9 +13,11 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
@@ -29,7 +31,6 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() {
const { t } = useTranslation();
@@ -101,7 +102,11 @@ export default function TemplateList() {
return (
<>
<DocumentTitle title={t("Templates")} />
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
@@ -1,59 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -1,25 +0,0 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,17 +3,7 @@ import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon(
file: File,
@@ -1,5 +1,4 @@
export {
getPageAttachments,
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
@@ -15,15 +15,6 @@ export interface IAttachment {
deletedAt: string | null;
}
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
@@ -166,18 +166,6 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
try {
if (typeof indexedDB?.databases === "function") {
const dbs = await indexedDB.databases();
dbs
.filter((db) => db.name?.startsWith("page."))
.forEach((db) => indexedDB.deleteDatabase(db.name!));
}
} catch {
//
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
@@ -11,7 +11,6 @@ import {
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
@@ -171,16 +170,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
</div>
</Group>
<CommentErrorBoundary>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</CommentErrorBoundary>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</Stack>
</Dialog>
);
@@ -1,8 +1,7 @@
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { StarterKit } from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Mention, LinkExtension, Color } from "@docmost/editor-ext";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
@@ -12,8 +11,6 @@ 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 { platformModifierKey } from "@/lib";
import { TableKit } from "@tiptap/extension-table";
import { TaskList, TaskItem } from "@tiptap/extension-list";
interface CommentEditorProps {
defaultContent?: any;
@@ -52,8 +49,6 @@ const CommentEditor = forwardRef(
placeholder: placeholder || t("Reply..."),
}),
LinkExtension,
TextStyle,
Color,
EmojiCommand,
Mention.configure({
suggestion: {
@@ -71,11 +66,6 @@ const CommentEditor = forwardRef(
return ReactNodeViewRenderer(MentionView);
},
}),
TableKit,
TaskList,
TaskItem.configure({
nested: true,
}),
],
editorProps: {
attributes: {
@@ -113,7 +103,6 @@ const CommentEditor = forwardRef(
},
content: defaultContent,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false,
@@ -123,12 +112,7 @@ const CommentEditor = forwardRef(
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
if (
!editable &&
commentEditor &&
!commentEditor.isDestroyed &&
defaultContent
) {
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
@@ -1,38 +0,0 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@/components/ui/empty-state.tsx";
type CommentErrorBoundaryProps = {
children: ReactNode;
};
// Contain comment-editor render throws (e.g. schema errors) so they don't unmount the whole app.
export function CommentErrorBoundary({ children }: CommentErrorBoundaryProps) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load comments. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
{children}
</ErrorBoundary>
);
}
@@ -53,10 +53,6 @@
margin-block-end: 0;
}
.ProseMirror :global(.tableWrapper) table {
min-width: 100% !important;
}
.actions {
}
@@ -12,7 +12,6 @@ import {
IconMathFunction,
IconRotate2,
IconSitemap,
IconSuperscript,
IconTable,
IconTag,
} from "@tabler/icons-react";
@@ -271,12 +270,6 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
>
{t("Math block")}
</Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -30,7 +30,6 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -178,16 +177,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -40,7 +40,6 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content as any}
/>
@@ -1,15 +1,14 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand, {
SlashCommandExtension as Command,
@@ -53,7 +52,6 @@ import {
Subpages,
Heading,
Highlight,
Color,
Indent,
UniqueID,
SharedStorage,
@@ -64,9 +62,6 @@ import {
TransclusionReference,
TableView,
BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -136,7 +131,6 @@ lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
document: false,
heading: false,
undoRedo: false,
link: false,
@@ -148,9 +142,6 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -203,19 +194,16 @@ export const mainExtensions = [
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
const doc = editor.state.doc;
if (pos >= 0 && pos <= doc.content.size) {
const parentName = doc.resolve(pos).parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote" ||
parentName === "footnote"
) {
return i18n.t("Write...");
}
const $pos = editor.state.doc.resolve(pos);
const parentName = $pos.parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
}
return i18n.t('Write anything. Enter "/" for commands');
}
@@ -426,9 +414,6 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({
elementsToJoin: [],
}),
@@ -1,64 +0,0 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -249,7 +249,6 @@ function CollabPageEditor({
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -485,7 +484,6 @@ function StaticPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
@@ -85,7 +85,6 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions}
content={title}
></EditorProvider>
@@ -94,7 +93,6 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
onCreate={({ editor }) => {
@@ -1,26 +0,0 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -1,46 +1,6 @@
/* Highlight colors with dark mode support */
.ProseMirror {
@mixin dark {
/* Arbitrary (imported) colors have no hand-tuned dark variant, so derive
one: cap lightness and chroma so the fill sits on the dark surface.
Our own palette opts out and keeps the values below. */
mark[data-color]:not(
[data-color="#98d8f2" i],
[data-color="#7edb6c" i],
[data-color="#e0d6ed" i],
[data-color="#ffc6c2" i],
[data-color="#faf594" i],
[data-color="#f5c8a9" i],
[data-color="#f5cfe0" i],
[data-color="#dfdfd7" i],
[data-color="#d7c4b7" i]
) {
background-color: oklch(
from var(--mark-bg, #fff) min(l, clamp(0.28, calc(1.22 - l), 0.45))
min(calc(c * 1.8), 0.09) h
) !important;
}
/* Imported text colors are picked for a light page and go unreadable on
the dark surface, so lift their lightness. */
span[data-text-color]:not(
[data-text-color="#2563EB" i],
[data-text-color="#008A00" i],
[data-text-color="#9333EA" i],
[data-text-color="#E00000" i],
[data-text-color="#EAB308" i],
[data-text-color="#FFA500" i],
[data-text-color="#BA4081" i],
[data-text-color="#A8A29E" i],
[data-text-color="#92400E" i]
) {
color: oklch(
from var(--text-color, currentcolor) max(l, 0.72) min(c, 0.16) h
) !important;
}
}
/* Blue */
mark[data-color="#98d8f2"] {
background-color: light-dark(
@@ -18,4 +18,3 @@
@import "./columns.css";
@import "./status.css";
@import "./base-embed.css";
@import "./footnotes.css";
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: start;
text-align: left;
}
.column-resize-handle {
@@ -86,16 +86,6 @@
.ProseMirror {
table {
@mixin dark {
/* Arbitrary (imported) colors: derive a dark variant; the hand-tuned
palette rules below override this for native colors. */
td[data-background-color],
th[data-background-color] {
background-color: oklch(
from var(--cell-bg, #fff) min(l, clamp(0.28, calc(1.22 - l), 0.45))
min(calc(c * 1.8), 0.09) h
) !important;
}
/* Blue */
td[data-background-color="#b4d5ff"],
th[data-background-color="#b4d5ff"] {
@@ -86,7 +86,6 @@ export function TitleEditor({
},
editable: editable,
content: title,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -1,7 +1,7 @@
.history {
display: flex;
align-items: center;
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
@@ -12,28 +12,6 @@
}
}
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
@@ -99,8 +77,3 @@
flex: 1;
padding: rem(16px) rem(40px);
}
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -31,7 +31,6 @@ export function HistoryEditor({
const editor = useEditor({
extensions: mainExtensions,
editable: false,
textDirection: "auto",
});
useEffect(() => {
@@ -171,6 +170,7 @@ export function HistoryEditor({
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
@@ -1,21 +1,10 @@
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5;
@@ -26,13 +15,6 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
}
const HistoryItem = memo(function HistoryItem({
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
onHover,
onHoverEnd,
isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => {
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
@@ -69,115 +37,63 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0;
return (
<div
className={clsx(classes.history, { [classes.active]: isActive })}
<UnstyledButton
p="xs"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<UnstyledButton
p="xs"
onClick={handleClick}
className={classes.historyButton}
>
<Text size="sm">{date}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors
.slice(0, MAX_VISIBLE_AVATARS)
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
{contributors[0].name}
</Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</div>
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
);
});
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const {
data: pageHistoryData,
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback(
(id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -213,44 +148,22 @@ function HistoryList({ pageId }: Props) {
)}
</ScrollArea>
{compareMode ? (
{canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={handleCancelCompare}
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group>
</>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)}
</div>
);
@@ -1,6 +1,5 @@
import {
ActionIcon,
CloseButton,
Group,
Paper,
ScrollArea,
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useMemo, useRef } from "react";
import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props {
pageId: string;
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav>
<div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea
h={650}
w="100%"
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
@@ -7,29 +7,21 @@ import {
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
function HistoryView() {
const { t } = useTranslation();
const activeId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(resolvedId);
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(resolvedPrevId);
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
@@ -3,45 +3,22 @@ import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -1,4 +1,4 @@
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
@@ -9,8 +9,7 @@ import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -39,66 +40,47 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(
async (historyId: string) => {
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditorTitle
.chain()
.clearContent()
.setContent(historyData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
mainEditor
.chain()
.clearContent()
.setContent(historyData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
},
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
});
}
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery(
pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -1,18 +0,0 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,7 +11,6 @@ import {
IconList,
IconMarkdown,
IconMessage,
IconPaperclip,
IconPrinter,
IconStar,
IconStarFilled,
@@ -43,7 +42,6 @@ import {
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
@@ -159,10 +157,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -299,15 +293,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
@@ -410,12 +395,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened}
onClose={closeVerificationModal}
/>
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</>
);
}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
width?: number;
opened?: boolean;
clearable?: boolean;
withinPortal?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -42,7 +41,6 @@ export function SpaceSelect({
width,
opened,
clearable,
withinPortal = true,
}: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
@@ -52,13 +50,9 @@ export function SpaceSelect({
limit: 50,
});
const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => {
if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
@@ -89,19 +83,14 @@ export function SpaceSelect({
onSearchChange={setSearchValue}
clearable={clearable}
variant="filled"
onChange={(slug) => {
// options accumulate across fetches; resolve against everything
// fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);
@@ -70,7 +70,6 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
withinPortal={false}
/>
</Popover.Dropdown>
</Popover>
-4
View File
@@ -43,10 +43,6 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function isBetaConfluenceImporter(): boolean {
return castToBoolean(getConfigValue("BETA_CONFLUENCE_IMPORTER"));
}
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
@@ -1,10 +1,13 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
export default function ForgotPassword() {
return (
<>
<DocumentTitle title="Forgot Password" />
<Helmet>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<ForgotPasswordForm />
</>
);
+5 -2
View File
@@ -1,13 +1,16 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Invitation Signup")} />
<Helmet>
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<InviteSignUpForm />
</>
);
+7 -2
View File
@@ -1,13 +1,18 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Login")} />
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<LoginForm />
</>
);
+12 -3
View File
@@ -1,10 +1,11 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function PasswordReset() {
const { t } = useTranslation();
@@ -22,7 +23,11 @@ export default function PasswordReset() {
if (isError || !resetToken) {
return (
<>
<DocumentTitle title={t("Password Reset")} />
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid or expired password reset link")}
@@ -44,7 +49,11 @@ export default function PasswordReset() {
return (
<>
<DocumentTitle title={t("Password Reset")} />
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<PasswordResetForm resetToken={resetToken} />
</>
);
@@ -1,10 +1,11 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SetupWorkspace() {
const { t } = useTranslation();
@@ -34,7 +35,11 @@ export default function SetupWorkspace() {
) {
return (
<>
<DocumentTitle title={t("Setup Workspace")} />
<Helmet>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<SetupWorkspaceForm />
</>
);
+7 -2
View File
@@ -2,15 +2,20 @@ import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Home() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Home")} />
<Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<Container size={"900"} pt="xl">
<HomeAiPrompt />
+7 -2
View File
@@ -17,7 +17,9 @@ import {
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
@@ -27,7 +29,6 @@ import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LabelPage() {
const { t } = useTranslation();
@@ -81,7 +82,11 @@ export default function LabelPage() {
return (
<>
<DocumentTitle title={labelName} />
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<Container size={820} py="xl">
<Stack gap="lg">
+7 -9
View File
@@ -3,6 +3,7 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor";
import { TitleEditor } from "@/features/editor/title-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -17,7 +18,6 @@ import { BaseView } from "@/ee/base/components/base-view";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { getPageTitle } from "@/features/page/page.utils";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -110,10 +110,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
paddingTop: "calc(var(--page-header-height) + 6px)",
}}
>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<MemoizedPageHeader readOnly={!canEdit} />
<div
style={{
@@ -160,10 +159,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
return (
page && (
<div>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<MemoizedPageHeader readOnly={!canEdit} />
@@ -5,16 +5,21 @@ import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountPreferences() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Preferences")} />
<Helmet>
<title>
{t("Preferences")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Preferences")} />
<AccountTheme />
@@ -4,17 +4,22 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountSettings() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("My Profile")} />
<Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />
@@ -1,15 +1,20 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function GroupInfo() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Manage Group")} />
<Helmet>
<title>
{t("Manage Group")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Manage Group")} />
<GroupDetails />
<GroupMembersList />
@@ -3,8 +3,9 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Groups() {
const { t } = useTranslation();
@@ -12,7 +13,9 @@ export default function Groups() {
return (
<>
<DocumentTitle title={t("Groups")} />
<Helmet>
<title>{t("Groups")} - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end">
@@ -1,17 +1,22 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Shares() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("Public sharing")} />
<Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
@@ -3,8 +3,9 @@ import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -12,7 +13,11 @@ export default function Spaces() {
return (
<>
<DocumentTitle title={t("Spaces")} />
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Spaces")} />
<Group my="md" justify="flex-end">
@@ -6,10 +6,11 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceMembers() {
const { t } = useTranslation();
@@ -37,7 +38,11 @@ export default function WorkspaceMembers() {
return (
<>
<DocumentTitle title={t("Members")} />
<Helmet>
<title>
{t("Members")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */}
@@ -2,19 +2,21 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title="Workspace Settings" />
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm />
+4 -6
View File
@@ -1,4 +1,5 @@
import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core";
@@ -13,7 +14,6 @@ import {
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SharedPage() {
const { t } = useTranslation();
@@ -56,14 +56,12 @@ export default function SharedPage() {
return (
<div>
<DocumentTitle
title={data?.page?.title || t("untitled")}
withAppName={false}
>
<Helmet>
<title>{`${data?.page?.title || t("untitled")}`}</title>
{!data?.share.searchIndexing && (
<meta name="robots" content="noindex" />
)}
</DocumentTitle>
</Helmet>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor
+5 -2
View File
@@ -2,7 +2,8 @@ import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function SpaceHome() {
const {spaceSlug} = useParams();
@@ -10,7 +11,9 @@ export default function SpaceHome() {
return (
<>
<DocumentTitle title={space?.name || 'Overview'} />
<Helmet>
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>}
</Container>
+7 -2
View File
@@ -1,12 +1,13 @@
import { Container, Title, Text, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import CreateSpaceModal from "@/features/space/components/create-space-modal";
import { AllSpacesList } from "@/features/space/components/spaces-page";
import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -21,7 +22,11 @@ export default function Spaces() {
return (
<>
<DocumentTitle title={t("Spaces")} />
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<Container size={"800"} pt="xl">
<Group justify="space-between" mb="xl">
-3
View File
@@ -16,7 +16,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
BETA_CONFLUENCE_IMPORTER,
} = loadEnv(mode, envPath, "");
return {
@@ -32,7 +31,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
BETA_CONFLUENCE_IMPORTER
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},
@@ -57,7 +55,6 @@ export default defineConfig(({ mode }) => {
},
},
server: {
allowedHosts: ['docmost.nz'],
proxy: {
"/api": {
target: APP_URL,
+14 -15
View File
@@ -40,30 +40,30 @@
"@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@keyv/redis": "5.1.6",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@modelcontextprotocol/sdk": "1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.28",
"@nestjs/common": "11.1.27",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.1.28",
"@nestjs/platform-socket.io": "11.1.28",
"@nestjs/platform-fastify": "11.1.27",
"@nestjs/platform-socket.io": "11.1.27",
"@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.1.28",
"@nestjs/websockets": "11.1.27",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"ai": "6.0.134",
@@ -77,7 +77,6 @@
"class-validator": "0.15.1",
"cookie": "1.1.1",
"csv-stringify": "6.8.0",
"entities": "7.0.1",
"fast-bm25": "0.0.5",
"fastify-ip": "2.0.0",
"fs-extra": "11.3.4",
@@ -91,8 +90,8 @@
"ldapts": "8.1.7",
"mammoth": "1.12.0",
"mime-types": "3.0.2",
"msgpackr": "1.11.9",
"nanoid": "5.1.16",
"msgpackr": "^1.11.9",
"nanoid": "5.1.7",
"nestjs-cls": "6.2.0",
"nestjs-kysely": "3.1.2",
"nestjs-pino": "4.6.1",
@@ -103,7 +102,7 @@
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"pg-tsquery": "8.4.2",
"pgvector": "0.2.1",
"pgvector": "^0.2.1",
"pino-http": "11.0.0",
"pino-pretty": "13.1.3",
"postgres": "3.4.8",
@@ -119,7 +118,7 @@
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"typesense": "3.0.5",
"undici": "7.29.0",
"undici": "7.28.0",
"ws": "8.21.0",
"yauzl": "3.4.0",
"zod": "4.3.6"
-4
View File
@@ -17,7 +17,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
import { ImportProcessorModule } from './integrations/import/import-processor.module';
import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
@@ -28,7 +27,6 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
try {
@@ -55,7 +53,6 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -77,7 +74,6 @@ try {
StaticModule,
HealthModule,
ImportModule,
ImportProcessorModule,
ExportModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
@@ -1,10 +1,10 @@
import { StarterKit } from '@tiptap/starter-kit';
import { Document } from '@tiptap/extension-document';
import { TextAlign } from '@tiptap/extension-text-align';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import {
@@ -35,7 +35,6 @@ import {
Mention,
Subpages,
Highlight,
Color,
Indent,
UniqueID,
Columns,
@@ -46,16 +45,8 @@ import {
TransclusionSource,
TransclusionReference,
BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
} from '@docmost/editor-ext';
import {
extensions as coreExtensions,
generateText,
getSchema,
JSONContent,
} from '@tiptap/core';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
@@ -66,17 +57,12 @@ import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
coreExtensions.TextDirection.configure({ direction: 'auto' }),
StarterKit.configure({
document: false,
codeBlock: false,
link: false,
trailingNode: false,
heading: false,
}),
Document.extend({
content: 'block+ footnotes?',
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph', 'transclusionSource'],
@@ -124,10 +110,7 @@ export const tiptapExtensions = [
Status,
TransclusionSource,
TransclusionReference,
BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
BaseEmbed
] as any;
export function jsonToHtml(tiptapJson: any) {
-1
View File
@@ -7,7 +7,6 @@ export const Feature = {
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
CONFLUENCE_API_IMPORT: 'import:confluence-api',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
@@ -53,14 +53,8 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import {
AttachmentInfoDto,
PageIdDto,
RemoveIconDto,
} from './dto/attachment.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
import { DomainService } from '../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
@@ -81,7 +75,6 @@ export class AttachmentController {
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
private readonly domainService: DomainService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -158,10 +151,7 @@ export class AttachmentController {
},
});
return res.send({
...fileResponse,
url: this.buildFileUrl(workspace, fileResponse),
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
@@ -421,37 +411,7 @@ export class AttachmentController {
await this.pageAccessService.validateCanView(page, user);
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/attachments')
async getPageAttachments(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const result = await this.attachmentRepo.findPageAttachments(
page.id,
pagination,
);
return {
...result,
items: result.items.map((attachment) => ({
...attachment,
url: this.buildFileUrl(workspace, attachment),
})),
};
return attachment;
}
@UseGuards(JwtAuthGuard)
@@ -505,10 +465,6 @@ export class AttachmentController {
}
}
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
}
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
@@ -1,11 +1,4 @@
import {
IsEnum,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@@ -14,12 +7,6 @@ export class AttachmentInfoDto {
attachmentId: string;
}
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
@@ -175,7 +175,44 @@ export class PageService {
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
return this.pageRepo.nextPagePosition(spaceId, parentPageId);
let pagePosition: string;
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
if (parentPageId) {
// check for children of this page
const lastPage = await lastPageQuery
.where('parentPageId', '=', parentPageId)
.executeTakeFirst();
if (!lastPage) {
pagePosition = generateJitteredKeyBetween(null, null);
} else {
// if there is an existing page, we should get a position below it
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
}
} else {
// for root page
const lastPage = await lastPageQuery
.where('parentPageId', 'is', null)
.executeTakeFirst();
// if no existing page, make this the first
if (!lastPage) {
pagePosition = generateJitteredKeyBetween(null, null); // we expect "a0"
} else {
// if there is an existing page, we should get a position below it
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
}
}
return pagePosition;
}
async update(
@@ -773,10 +810,6 @@ export class PageService {
throw new BadRequestException('Invalid move position');
}
if (dto.parentPageId && dto.parentPageId === dto.pageId) {
throw new BadRequestException('A page cannot be its own parent');
}
let parentPageId = null;
if (movedPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
@@ -1,8 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DB } from '@docmost/db/types/db';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
@@ -11,8 +8,6 @@ import {
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AttachmentRepo {
@@ -94,41 +89,6 @@ export class AttachmentRepo {
.execute();
}
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('attachments')
.select(this.baseFields)
.select((eb) => this.withCreator(eb))
.where('pageId', '=', pageId)
.where('type', '=', AttachmentType.File)
.where('deletedAt', 'is', null);
if (pagination.query) {
query = query.where(
sql`f_unaccent(file_name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'attachments.creatorId'),
).as('creator');
}
async findByIds(
ids: string[],
opts?: {
@@ -16,7 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@Injectable()
export class PageRepo {
@@ -606,27 +605,4 @@ export class PageRepo {
.execute()
);
}
async nextPagePosition(
spaceId: string,
parentPageId?: string,
): Promise<string> {
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
const lastPage = parentPageId
? await lastPageQuery
.where('parentPageId', '=', parentPageId)
.executeTakeFirst()
: await lastPageQuery
.where('parentPageId', 'is', null)
.executeTakeFirst();
return generateJitteredKeyBetween(lastPage?.position ?? null, null);
}
}

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