Compare commits

...

27 Commits

Author SHA1 Message Date
60a8ed6826 sync 2025-10-25 02:08:29 +01:00
f5684b792e fix duplicated page parenting (#1692) 2025-10-23 15:00:11 +01:00
042836cb6d sync 2025-10-07 21:09:55 +01:00
4f1f0ba513 fix 2025-10-07 21:06:59 +01:00
3164b6981c feat: api keys management (EE) (#1665)
* feat: api keys (EE)

* improvements

* fix table

* fix route

* remove token suffix

* api settings

* Fix

* fix

* fix

* fix
2025-10-07 21:05:13 +01:00
16c1e864af fix comment space 2025-10-07 18:44:37 +01:00
c9b1cad982 sync 2025-10-07 18:39:30 +01:00
bf8cf6254f feat: Typesense search driver (EE) (#1664)
* feat: typesense driver (EE) - WIP

* feat: typesense driver (EE) - WIP

* feat: typesense

* sync

* fix
2025-10-07 17:34:32 +01:00
3135030376 fix editor converter (#1647) 2025-09-30 16:07:19 +01:00
3fae41a5ca fix: editor performance improvements (#1648)
* Switch to useEditorState
* change shouldRerenderOnTransaction to false
2025-09-30 14:04:01 +01:00
b50e25600a sync 2025-09-28 16:44:33 +01:00
1f3b0c7276 cloud fix 2025-09-24 21:25:39 +01:00
3c4cab0d2a v0.23.2 2025-09-18 18:00:28 +01:00
4de25a8b94 invalidate queries on space deletion 2025-09-18 15:52:53 +01:00
cf5bbb10df fix import html processing 2025-09-18 15:34:13 +01:00
ac17521717 sync 2025-09-18 13:24:16 +01:00
9ac180f719 fix: enhance page import (#1570)
* change import process

* fix processor

* fix page name in notion import

* preserve confluence table bg color

* sync
2025-09-17 23:50:27 +01:00
46669fea56 (cloud) disable page sharing in trial mode 2025-09-17 23:36:13 +01:00
fe6ecdf1f1 fix: update combobox props in SpaceSelect component (#1564)
Added 'keepMounted: false' and 'dropdownPadding: 0' to comboboxProps for improved dropdown behavior and appearance in the SpaceSelect sidebar component.
2025-09-17 13:36:12 +01:00
04ae1d7270 Allow lastColumnResizable in table 2025-09-15 22:34:29 +01:00
1280f96f37 feat: implement space and workspace icons (#1558)
* feat: implement space and workspace icons
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Add Sharp package for server-side image resizing and optimization
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Support removing icons

* add workspace logo support
- add upload loader
- add white background to transparent image
- other fixes and enhancements

* dark mode

* fixes

* cleanup
2025-09-15 21:11:37 +01:00
61d1cf88a7 fix: reset file inputs after import 2025-09-15 12:52:31 +01:00
f413720e15 - sync
- reinstantiate S3 client to fix file upload errors during import
- delete import zip file after use
2025-09-14 03:00:23 +01:00
8e16ad952a v0.23.1 2025-09-13 03:15:53 +01:00
7ada3cb1f9 fix: page import task (#1551)
* fix import

* - fix notion importer
- support notion page icon import
- fix horizontal rule css
- rename service file

* sync

* 3 mins delay
2025-09-13 03:14:59 +01:00
47c54174b3 sync 2025-09-11 00:50:15 +01:00
dc0650289d sync 2025-09-04 15:07:01 -07:00
126 changed files with 3772 additions and 842 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.23.0",
"version": "0.23.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",

View File

@ -527,5 +527,32 @@
"Delete SSO provider": "Delete SSO provider",
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
"Action": "Action",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration"
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
}

View File

@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
export default function App() {
const { t } = useTranslation();
@ -96,8 +98,10 @@ export default function App() {
path={"account/preferences"}
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />

View File

@ -0,0 +1,165 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
const handleRemove = async () => {
if (disabled) return;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}

View File

@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
text?: string;
}
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation();
return (
<Table.Tr>
<Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center">
{t("No results found...")}
{text || t("No results found...")}
</Text>
</Table.Td>
</Table.Tr>

View File

@ -1,8 +1,8 @@
import {
Group,
Menu,
UnstyledButton,
Text,
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import {
@ -10,7 +10,6 @@ import {
IconBrush,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout,
IconMoon,
@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() {
const { t } = useTranslation();
@ -50,6 +50,7 @@ export default function TopMenu() {
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}

View File

@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};

View File

@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling,
prefetchGroups,
prefetchLicense,
@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
],
},
{
@ -90,6 +100,15 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
],
},
{
@ -195,6 +214,12 @@ export default function SettingsSidebar() {
case "Public sharing":
prefetchHandler = prefetchShares;
break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default:
break;
}

View File

@ -1,6 +1,7 @@
import React from "react";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
@ -11,13 +12,15 @@ interface CustomAvatarProps {
variant?: string;
style?: any;
component?: any;
type?: AvatarIconType;
mt?: string | number;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl);
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
return (
<Avatar

View File

@ -0,0 +1,72 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import CopyTextButton from "@/components/common/copy.tsx";
interface ApiKeyCreatedModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey;
}
export function ApiKeyCreatedModal({
opened,
onClose,
apiKey,
}: ApiKeyCreatedModalProps) {
const { t } = useTranslation();
if (!apiKey) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,143 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
isLoading?: boolean;
showUserColumn?: boolean;
onUpdate?: (apiKey: IApiKey) => void;
onRevoke?: (apiKey: IApiKey) => void;
}
export function ApiKeyTable({
apiKeys,
isLoading,
showUserColumn = false,
onUpdate,
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,153 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
import { IApiKey } from "@/ee/api-key";
const DateInput = lazy(() =>
import("@mantine/dates").then((module) => ({
default: module.DateInput,
})),
);
interface CreateApiKeyModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IApiKey) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
},
});
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined;
}
if (expirationOption === "custom") {
return form.values.expiresAt;
}
const days = parseInt(expirationOption);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return `${days} days (${formatted})`;
};
const expirationOptions = [
{ value: "30", label: getExpirationLabel(30) },
{ value: "60", label: getExpirationLabel(60) },
{ value: "90", label: getExpirationLabel(90) },
{ value: "365", label: getExpirationLabel(365) },
{ value: "custom", label: t("Custom") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = async (data: {
name?: string;
expiresAt?: string | Date;
}) => {
const groupData = {
name: data.name,
expiresAt: getExpirationDate(),
};
try {
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
onSuccess(createdKey);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
setExpirationOption("30");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,62 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
interface RevokeApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function RevokeApiKeyModal({
opened,
onClose,
apiKey,
}: RevokeApiKeyModalProps) {
const { t } = useTranslation();
const revokeApiKeyMutation = useRevokeApiKeyMutation();
const handleRevoke = async () => {
if (!apiKey) return;
await revokeApiKeyMutation.mutateAsync({
apiKeyId: apiKey.id,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,80 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
import { useEffect } from "react";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function UpdateApiKeyModal({
opened,
onClose,
apiKey,
}: UpdateApiKeyModalProps) {
const { t } = useTranslation();
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
});
useEffect(() => {
if (opened && apiKey) {
form.setValues({ name: apiKey.name });
}
}, [opened, apiKey]);
const handleSubmit = async (data: { name?: string }) => {
const apiKeyData = {
apiKeyId: apiKey.id,
name: data.name,
};
await updateApiKeyMutation.mutateAsync(apiKeyData);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,11 @@
export { ApiKeyTable } from "./components/api-key-table";
export { CreateApiKeyModal } from "./components/create-api-key-modal";
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
// Services
export * from "./services/api-key-service";
// Types
export * from "./types/api-key.types";

View File

@ -0,0 +1,106 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { 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";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -0,0 +1,117 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { 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";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
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';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -0,0 +1,97 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createApiKey,
getApiKeys,
IApiKey,
ICreateApiKeyRequest,
IUpdateApiKeyRequest,
revokeApiKey,
updateApiKey,
} from "@/ee/api-key";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetApiKeysQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IApiKey>, Error> {
return useQuery({
queryKey: ["api-key-list", params],
queryFn: () => getApiKeys(params),
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useRevokeApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
Error,
{
apiKeyId: string;
}
>({
mutationFn: (data) => revokeApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
mutationFn: (data) => updateApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -0,0 +1,32 @@
import api from "@/lib/api-client";
import {
ICreateApiKeyRequest,
IApiKey,
IUpdateApiKeyRequest,
} from "@/ee/api-key/types/api-key.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getApiKeys(
params?: QueryParams,
): Promise<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}

View File

@ -0,0 +1,23 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IApiKey {
id: string;
name: string;
token?: string;
creatorId: string;
workspaceId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
creator: Partial<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}

View File

@ -0,0 +1,64 @@
import api from "@/lib/api-client";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.AVATAR);
}
export async function uploadSpaceIcon(
file: File,
spaceId: string,
): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
}
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
}
async function removeIcon(
type: AvatarIconType,
spaceId?: string,
): Promise<void> {
const payload: { spaceId?: string; type: string } = { type };
if (spaceId) {
payload.spaceId = spaceId;
}
await api.post("/attachments/remove-icon", payload);
}
export async function removeAvatar(): Promise<void> {
await removeIcon(AvatarIconType.AVATAR);
}
export async function removeSpaceIcon(spaceId: string): Promise<void> {
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
}
export async function removeWorkspaceIcon(): Promise<void> {
await removeIcon(AvatarIconType.WORKSPACE_ICON);
}

View File

@ -0,0 +1,9 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";

View File

@ -0,0 +1,29 @@
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
WORKSPACE_ICON = "workspace-icon",
}
export enum AttachmentType {
AVATAR = "avatar",
WORKSPACE_ICON = "workspace-icon",
SPACE_ICON = "space-icon",
FILE = "file",
}

View File

@ -3,6 +3,7 @@ import {
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [
{
name: "Bold",
isActive: () => props.editor.isActive("bold"),
isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold,
},
{
name: "Italic",
isActive: () => props.editor.isActive("italic"),
isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic,
},
{
name: "Underline",
isActive: () => props.editor.isActive("underline"),
isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline,
},
{
name: "Strike",
isActive: () => props.editor.isActive("strike"),
isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough,
},
{
name: "Code",
isActive: () => props.editor.isActive("code"),
isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = {
name: "Comment",
isActive: () => props.editor.isActive("comment"),
isActive: () => editorState?.isComment,
command: () => {
const commentId = uuid7();

View File

@ -9,7 +9,8 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem {
@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
}
interface ColorSelectorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -108,12 +109,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen,
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
editorState[`text_${color}`]
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }),
editorState[`highlight_${color}`]
);
return (
@ -151,7 +176,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
justify="left"
fullWidth
rightSection={
editor.isActive("textStyle", { color }) && (
editorState[`text_${color}`] && (
<IconCheck style={{ width: rem(16) }} />
)
}

View File

@ -13,11 +13,12 @@ import {
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [
{
name: "Text",
@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
editorState?.isParagraph &&
!editorState?.isBulletList &&
!editorState?.isOrderedList,
},
{
name: "Heading 1",
icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
isActive: () => editorState?.isHeading1,
},
{
name: "Heading 2",
icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
isActive: () => editorState?.isHeading2,
},
{
name: "Heading 3",
icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
isActive: () => editorState?.isHeading3,
},
{
name: "To-do List",
icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
isActive: () => editorState?.isTaskItem,
},
{
name: "Bullet List",
icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
isActive: () => editorState?.isBulletList,
},
{
name: "Numbered List",
icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
isActive: () => editorState?.isOrderedList,
},
{
name: "Blockquote",
@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: () => editor.isActive("blockquote"),
isActive: () => editorState?.isBlockquote,
},
{
name: "Code",
icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
isActive: () => editorState?.isCodeBlock,
},
];

View File

@ -8,11 +8,12 @@ import {
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TextAlignmentProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [
{
name: "Align left",
isActive: () => editor.isActive({ textAlign: "left" }),
isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
isActive: () => editor.isActive({ textAlign: "center" }),
isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
isActive: () => editor.isActive({ textAlign: "right" }),
isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
{
name: "Justify",
isActive: () => editor.isActive({ textAlign: "justify" }),
isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified,
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return (
<Popover opened={isOpen} withArrow>
@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<IconAlignLeft style={{ width: rem(16) }} stroke={2} />
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Popover.Target>

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
@ -9,7 +10,7 @@ import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu}`}
pluginKey={`callout-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -111,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")}
size="lg"
aria-label={t("Info")}
variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
variant={editorState?.isInfo ? "light" : "default"}
>
<IconInfoCircleFilled size={18} />
</ActionIcon>
@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")}
size="lg"
aria-label={t("Success")}
variant={
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
variant={editorState?.isSuccess ? "light" : "default"}
>
<IconCircleCheckFilled size={18} />
</ActionIcon>
@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")}
size="lg"
aria-label={t("Warning")}
variant={
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
variant={editorState?.isWarning ? "light" : "default"}
>
<IconAlertTriangleFilled size={18} />
</ActionIcon>
@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")}
size="lg"
aria-label={t("Danger")}
variant={
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
variant={editorState?.isDanger ? "light" : "default"}
>
<IconCircleXFilled size={18} />
</ActionIcon>

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false;
}
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor]
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio';
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` });
editor.commands.updateAttributes("drawio", { width: `${value}%` });
},
[editor]
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu}`}
pluginKey={`drawio-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
sticky: "popper",
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{editor.getAttributes('drawio')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>

View File

@ -17,7 +17,7 @@ import {
EventExit,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false;
}
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
return (
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
},
[editor]
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
},
[editor]
[editor],
);
return (
@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
sticky: "popper",
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{editor.getAttributes('excalidraw')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>

View File

@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu}`}
pluginKey={`image-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft}
size="lg"
aria-label={t("Align left")}
variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
variant={editorState?.isAlignLeft ? "light" : "default"}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
variant={
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
variant={editorState?.isAlignCenter ? "light" : "default"}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
variant={editorState?.isAlignRight ? "light" : "default"}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("image")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</BaseBubbleMenu>
);

View File

@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link");
}, [editor]);
const { href: link } = editor.getAttributes("link");
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => {
setShowEdit(true);
@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={link}
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>

View File

@ -9,7 +9,8 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
@ -18,7 +19,7 @@ export interface TableColorItem {
}
interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
}
const TABLE_COLORS: TableColorItem[] = [
@ -38,37 +39,50 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
let currentColor = "";
if (ctx.editor.isActive("tableCell")) {
const attrs = ctx.editor.getAttributes("tableCell");
currentColor = attrs.backgroundColor || "";
} else if (ctx.editor.isActive("tableHeader")) {
const attrs = ctx.editor.getAttributes("tableHeader");
currentColor = attrs.backgroundColor || "";
}
return {
currentColor,
isTableCell: ctx.editor.isActive("tableCell"),
isTableHeader: ctx.editor.isActive("tableHeader"),
};
},
});
if (!editor || !editorState) {
return null;
}
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
backgroundColorName: color ? colorName : null,
})
.updateAttributes("tableHeader", {
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
backgroundColorName: color ? colorName : null,
})
.run();
setOpened(false);
};
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return (
<Popover
width={200}
@ -123,7 +137,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer",
}}
>
{currentColor === item.color && (
{editorState.currentColor === item.color && (
<IconCheck
size={18}
style={{

View File

@ -9,15 +9,15 @@ import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
}
interface AlignmentItem {
@ -32,25 +32,44 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
isActive: () => editor.isActive({ textAlign: "left" }),
isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
isActive: () => editor.isActive({ textAlign: "center" }),
isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
isActive: () => editor.isActive({ textAlign: "right" }),
isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
@ -64,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened}
position="bottom"
withArrow
transitionProps={{ transition: 'pop' }}
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
@ -87,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
item.isActive() && <IconCheck size={16} />
}
rightSection={item.isActive() && <IconCheck size={16} />}
justify="left"
fullWidth
onClick={() => {
@ -106,4 +123,4 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
</Popover.Dropdown>
</Popover>
);
};
};

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu}`}
pluginKey={`video-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -103,9 +123,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft}
size="lg"
aria-label={t("Align left")}
variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
variant={editorState?.isAlignLeft ? "light" : "default"}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter}
size="lg"
aria-label={t("Align center")}
variant={
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
variant={editorState?.isAlignCenter ? "light" : "default"}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight}
size="lg"
aria-label={t("Align right")}
variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
variant={editorState?.isAlignRight ? "light" : "default"}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("video")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</BaseBubbleMenu>
);

View File

@ -165,7 +165,7 @@ export const mainExtensions = [
}),
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
lastColumnResizable: true,
allowTableNodeSelection: true,
}),
TableRow,

View File

@ -7,7 +7,12 @@ import {
onAuthenticationFailedParameters,
WebSocketStatus,
} from "@hocuspocus/provider";
import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
import {
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import {
collabExtensions,
mainExtensions,
@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts';
import { searchSpotlight } from "@/features/search/constants.ts";
interface PageEditorProps {
pageId: string;
@ -77,7 +82,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
@ -213,17 +218,17 @@ export default function PageEditor({
extensions,
editable,
immediatelyRender: true,
shouldRerenderOnTransaction: true,
shouldRerenderOnTransaction: false,
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@ -268,9 +273,16 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider]
[pageId, editable, remoteProvider],
);
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
return ctx.editor?.isEditable ?? false;
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@ -306,7 +318,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent
handleActiveCommentEvent,
);
};
}, []);
@ -389,7 +401,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editor.isEditable && (
{editor && editorIsEditable && (
<div>
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />

View File

@ -94,8 +94,12 @@
hr {
border: none;
border-top: 2px solid #ced4da;
margin: 2rem 0;
@mixin light {
border-top: 1px solid var(--mantine-color-gray-4);
}
@mixin dark {
border-top: 1px solid var(--mantine-color-dark-4);
}
&:hover {
cursor: pointer;

View File

@ -1,11 +1,12 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(50),

View File

@ -4,10 +4,11 @@ import {
useGroupQuery,
useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(50),

View File

@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx";
import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery(
params?: QueryParams,
@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" });
notifications.show({ message: t("Group updated successfully") });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
});
@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" });
notifications.show({ message: t("Group deleted successfully") });
queryClient.refetchQueries({ queryKey: ["groups"] });
},
onError: (error) => {
@ -119,11 +122,12 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" });
notifications.show({ message: t("Added successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
notifications.show({ message: t("Removed successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});

View File

@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
@ -84,6 +84,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit();
const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => {
@ -116,6 +122,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
});
setFileTaskId(importTask.id);
// Reset file input after successful upload
if (source === "notion" && notionFileRef.current) {
notionFileRef.current();
} else if (source === "confluence" && confluenceFileRef.current) {
confluenceFileRef.current();
} else if (source === "generic" && zipFileRef.current) {
zipFileRef.current();
}
} catch (err) {
console.log("Failed to upload import file", err);
notifications.update({
@ -243,6 +258,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree);
}
// Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@ -272,7 +291,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return (
<>
<SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept=".md" multiple>
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
{(props) => (
<Button
justify="start"
@ -285,7 +304,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)}
</FileButton>
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
{(props) => (
<Button
justify="start"
@ -301,6 +320,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip"
resetRef={notionFileRef}
>
{(props) => (
<Button
@ -316,6 +336,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "confluence")}
accept="application/zip"
resetRef={confluenceFileRef}
>
{(props) => (
<Tooltip
@ -352,6 +373,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
<FileButton
onChange={(file) => handleZipUpload(file, "generic")}
accept="application/zip"
resetRef={zipFileRef}
>
{(props) => (
<Group justify="center">

View File

@ -9,10 +9,11 @@ import {
SidebarPagesParams,
} from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);

View File

@ -8,6 +8,7 @@ import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts";
interface SearchSpotlightProps {
spaceId?: string;
@ -43,7 +44,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering
const isAttachmentSearch =
filters.contentType === "attachment" && hasLicenseKey;
filters.contentType === "attachment" && (hasLicenseKey || isCloud());
const resultItems = (searchResults || []).map((result) => (
<SearchResultItem

View File

@ -10,7 +10,7 @@ import {
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
import {
useCreateShareMutation,
@ -18,23 +18,27 @@ import {
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query.ts";
import { Link, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps {
readOnly: boolean;
}
export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug);
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
@ -61,7 +65,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
@ -92,26 +96,29 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
});
};
const shareLink = useMemo(() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
), [publicLink]);
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
return (
<Popover width={350} position="bottom" withArrow shadow="md">
@ -135,7 +142,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
</Button>
</Popover.Target>
<Popover.Dropdown style={{ userSelect: "none" }}>
{isDescendantShared ? (
{isCloud() && isTrial ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center" mb="sm">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button
size="xs"
onClick={() => navigate("/settings/billing")}
fullWidth
>
{t("Upgrade Plan")}
</Button>
</>
) : isDescendantShared ? (
<>
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor

View File

@ -1,10 +1,10 @@
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React, {useMemo} from "react";
import React from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
@ -39,16 +39,18 @@ export default function SpaceSettingsModal({
xOffset={0}
mah={400}
>
<Modal.Overlay/>
<Modal.Content style={{overflow: "hidden"}}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title>
<Text fw={500} lineClamp={1}>{space?.name}</Text>
<Text fw={500} lineClamp={1}>
{space?.name}
</Text>
</Modal.Title>
<Modal.CloseButton/>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<div style={{height: rem(600)}}>
<div style={{ height: rem(600) }}>
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
</Tabs.List>
<Tabs.Panel value="general">
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
<ScrollArea h={550} scrollbarSize={4} pr={8}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="members">
@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id}/>}
) && <AddSpaceMembersModal spaceId={space?.id} />}
</Group>
<SpaceMembersList

View File

@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface SpaceSelectProps {
onChange: (value: ISpace) => void;
@ -16,7 +18,14 @@ interface SpaceSelectProps {
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm" wrap="nowrap">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<CustomAvatar
name={option.label}
avatarUrl={option?.["icon"]}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<div>
<Text size="sm" lineClamp={1}>
{option.label}
@ -50,6 +59,7 @@ export function SpaceSelect({
return {
label: space.name,
value: space.slug,
icon: space.logo,
};
});
@ -76,12 +86,11 @@ export function SpaceSelect({
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
// duct tape
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom" }}
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);

View File

@ -74,7 +74,11 @@ export function SpaceSidebar() {
marginBottom: 3,
}}
>
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
<SwitchSpace
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</div>
<div className={classes.section}>

View File

@ -1,17 +1,25 @@
import classes from './switch-space.module.css';
import { useNavigate } from 'react-router-dom';
import { SpaceSelect } from './space-select';
import { getSpaceUrl } from '@/lib/config';
import { Avatar, Button, Popover, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import classes from "./switch-space.module.css";
import { useNavigate } from "react-router-dom";
import { SpaceSelect } from "./space-select";
import { getSpaceUrl } from "@/lib/config";
import { Button, Popover, Text } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import React from "react";
interface SwitchSpaceProps {
spaceName: string;
spaceSlug: string;
spaceIcon?: string;
}
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
export function SwitchSpace({
spaceName,
spaceSlug,
spaceIcon,
}: SwitchSpaceProps) {
const navigate = useNavigate();
const [opened, { close, open, toggle }] = useDisclosure(false);
@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
color="gray"
onClick={open}
>
<Avatar
size={20}
<CustomAvatar
name={spaceName}
avatarUrl={spaceIcon}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={spaceName}
size={20}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName}
@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={space => handleSelect(space.slug)}
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
/>

View File

@ -1,11 +1,23 @@
import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import React, { useState } from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Button, Divider, Text } from "@mantine/core";
import DeleteSpaceModal from "./delete-space-modal";
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadSpaceIcon,
removeSpaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { queryClient } from "@/main.tsx";
import {
ResponsiveSettingsContent,
ResponsiveSettingsControl,
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
interface SpaceDetailsProps {
spaceId: string;
@ -13,9 +25,40 @@ interface SpaceDetailsProps {
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
const handleIconUpload = async (file: File) => {
setIsIconUploading(true);
try {
await uploadSpaceIcon(file, spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
const handleIconRemove = async () => {
setIsIconUploading(true);
try {
await removeSpaceIcon(spaceId);
await refetch();
await queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
} catch (err) {
// skip
} finally {
setIsIconUploading(false);
}
};
return (
<>
@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<Text my="md" fw={600}>
{t("Details")}
</Text>
<div style={{ marginBottom: "20px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={space.logo}
fallbackName={space.name}
size={"60px"}
variant="filled"
type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isIconUploading}
disabled={readOnly}
/>
</div>
<EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Export space")}</Text>
<Text size="sm" c="dimmed">
{t("Export all pages and attachments in this space.")}
</Text>
</div>
<Button onClick={openExportModal}>
{t("Export")}
</Button>
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Button onClick={openExportModal}>{t("Export")}</Button>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("Delete space")}</Text>
<Text size="sm" c="dimmed">
{t("Delete this space with all its pages and data.")}
</Text>
</div>
<DeleteSpaceModal space={space} />
</Group>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<DeleteSpaceModal space={space} />
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<ExportModal
type="space"

View File

@ -7,7 +7,7 @@
}
.cardSection {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.title {

View File

@ -1,5 +1,5 @@
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React, { useEffect } from 'react';
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React from "react";
import {
prefetchSpace,
useGetSpacesQuery,
@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import { IconArrowRight } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceGrid() {
const { t } = useTranslation();
@ -27,8 +29,10 @@ export default function SpaceGrid() {
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size="md"
@ -54,7 +58,7 @@ export default function SpaceGrid() {
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
{data?.items && data.items.length > 9 && (
<Group justify="flex-end" mt="lg">
<Button

View File

@ -1,4 +1,4 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function SpaceList() {
const { t } = useTranslation();
@ -39,8 +41,10 @@ export default function SpaceList() {
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Avatar
<CustomAvatar
color="initials"
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
variant="filled"
name={space.name}
/>

View File

@ -6,13 +6,12 @@ import {
Box,
Space,
Menu,
Avatar,
Anchor,
} from "@mantine/core";
import { IconDots, IconSettings } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { getSpaceUrl } from "@/lib/config";
@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
import NoTableResults from "@/components/common/no-table-results";
import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface AllSpacesListProps {
spaces: any[];
@ -87,11 +88,13 @@ export default function AllSpacesList({
className={classes.spaceLink}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
>
<Avatar
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
name={space.name}
size={40}
size="md"
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>

View File

@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
});
}
const spaces = queryClient.getQueryData(["spaces"]) as any;
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) {
spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id,
);
queryClient.setQueryData(["spaces"], spaces);
}
}*/
// Invalidate all spaces queries to refresh lists
queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;

View File

@ -8,7 +8,6 @@ import {
ISpaceMember,
} from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver";
export async function getSpaces(

View File

@ -9,7 +9,7 @@ export interface ISpace {
id: string;
name: string;
description: string;
icon: string;
logo?: string;
slug: string;
hostname: string;
creatorId: string;
@ -74,4 +74,4 @@ export interface IExportSpaceParams {
spaceId: string;
format: ExportFormat;
includeAttachments?: boolean;
}
}

View File

@ -1,55 +1,58 @@
import { focusAtom } from "jotai-optics";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
currentUserAtom,
userAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { FileButton, Tooltip } from "@mantine/core";
import { uploadAvatar } from "@/features/user/services/user-service.ts";
import { useTranslation } from "react-i18next";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadUserAvatar,
removeAvatar,
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function AccountAvatar() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
const [file, setFile] = useState<File | null>(null);
const handleFileChange = async (selectedFile: File) => {
if (!selectedFile) {
return;
}
setFile(selectedFile);
const handleUpload = async (selectedFile: File) => {
setIsLoading(true);
try {
setIsLoading(true);
const avatar = await uploadAvatar(selectedFile);
setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
const avatar = await uploadUserAvatar(selectedFile);
if (currentUser?.user) {
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
}
} catch (err) {
console.log(err);
// skip
} finally {
setIsLoading(false);
}
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatar();
if (currentUser?.user) {
setUser({ ...currentUser.user, avatarUrl: null });
}
} catch (err) {
// skip
} finally {
setIsLoading(false);
}
};
return (
<>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
{(props) => (
<Tooltip label={t("Change photo")} position="bottom">
<CustomAvatar
{...props}
component="button"
size="60px"
avatarUrl={currentUser?.user.avatarUrl}
name={currentUser?.user.name}
style={{ cursor: "pointer" }}
/>
</Tooltip>
)}
</FileButton>
</>
<AvatarUploader
currentImageUrl={currentUser?.user.avatarUrl}
fallbackName={currentUser?.user.name}
size="60px"
type={AvatarIconType.AVATAR}
onUpload={handleUpload}
onRemove={handleRemove}
isLoading={isLoading}
/>
);
}

View File

@ -10,16 +10,3 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
const req = await api.post<IUser>("/users/update", data);
return req.data as IUser;
}
export async function uploadAvatar(file: File): Promise<any> {
const formData = new FormData();
formData.append("type", "avatar");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req;
}

View File

@ -0,0 +1,67 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
import {
uploadWorkspaceIcon,
removeWorkspaceIcon,
} from "@/features/attachments/services/attachment-service.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceIcon() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const handleIconUpload = async (file: File) => {
setIsLoading(true);
try {
const result = await uploadWorkspaceIcon(file);
if (workspace) {
setWorkspace({ ...workspace, logo: result.fileName });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
const handleIconRemove = async () => {
setIsLoading(true);
try {
await removeWorkspaceIcon();
if (workspace) {
setWorkspace({ ...workspace, logo: null });
}
} catch (error) {
//
} finally {
setIsLoading(false);
}
};
return (
<div style={{ marginBottom: "24px" }}>
<Text size="sm" fw={500} mb="xs">
{t("Icon")}
</Text>
<AvatarUploader
currentImageUrl={workspace?.logo}
fallbackName={workspace?.name}
type={AvatarIconType.WORKSPACE_ICON}
size="60px"
radius="sm"
variant="filled"
onUpload={handleIconUpload}
onRemove={handleIconRemove}
isLoading={isLoading}
disabled={!isAdmin}
/>
</div>
);
}

View File

@ -109,15 +109,3 @@ export async function getAppVersion(): Promise<IVersion> {
return req.data;
}
export async function uploadLogo(file: File) {
const formData = new FormData();
formData.append("type", "workspace-logo");
formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req.data;
}

View File

@ -1,5 +1,6 @@
import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
declare global {
interface Window {
@ -41,11 +42,14 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function getAvatarUrl(avatarUrl: string) {
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
) {
if (!avatarUrl) return null;
if (avatarUrl?.startsWith("http")) return avatarUrl;
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl);
}
export function getSpaceUrl(spaceSlug: string) {

View File

@ -2,6 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
adminView?: boolean;
}
export enum UserRole {
@ -36,20 +37,3 @@ export type IPagination<T> = {
items: T[];
meta: IPaginationMeta;
};
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

View File

@ -1,6 +1,8 @@
import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import '@mantine/dates/styles.css';
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme";

View File

@ -1,5 +1,6 @@
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 { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@ -14,6 +15,7 @@ export default function WorkspaceSettings() {
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm />
{isCloud() && (

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.23.0",
"version": "0.23.2",
"description": "",
"author": "",
"private": true,
@ -37,6 +37,7 @@
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2",
@ -55,14 +56,15 @@
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.53.2",
"bullmq": "^5.61.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "^15.11.6",
"happy-dom": "^18.0.1",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
@ -84,10 +86,12 @@
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "^1.0.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.2",
"yauzl": "^3.2.0"
},

View File

@ -16,6 +16,8 @@ import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
const enterpriseModules = [];
try {
@ -36,6 +38,9 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CollaborationModule,
WsModule,
QueueModule,

View File

@ -35,11 +35,10 @@ import {
Subpages,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
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
// see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [

View File

@ -1,3 +1,8 @@
export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated',
}
PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated',
PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored',
}

View File

@ -1,21 +1,29 @@
import { Extensions, getSchema, JSONContent } from '@tiptap/core';
import { DOMSerializer, Node } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
import { type Extensions, type JSONContent, getSchema } from '@tiptap/core';
import { Node } from '@tiptap/pm/model';
import { getHTMLFromFragment } from './getHTMLFromFragment';
/**
* This function generates HTML from a ProseMirror JSON content object.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The ProseMirror JSON content object.
* @param extensions - The Tiptap extensions used to build the schema.
* @returns The generated HTML string.
* @example
* ```js
* const html = generateHTML(doc, extensions)
* console.log(html)
* ```
*/
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
if (typeof window !== 'undefined') {
throw new Error(
'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc);
const window = new Window();
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
contentNode.content,
{
document: window.document as unknown as Document,
},
);
const serializer = new window.XMLSerializer();
// @ts-ignore
return serializer.serializeToString(fragment as unknown as Node);
return getHTMLFromFragment(contentNode, schema);
}

View File

@ -1,21 +1,55 @@
import { Extensions, getSchema } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import type { Extensions } from '@tiptap/core';
import { getSchema } from '@tiptap/core';
import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
// this function does not work as intended
// it has issues with closing tags
/**
* Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content.
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param {string} html - The HTML string to be converted into a Prosemirror node.
* @param {Extensions} extensions - The extensions to be used for generating the schema.
* @param {ParseOptions} options - The options to be supplied to the parser.
* @returns {Promise<Record<string, any>>} - A promise with the generated JSON object.
* @example
* const html = '<p>Hello, world!</p>'
* const extensions = [...]
* const json = generateJSON(html, extensions)
* console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] }
*/
export function generateJSON(
html: string,
extensions: Extensions,
options?: ParseOptions,
): Record<string, any> {
const schema = getSchema(extensions);
if (typeof window !== 'undefined') {
throw new Error(
'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const window = new Window();
const document = window.document;
document.body.innerHTML = html;
const localWindow = new Window();
const localDOMParser = new localWindow.DOMParser();
let result: Record<string, any>;
return DOMParser.fromSchema(schema)
.parse(document as never, options)
.toJSON();
try {
const schema = getSchema(extensions);
let doc: ReturnType<typeof localDOMParser.parseFromString> | null = null;
const htmlString = `<!DOCTYPE html><html><body>${html}</body></html>`;
doc = localDOMParser.parseFromString(htmlString, 'text/html');
if (!doc) {
throw new Error('Failed to parse HTML string');
}
result = PMDOMParser.fromSchema(schema)
.parse(doc.body as unknown as Node, options)
.toJSON();
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
}

View File

@ -0,0 +1,54 @@
import type { Node, Schema } from '@tiptap/pm/model';
import { DOMSerializer } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
/**
* Returns the HTML string representation of a given document node.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The document node to serialize.
* @param schema - The Prosemirror schema to use for serialization.
* @returns A promise containing the HTML string representation of the document fragment.
*
* @example
* ```typescript
* const html = getHTMLFromFragment(doc, schema)
* ```
*/
export function getHTMLFromFragment(
doc: Node,
schema: Schema,
options?: { document?: Document },
): string {
if (options?.document) {
const wrap = options.document.createElement('div');
DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{ document: options.document },
wrap,
);
return wrap.innerHTML;
}
const localWindow = new Window();
let result: string;
try {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{
document: localWindow.document as unknown as Document,
},
);
const serializer = new localWindow.XMLSerializer();
result = serializer.serializeToString(fragment as any);
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
}

View File

@ -72,7 +72,9 @@ export function extractDateFromUuid7(uuid7: string) {
}
export function sanitizeFileName(fileName: string): string {
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
const sanitizedFilename = sanitize(fileName)
.replace(/ /g, '_')
.replace(/#/g, '_');
return sanitizedFilename.slice(0, 255);
}

View File

@ -0,0 +1,34 @@
// MIT - https://github.com/typestack/class-validator/pull/2626
import isISO6391Validator from 'validator/lib/isISO6391';
import { buildMessage, ValidateBy, ValidationOptions } from 'class-validator';
export const IS_ISO6391 = 'isISO6391';
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function isISO6391(value: unknown): boolean {
return typeof value === 'string' && isISO6391Validator(value);
}
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function IsISO6391(
validationOptions?: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: IS_ISO6391,
validator: {
validate: (value, args): boolean => isISO6391(value),
defaultMessage: buildMessage(
(eachPrefix) =>
eachPrefix + '$property must be a valid ISO 639-1 language code',
validationOptions,
),
},
},
validationOptions,
);
}

View File

@ -1,12 +1,12 @@
export enum AttachmentType {
Avatar = 'avatar',
WorkspaceLogo = 'workspace-logo',
SpaceLogo = 'space-logo',
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '5MB';
export const MAX_AVATAR_SIZE = '10MB';
export const inlineFileExtensions = [
'.jpg',

View File

@ -1,5 +1,6 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
@ -51,6 +52,7 @@ 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 { RemoveIconDto } from './dto/attachment.dto';
@Controller()
export class AttachmentController {
@ -302,7 +304,7 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
if (attachmentType === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
@ -314,7 +316,7 @@ export class AttachmentController {
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (attachmentType === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
@ -372,8 +374,59 @@ export class AttachmentController {
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
// this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
async removeIcon(
@Body() dto: RemoveIconDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { type, spaceId } = dto;
// remove current user avatar
if (type === AttachmentType.Avatar) {
await this.attachmentService.removeUserAvatar(user);
return;
}
// remove space icon
if (type === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException(
'spaceId is required to change space icons',
);
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
return;
}
// remove workspace icon
if (type === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.attachmentService.removeWorkspaceIcon(workspace);
return;
}
}
}

View File

@ -1,8 +1,8 @@
import { MultipartFile } from '@fastify/multipart';
import { randomBytes } from 'crypto';
import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile {
buffer: Buffer;
@ -22,10 +22,8 @@ export async function prepareFile(
}
try {
const rand = randomBytes(8).toString('hex');
const buffer = await file.toBuffer();
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase();
@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
switch (type) {
case AttachmentType.Avatar:
return `${workspaceId}/avatars`;
case AttachmentType.WorkspaceLogo:
return `${workspaceId}/workspace-logo`;
case AttachmentType.SpaceLogo:
case AttachmentType.WorkspaceIcon:
return `${workspaceId}/workspace-logos`;
case AttachmentType.SpaceIcon:
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
}
export const validAttachmentTypes = Object.values(AttachmentType);
export async function compressAndResizeIcon(
buffer: Buffer,
attachmentType?: AttachmentType,
): Promise<Buffer> {
try {
let sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
const targetWidth = 300;
const targetHeight = 300;
// Only resize if image is larger than target dimensions
if (metadata.width > targetWidth || metadata.height > targetHeight) {
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Handle based on original format
if (metadata.format === 'png') {
// Only flatten avatars to remove transparency
if (attachmentType === AttachmentType.Avatar) {
sharpInstance = sharpInstance.flatten({
background: { r: 255, g: 255, b: 255 },
});
}
return await sharpInstance
.png({
quality: 85,
compressionLevel: 6,
})
.toBuffer();
} else {
return await sharpInstance
.jpeg({
quality: 85,
progressive: true,
mozjpeg: true,
})
.toBuffer();
}
} catch (err) {
throw err;
}
}

View File

@ -0,0 +1,17 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
AttachmentType.Avatar,
AttachmentType.SpaceIcon,
AttachmentType.WorkspaceIcon,
])
@IsNotEmpty()
type: AttachmentType;
@IsOptional()
@IsUUID()
spaceId: string;
}

View File

@ -1,3 +0,0 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class AvatarUploadDto {}

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class GetFileDto {
@IsString()
@IsNotEmpty()
attachmentId: string;
}

View File

@ -1,20 +0,0 @@
import {
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UploadFileDto {
@IsString()
@IsNotEmpty()
attachmentType: string;
@IsOptional()
@IsUUID()
pageId: string;
@IsDefined()
file: any;
}

View File

@ -67,9 +67,15 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
this.logger.debug(
`Error processing ${job.name} job for attachment ${job.data?.attachmentId}. Reason: ${job.failedReason}`,
);
} else {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
}
@OnWorkerEvent('completed')

View File

@ -7,6 +7,7 @@ import {
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
compressAndResizeIcon,
getAttachmentFolderPath,
PreparedFile,
prepareFile,
@ -16,7 +17,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { AttachmentType, validImageExtensions } from '../attachment.constants';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Attachment } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@ -132,8 +133,8 @@ export class AttachmentService {
filePromise: Promise<MultipartFile>,
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceLogo
| AttachmentType.SpaceLogo,
| AttachmentType.WorkspaceIcon
| AttachmentType.SpaceIcon,
userId: string,
workspaceId: string,
spaceId?: string,
@ -141,6 +142,9 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
@ -174,7 +178,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.WorkspaceLogo) {
} else if (type === AttachmentType.WorkspaceIcon) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
@ -186,7 +190,7 @@ export class AttachmentService {
workspaceId,
trx,
);
} else if (type === AttachmentType.SpaceLogo && spaceId) {
} else if (type === AttachmentType.SpaceIcon && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx,
});
@ -205,7 +209,6 @@ export class AttachmentService {
});
} catch (err) {
// delete uploaded file on db update failure
this.logger.error('Image upload error:', err);
await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image');
}
@ -389,4 +392,40 @@ export class AttachmentService {
}
}
async removeUserAvatar(user: User) {
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
await this.deleteRedundantFile(filePath);
}
await this.userRepo.updateUser(
{ avatarUrl: null },
user.id,
user.workspaceId,
);
}
async removeSpaceIcon(spaceId: string, workspaceId: string) {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
}
async removeWorkspaceIcon(workspace: Workspace) {
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
await this.deleteRedundantFile(filePath);
}
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
}
}

View File

@ -4,6 +4,7 @@ export enum JwtType {
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
}
export type JwtPayload = {
sub: string;
@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
workspaceId: string;
type: 'mfa_token';
}
export type JwtApiKeyPayload = {
sub: string;
workspaceId: string;
apiKeyId: string;
type: 'api_key';
};

View File

@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtApiKeyPayload,
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
@ -77,10 +78,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async generateMfaToken(
user: User,
workspaceId: string,
): Promise<string> {
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
@ -93,6 +91,27 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '5m' });
}
async generateApiToken(opts: {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: string | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
const payload: JwtApiKeyPayload = {
sub: user.id,
apiKeyId: apiKeyId,
workspaceId,
type: JwtType.API_KEY,
};
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),

View File

@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { JwtPayload, JwtType } from '../dto/jwt-payload';
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) {
super({
jwtFromRequest: (req: FastifyRequest) => {
@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
async validate(req: any, payload: JwtPayload) {
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
if (!payload.workspaceId) {
throw new UnauthorizedException();
}
@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match');
}
if (payload.type === JwtType.API_KEY) {
return this.validateApiKey(req, payload as JwtApiKeyPayload);
}
if (payload.type !== JwtType.ACCESS) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace };
}
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
let ApiKeyModule: any;
let isApiKeyModuleReady = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
isApiKeyModuleReady = true;
} catch (err) {
this.logger.debug(
'API Key module requested but enterprise module not bundled in this build',
);
isApiKeyModuleReady = false;
}
if (isApiKeyModuleReady) {
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
strict: false,
});
return ApiKeyService.validateApiKey(payload);
}
throw new UnauthorizedException('Enterprise API Key module missing');
}
}

View File

@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
return build();
}

View File

@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
Space = 'space',
Group = 'group',
Attachment = 'attachment',
API = 'api_key',
}
export type IWorkspaceAbility =
@ -18,4 +19,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];

View File

@ -9,6 +9,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule]
imports: [StorageModule],
})
export class PageModule {}

View File

@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class PageService {
@ -49,6 +51,7 @@ export class PageService {
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
private eventEmitter: EventEmitter2,
) {}
async findById(
@ -231,21 +234,28 @@ export class PageService {
);
}
// update spaceId in shares
if (pageIds.length > 0) {
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
}
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
trx,
);
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
trx,
);
}
});
}
@ -371,15 +381,20 @@ export class PageService {
workspaceId: page.workspaceId,
creatorId: authUser.id,
lastUpdatedById: authUser.id,
parentPageId: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
parentPageId: page.id === rootPage.id
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
};
}),
);
await this.db.insertInto('pages').values(insertablePages).execute();
const insertedPageIds = insertablePages.map((page) => page.id);
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: insertedPageIds,
});
//TODO: best to handle this in a queue
const attachmentsIds = Array.from(attachmentMap.keys());
if (attachmentsIds.length > 0) {
@ -606,6 +621,9 @@ export class PageService {
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
this.eventEmitter.emit(EventName.PAGE_DELETED, {
pageIds: pageIds,
});
}
}

View File

@ -1,3 +1,5 @@
import { Space } from '@docmost/db/types/entity.types';
export class SearchResponseDto {
id: string;
title: string;
@ -8,4 +10,5 @@ export class SearchResponseDto {
highlight: string;
createdAt: Date;
updatedAt: Date;
space: Partial<Space>;
}

View File

@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Logger,
Post,
UseGuards,
} from '@nestjs/common';
@ -24,13 +25,19 @@ import {
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { ModuleRef } from '@nestjs/core';
@UseGuards(JwtAuthGuard)
@Controller('search')
export class SearchController {
private readonly logger = new Logger(SearchController.name);
constructor(
private readonly searchService: SearchService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) {}
@HttpCode(HttpStatus.OK)
@ -53,7 +60,14 @@ export class SearchController {
}
}
return this.searchService.searchPage(searchDto.query, searchDto, {
if (this.environmentService.getSearchDriver() === 'typesense') {
return this.searchTypesense(searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
return this.searchService.searchPage(searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
@ -81,8 +95,47 @@ export class SearchController {
throw new BadRequestException('shareId is required');
}
return this.searchService.searchPage(searchDto.query, searchDto, {
if (this.environmentService.getSearchDriver() === 'typesense') {
return this.searchTypesense(searchDto, {
workspaceId: workspace.id,
});
}
return this.searchService.searchPage(searchDto, {
workspaceId: workspace.id,
});
}
async searchTypesense(
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
) {
const { userId, workspaceId } = opts;
let TypesenseModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
TypesenseModule = require('./../../ee/typesense/services/page-search.service');
const PageSearchService = this.moduleRef.get(
TypesenseModule.PageSearchService,
{
strict: false,
},
);
return PageSearchService.searchPage(searchParams, {
userId: userId,
workspaceId,
});
} catch (err) {
this.logger.debug(
'Typesense module requested but enterprise module not bundled in this build',
);
}
throw new BadRequestException('Enterprise Typesense search module missing');
}
}

View File

@ -21,13 +21,14 @@ export class SearchService {
) {}
async searchPage(
query: string,
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SearchResponseDto[]> {
const { query } = searchParams;
if (query.length < 1) {
return;
}

View File

@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
enforceMfa: boolean;
@IsOptional()
@IsBoolean()
restrictApiToAdmins: boolean;
}

View File

@ -303,6 +303,15 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
await this.workspaceRepo.updateApiSettings(
workspaceId,
'restrictToAdmins',
updateWorkspaceDto.restrictApiToAdmins,
);
delete updateWorkspaceDto.restrictApiToAdmins;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {

View File

@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
ShareRepo,
PageListener,
],
exports: [
WorkspaceRepo,
@ -90,7 +92,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
ShareRepo,
],
})
export class DatabaseModule

View File

@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
export class PageEvent {
pageIds: string[];
}
@Injectable()
export class PageListener {
private readonly logger = new Logger(PageListener.name);
constructor(
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
) {}
@OnEvent(EventName.PAGE_CREATED)
async handlePageCreated(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds });
}
@OnEvent(EventName.PAGE_UPDATED)
async handlePageUpdated(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
}
@OnEvent(EventName.PAGE_DELETED)
async handlePageDeleted(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
}
@OnEvent(EventName.PAGE_SOFT_DELETED)
async handlePageSoftDeleted(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
}
@OnEvent(EventName.PAGE_RESTORED)
async handlePageRestored(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
}
}

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