mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 14:12:36 +10:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a8ed6826 | |||
| f5684b792e | |||
| 042836cb6d | |||
| 4f1f0ba513 | |||
| 3164b6981c | |||
| 16c1e864af | |||
| c9b1cad982 | |||
| bf8cf6254f | |||
| 3135030376 | |||
| 3fae41a5ca | |||
| b50e25600a | |||
| 1f3b0c7276 | |||
| 3c4cab0d2a | |||
| 4de25a8b94 | |||
| cf5bbb10df | |||
| ac17521717 | |||
| 9ac180f719 | |||
| 46669fea56 | |||
| fe6ecdf1f1 | |||
| 04ae1d7270 | |||
| 1280f96f37 | |||
| 61d1cf88a7 | |||
| f413720e15 | |||
| 8e16ad952a | |||
| 7ada3cb1f9 | |||
| 47c54174b3 | |||
| dc0650289d |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.23.0",
|
"version": "0.23.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -17,6 +17,7 @@
|
|||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
|
"@mantine/dates": "^8.3.2",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^8.1.3",
|
||||||
|
|||||||
@ -527,5 +527,32 @@
|
|||||||
"Delete SSO provider": "Delete SSO provider",
|
"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?",
|
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
|
||||||
"Action": "Action",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
|
|||||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -96,8 +98,10 @@ export default function App() {
|
|||||||
path={"account/preferences"}
|
path={"account/preferences"}
|
||||||
element={<AccountPreferences />}
|
element={<AccountPreferences />}
|
||||||
/>
|
/>
|
||||||
|
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
|
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
|||||||
165
apps/client/src/components/common/avatar-uploader.tsx
Normal file
165
apps/client/src/components/common/avatar-uploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
interface NoTableResultsProps {
|
interface NoTableResultsProps {
|
||||||
colSpan: number;
|
colSpan: number;
|
||||||
|
text?: string;
|
||||||
}
|
}
|
||||||
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
|
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={colSpan}>
|
<Table.Td colSpan={colSpan}>
|
||||||
<Text fw={500} c="dimmed" ta="center">
|
<Text fw={500} c="dimmed" ta="center">
|
||||||
{t("No results found...")}
|
{text || t("No results found...")}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
UnstyledButton,
|
|
||||||
Text,
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@ -10,7 +10,6 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
|
||||||
IconDeviceDesktop,
|
IconDeviceDesktop,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
|||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function TopMenu() {
|
export default function TopMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -50,6 +50,7 @@ export default function TopMenu() {
|
|||||||
name={workspace?.name}
|
name={workspace?.name}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
type={AvatarIconType.WORKSPACE_ICON}
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace?.name}
|
{workspace?.name}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
|
|||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -65,3 +66,17 @@ export const prefetchShares = () => {
|
|||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
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 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import {
|
import {
|
||||||
|
prefetchApiKeyManagement,
|
||||||
|
prefetchApiKeys,
|
||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconBrush,
|
icon: IconBrush,
|
||||||
path: "/settings/account/preferences",
|
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: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
{ 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":
|
case "Public sharing":
|
||||||
prefetchHandler = prefetchShares;
|
prefetchHandler = prefetchShares;
|
||||||
break;
|
break;
|
||||||
|
case "API keys":
|
||||||
|
prefetchHandler = prefetchApiKeys;
|
||||||
|
break;
|
||||||
|
case "API management":
|
||||||
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
import { getAvatarUrl } from "@/lib/config.ts";
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface CustomAvatarProps {
|
interface CustomAvatarProps {
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
@ -11,13 +12,15 @@ interface CustomAvatarProps {
|
|||||||
variant?: string;
|
variant?: string;
|
||||||
style?: any;
|
style?: any;
|
||||||
component?: any;
|
component?: any;
|
||||||
|
type?: AvatarIconType;
|
||||||
|
mt?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/client/src/ee/api-key/components/api-key-table.tsx
Normal file
143
apps/client/src/ee/api-key/components/api-key-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/client/src/ee/api-key/components/create-api-key-modal.tsx
Normal file
153
apps/client/src/ee/api-key/components/create-api-key-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/client/src/ee/api-key/index.ts
Normal file
11
apps/client/src/ee/api-key/index.ts
Normal 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";
|
||||||
106
apps/client/src/ee/api-key/pages/user-api-keys.tsx
Normal file
106
apps/client/src/ee/api-key/pages/user-api-keys.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
Normal file
117
apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/client/src/ee/api-key/queries/api-key-query.ts
Normal file
97
apps/client/src/ee/api-key/queries/api-key-query.ts
Normal 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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/client/src/ee/api-key/services/api-key-service.ts
Normal file
32
apps/client/src/ee/api-key/services/api-key-service.ts
Normal 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);
|
||||||
|
}
|
||||||
23
apps/client/src/ee/api-key/types/api-key.types.ts
Normal file
23
apps/client/src/ee/api-key/types/api-key.types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
9
apps/client/src/features/attachments/services/index.ts
Normal file
9
apps/client/src/features/attachments/services/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
uploadIcon,
|
||||||
|
uploadUserAvatar,
|
||||||
|
uploadSpaceIcon,
|
||||||
|
uploadWorkspaceIcon,
|
||||||
|
removeAvatar,
|
||||||
|
removeSpaceIcon,
|
||||||
|
removeWorkspaceIcon,
|
||||||
|
} from "./attachment-service.ts";
|
||||||
@ -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",
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import {
|
|||||||
BubbleMenuProps,
|
BubbleMenuProps,
|
||||||
isNodeSelection,
|
isNodeSelection,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
useEditorState,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
}, [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[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: "Bold",
|
name: "Bold",
|
||||||
isActive: () => props.editor.isActive("bold"),
|
isActive: () => editorState?.isBold,
|
||||||
command: () => props.editor.chain().focus().toggleBold().run(),
|
command: () => props.editor.chain().focus().toggleBold().run(),
|
||||||
icon: IconBold,
|
icon: IconBold,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Italic",
|
name: "Italic",
|
||||||
isActive: () => props.editor.isActive("italic"),
|
isActive: () => editorState?.isItalic,
|
||||||
command: () => props.editor.chain().focus().toggleItalic().run(),
|
command: () => props.editor.chain().focus().toggleItalic().run(),
|
||||||
icon: IconItalic,
|
icon: IconItalic,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Underline",
|
name: "Underline",
|
||||||
isActive: () => props.editor.isActive("underline"),
|
isActive: () => editorState?.isUnderline,
|
||||||
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
||||||
icon: IconUnderline,
|
icon: IconUnderline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Strike",
|
name: "Strike",
|
||||||
isActive: () => props.editor.isActive("strike"),
|
isActive: () => editorState?.isStrike,
|
||||||
command: () => props.editor.chain().focus().toggleStrike().run(),
|
command: () => props.editor.chain().focus().toggleStrike().run(),
|
||||||
icon: IconStrikethrough,
|
icon: IconStrikethrough,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Code",
|
name: "Code",
|
||||||
isActive: () => props.editor.isActive("code"),
|
isActive: () => editorState?.isCode,
|
||||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||||
icon: IconCode,
|
icon: IconCode,
|
||||||
},
|
},
|
||||||
@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
|
|
||||||
const commentItem: BubbleMenuItem = {
|
const commentItem: BubbleMenuItem = {
|
||||||
name: "Comment",
|
name: "Comment",
|
||||||
isActive: () => props.editor.isActive("comment"),
|
isActive: () => editorState?.isComment,
|
||||||
command: () => {
|
command: () => {
|
||||||
const commentId = uuid7();
|
const commentId = uuid7();
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface BubbleColorMenuItem {
|
export interface BubbleColorMenuItem {
|
||||||
@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ColorSelectorProps {
|
interface ColorSelectorProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@ -108,12 +109,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
setIsOpen,
|
setIsOpen,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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 }) =>
|
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||||
editor.isActive("textStyle", { color }),
|
editorState[`text_${color}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||||
editor.isActive("highlight", { color }),
|
editorState[`highlight_${color}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -151,7 +176,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
justify="left"
|
justify="left"
|
||||||
fullWidth
|
fullWidth
|
||||||
rightSection={
|
rightSection={
|
||||||
editor.isActive("textStyle", { color }) && (
|
editorState[`text_${color}`] && (
|
||||||
<IconCheck style={{ width: rem(16) }} />
|
<IconCheck style={{ width: rem(16) }} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,12 @@ import {
|
|||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea } from "@mantine/core";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: "Text",
|
name: "Text",
|
||||||
@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
command: () =>
|
command: () =>
|
||||||
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
||||||
isActive: () =>
|
isActive: () =>
|
||||||
editor.isActive("paragraph") &&
|
editorState?.isParagraph &&
|
||||||
!editor.isActive("bulletList") &&
|
!editorState?.isBulletList &&
|
||||||
!editor.isActive("orderedList"),
|
!editorState?.isOrderedList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Heading 1",
|
name: "Heading 1",
|
||||||
icon: IconH1,
|
icon: IconH1,
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
isActive: () => editorState?.isHeading1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Heading 2",
|
name: "Heading 2",
|
||||||
icon: IconH2,
|
icon: IconH2,
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
isActive: () => editorState?.isHeading2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Heading 3",
|
name: "Heading 3",
|
||||||
icon: IconH3,
|
icon: IconH3,
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
isActive: () => editorState?.isHeading3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "To-do List",
|
name: "To-do List",
|
||||||
icon: IconCheckbox,
|
icon: IconCheckbox,
|
||||||
command: () => editor.chain().focus().toggleTaskList().run(),
|
command: () => editor.chain().focus().toggleTaskList().run(),
|
||||||
isActive: () => editor.isActive("taskItem"),
|
isActive: () => editorState?.isTaskItem,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bullet List",
|
name: "Bullet List",
|
||||||
icon: IconList,
|
icon: IconList,
|
||||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||||
isActive: () => editor.isActive("bulletList"),
|
isActive: () => editorState?.isBulletList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Numbered List",
|
name: "Numbered List",
|
||||||
icon: IconListNumbers,
|
icon: IconListNumbers,
|
||||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||||
isActive: () => editor.isActive("orderedList"),
|
isActive: () => editorState?.isOrderedList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Blockquote",
|
name: "Blockquote",
|
||||||
@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
.toggleNode("paragraph", "paragraph")
|
.toggleNode("paragraph", "paragraph")
|
||||||
.toggleBlockquote()
|
.toggleBlockquote()
|
||||||
.run(),
|
.run(),
|
||||||
isActive: () => editor.isActive("blockquote"),
|
isActive: () => editorState?.isBlockquote,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Code",
|
name: "Code",
|
||||||
icon: IconCode,
|
icon: IconCode,
|
||||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
isActive: () => editor.isActive("codeBlock"),
|
isActive: () => editorState?.isCodeBlock,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -8,11 +8,12 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface TextAlignmentProps {
|
interface TextAlignmentProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: "Align left",
|
name: "Align left",
|
||||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
isActive: () => editorState?.isAlignLeft,
|
||||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||||
icon: IconAlignLeft,
|
icon: IconAlignLeft,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Align center",
|
name: "Align center",
|
||||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
isActive: () => editorState?.isAlignCenter,
|
||||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||||
icon: IconAlignCenter,
|
icon: IconAlignCenter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Align right",
|
name: "Align right",
|
||||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
isActive: () => editorState?.isAlignRight,
|
||||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||||
icon: IconAlignRight,
|
icon: IconAlignRight,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Justify",
|
name: "Justify",
|
||||||
isActive: () => editor.isActive({ textAlign: "justify" }),
|
isActive: () => editorState?.isAlignJustify,
|
||||||
command: () => editor.chain().focus().setTextAlign("justify").run(),
|
command: () => editor.chain().focus().setTextAlign("justify").run(),
|
||||||
icon: IconAlignJustified,
|
icon: IconAlignJustified,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
|
||||||
name: "Multiple",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover opened={isOpen} withArrow>
|
<Popover opened={isOpen} withArrow>
|
||||||
@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
|||||||
rightSection={<IconChevronDown size={16} />}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
<IconAlignLeft style={{ width: rem(16) }} stroke={2} />
|
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BubbleMenu as BaseBubbleMenu,
|
BubbleMenu as BaseBubbleMenu,
|
||||||
findParentNode,
|
findParentNode,
|
||||||
posToDOMRect,
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
@ -9,7 +10,7 @@ import {
|
|||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[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 getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`callout-menu}`}
|
pluginKey={`callout-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
getReferenceClientRect,
|
getReferenceClientRect,
|
||||||
@ -111,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Info")}
|
aria-label={t("Info")}
|
||||||
variant={
|
variant={editorState?.isInfo ? "light" : "default"}
|
||||||
editor.isActive("callout", { type: "info" }) ? "light" : "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconInfoCircleFilled size={18} />
|
<IconInfoCircleFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Success")}
|
aria-label={t("Success")}
|
||||||
variant={
|
variant={editorState?.isSuccess ? "light" : "default"}
|
||||||
editor.isActive("callout", { type: "success" })
|
|
||||||
? "light"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconCircleCheckFilled size={18} />
|
<IconCircleCheckFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Warning")}
|
aria-label={t("Warning")}
|
||||||
variant={
|
variant={editorState?.isWarning ? "light" : "default"}
|
||||||
editor.isActive("callout", { type: "warning" })
|
|
||||||
? "light"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconAlertTriangleFilled size={18} />
|
<IconAlertTriangleFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Danger")}
|
aria-label={t("Danger")}
|
||||||
variant={
|
variant={editorState?.isDanger ? "light" : "default"}
|
||||||
editor.isActive("callout", { type: "danger" })
|
|
||||||
? "light"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconCircleXFilled size={18} />
|
<IconCircleXFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@ -2,15 +2,16 @@ import {
|
|||||||
BubbleMenu as BaseBubbleMenu,
|
BubbleMenu as BaseBubbleMenu,
|
||||||
findParentNode,
|
findParentNode,
|
||||||
posToDOMRect,
|
posToDOMRect,
|
||||||
} from '@tiptap/react';
|
useEditorState,
|
||||||
import { useCallback } from 'react';
|
} from "@tiptap/react";
|
||||||
import { sticky } from 'tippy.js';
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from 'prosemirror-model';
|
import { sticky } from "tippy.js";
|
||||||
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from '@/features/editor/components/table/types/types.ts';
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
|
|
||||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
return false;
|
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 getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
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);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
editor.commands.updateAttributes('drawio', { width: `${value}%` });
|
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`drawio-menu}`}
|
pluginKey={`drawio-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
getReferenceClientRect,
|
getReferenceClientRect,
|
||||||
offset: [0, 8],
|
offset: [0, 8],
|
||||||
zIndex: 99,
|
zIndex: 99,
|
||||||
popperOptions: {
|
popperOptions: {
|
||||||
modifiers: [{ name: 'flip', enabled: false }],
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
},
|
},
|
||||||
plugins: [sticky],
|
plugins: [sticky],
|
||||||
sticky: 'popper',
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editor.getAttributes('drawio')?.width && (
|
{editorState?.width && (
|
||||||
<NodeWidthResize
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
onChange={onWidthChange}
|
|
||||||
value={parseInt(editor.getAttributes('drawio').width)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
EventExit,
|
EventExit,
|
||||||
EventSave,
|
EventSave,
|
||||||
} from "react-drawio";
|
} from "react-drawio";
|
||||||
import { IAttachment } from "@/lib/types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
|
|||||||
@ -2,15 +2,16 @@ import {
|
|||||||
BubbleMenu as BaseBubbleMenu,
|
BubbleMenu as BaseBubbleMenu,
|
||||||
findParentNode,
|
findParentNode,
|
||||||
posToDOMRect,
|
posToDOMRect,
|
||||||
} from '@tiptap/react';
|
useEditorState,
|
||||||
import { useCallback } from 'react';
|
} from "@tiptap/react";
|
||||||
import { sticky } from 'tippy.js';
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from 'prosemirror-model';
|
import { sticky } from "tippy.js";
|
||||||
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from '@/features/editor/components/table/types/types.ts';
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
|
|
||||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
return false;
|
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 getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
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);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
(value: number) => {
|
(value: number) => {
|
||||||
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
|
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
offset: [0, 8],
|
offset: [0, 8],
|
||||||
zIndex: 99,
|
zIndex: 99,
|
||||||
popperOptions: {
|
popperOptions: {
|
||||||
modifiers: [{ name: 'flip', enabled: false }],
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
},
|
},
|
||||||
plugins: [sticky],
|
plugins: [sticky],
|
||||||
sticky: 'popper',
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editor.getAttributes('excalidraw')?.width && (
|
{editorState?.width && (
|
||||||
<NodeWidthResize
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
onChange={onWidthChange}
|
|
||||||
value={parseInt(editor.getAttributes('excalidraw').width)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
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 ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BubbleMenu as BaseBubbleMenu,
|
BubbleMenu as BaseBubbleMenu,
|
||||||
findParentNode,
|
findParentNode,
|
||||||
posToDOMRect,
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
import { sticky } from "tippy.js";
|
||||||
@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[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 getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`image-menu}`}
|
pluginKey={`image-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
getReferenceClientRect,
|
getReferenceClientRect,
|
||||||
@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={
|
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||||
editor.isActive("image", { align: "left" }) ? "light" : "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={
|
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||||
editor.isActive("image", { align: "center" })
|
|
||||||
? "light"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={
|
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||||
editor.isActive("image", { align: "right" }) ? "light" : "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
|
|
||||||
{editor.getAttributes("image")?.width && (
|
{editorState?.width && (
|
||||||
<NodeWidthResize
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
onChange={onWidthChange}
|
|
||||||
value={parseInt(editor.getAttributes("image").width)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 React, { useCallback, useState } from "react";
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
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");
|
return editor.isActive("link");
|
||||||
}, [editor]);
|
}, [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(() => {
|
const handleEdit = useCallback(() => {
|
||||||
setShowEdit(true);
|
setShowEdit(true);
|
||||||
@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
|||||||
padding="xs"
|
padding="xs"
|
||||||
bg="var(--mantine-color-body)"
|
bg="var(--mantine-color-body)"
|
||||||
>
|
>
|
||||||
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
|
<LinkEditorPanel
|
||||||
|
initialUrl={editorState?.href}
|
||||||
|
onSetLink={onSetLink}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<LinkPreviewPanel
|
<LinkPreviewPanel
|
||||||
url={link}
|
url={editorState?.href}
|
||||||
onClear={onUnsetLink}
|
onClear={onUnsetLink}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface TableColorItem {
|
export interface TableColorItem {
|
||||||
@ -18,7 +19,7 @@ export interface TableColorItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TableBackgroundColorProps {
|
interface TableBackgroundColorProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_COLORS: TableColorItem[] = [
|
const TABLE_COLORS: TableColorItem[] = [
|
||||||
@ -38,37 +39,50 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = React.useState(false);
|
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) => {
|
const setTableCellBackground = (color: string, colorName: string) => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.updateAttributes("tableCell", {
|
.updateAttributes("tableCell", {
|
||||||
backgroundColor: color || null,
|
backgroundColor: color || null,
|
||||||
backgroundColorName: color ? colorName : null
|
backgroundColorName: color ? colorName : null,
|
||||||
})
|
})
|
||||||
.updateAttributes("tableHeader", {
|
.updateAttributes("tableHeader", {
|
||||||
backgroundColor: color || null,
|
backgroundColor: color || null,
|
||||||
backgroundColorName: color ? colorName : null
|
backgroundColorName: color ? colorName : null,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
setOpened(false);
|
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 (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
width={200}
|
width={200}
|
||||||
@ -123,7 +137,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentColor === item.color && (
|
{editorState.currentColor === item.color && (
|
||||||
<IconCheck
|
<IconCheck
|
||||||
size={18}
|
size={18}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -9,15 +9,15 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
Popover,
|
Popover,
|
||||||
rem,
|
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface TableTextAlignmentProps {
|
interface TableTextAlignmentProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlignmentItem {
|
interface AlignmentItem {
|
||||||
@ -32,25 +32,44 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = React.useState(false);
|
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[] = [
|
const items: AlignmentItem[] = [
|
||||||
{
|
{
|
||||||
name: "Align left",
|
name: "Align left",
|
||||||
value: "left",
|
value: "left",
|
||||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
isActive: () => editorState?.isAlignLeft,
|
||||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
command: () => editor.chain().focus().setTextAlign("left").run(),
|
||||||
icon: IconAlignLeft,
|
icon: IconAlignLeft,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Align center",
|
name: "Align center",
|
||||||
value: "center",
|
value: "center",
|
||||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
isActive: () => editorState?.isAlignCenter,
|
||||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
command: () => editor.chain().focus().setTextAlign("center").run(),
|
||||||
icon: IconAlignCenter,
|
icon: IconAlignCenter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Align right",
|
name: "Align right",
|
||||||
value: "right",
|
value: "right",
|
||||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
isActive: () => editorState?.isAlignRight,
|
||||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
command: () => editor.chain().focus().setTextAlign("right").run(),
|
||||||
icon: IconAlignRight,
|
icon: IconAlignRight,
|
||||||
},
|
},
|
||||||
@ -64,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
withArrow
|
withArrow
|
||||||
transitionProps={{ transition: 'pop' }}
|
transitionProps={{ transition: "pop" }}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
<Tooltip label={t("Text alignment")} withArrow>
|
||||||
@ -87,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
key={index}
|
key={index}
|
||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<item.icon size={16} />}
|
leftSection={<item.icon size={16} />}
|
||||||
rightSection={
|
rightSection={item.isActive() && <IconCheck size={16} />}
|
||||||
item.isActive() && <IconCheck size={16} />
|
|
||||||
}
|
|
||||||
justify="left"
|
justify="left"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BubbleMenu as BaseBubbleMenu,
|
BubbleMenu as BaseBubbleMenu,
|
||||||
findParentNode,
|
findParentNode,
|
||||||
posToDOMRect,
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
import { sticky } from "tippy.js";
|
||||||
@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
[editor],
|
[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 getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`video-menu}`}
|
pluginKey={`video-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
tippyOptions={{
|
||||||
getReferenceClientRect,
|
getReferenceClientRect,
|
||||||
@ -103,9 +123,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignVideoLeft}
|
onClick={alignVideoLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={
|
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||||
editor.isActive("video", { align: "left" }) ? "light" : "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignVideoCenter}
|
onClick={alignVideoCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={
|
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||||
editor.isActive("video", { align: "center" })
|
|
||||||
? "light"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignVideoRight}
|
onClick={alignVideoRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={
|
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||||
editor.isActive("video", { align: "right" }) ? "light" : "default"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
|
|
||||||
{editor.getAttributes("video")?.width && (
|
{editorState?.width && (
|
||||||
<NodeWidthResize
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
onChange={onWidthChange}
|
|
||||||
value={parseInt(editor.getAttributes("video").width)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -165,7 +165,7 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
CustomTable.configure({
|
CustomTable.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: true,
|
||||||
allowTableNodeSelection: true,
|
allowTableNodeSelection: true,
|
||||||
}),
|
}),
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
@ -7,7 +7,12 @@ import {
|
|||||||
onAuthenticationFailedParameters,
|
onAuthenticationFailedParameters,
|
||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
|
import {
|
||||||
|
EditorContent,
|
||||||
|
EditorProvider,
|
||||||
|
useEditor,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import {
|
import {
|
||||||
collabExtensions,
|
collabExtensions,
|
||||||
mainExtensions,
|
mainExtensions,
|
||||||
@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -77,7 +82,7 @@ export default function PageEditor({
|
|||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
const documentName = `page.${pageId}`;
|
||||||
@ -213,17 +218,17 @@ export default function PageEditor({
|
|||||||
extensions,
|
extensions,
|
||||||
editable,
|
editable,
|
||||||
immediatelyRender: true,
|
immediatelyRender: true,
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
scrollThreshold: 80,
|
scrollThreshold: 80,
|
||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||||
searchSpotlight.open();
|
searchSpotlight.open();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -268,9 +273,16 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
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 debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
|
|
||||||
@ -306,7 +318,7 @@ export default function PageEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
"ACTIVE_COMMENT_EVENT",
|
"ACTIVE_COMMENT_EVENT",
|
||||||
handleActiveCommentEvent
|
handleActiveCommentEvent,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@ -389,7 +401,7 @@ export default function PageEditor({
|
|||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editor && editor.isEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
|
|||||||
@ -94,8 +94,12 @@
|
|||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 2px solid #ced4da;
|
@mixin light {
|
||||||
margin: 2rem 0;
|
border-top: 1px solid var(--mantine-color-gray-4);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
border-top: 1px solid var(--mantine-color-dark-4);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
|
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 * as z from "zod";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(50),
|
name: z.string().trim().min(2).max(50),
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import {
|
|||||||
useGroupQuery,
|
useGroupQuery,
|
||||||
useUpdateGroupMutation,
|
useUpdateGroupMutation,
|
||||||
} from "@/features/group/queries/group-query.ts";
|
} from "@/features/group/queries/group-query.ts";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function useGetGroupsQuery(
|
export function useGetGroupsQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
|
|||||||
|
|
||||||
export function useUpdateGroupMutation() {
|
export function useUpdateGroupMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||||
mutationFn: (data) => updateGroup(data),
|
mutationFn: (data) => updateGroup(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Group updated successfully" });
|
notifications.show({ message: t("Group updated successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["group", variables.groupId],
|
queryKey: ["group", variables.groupId],
|
||||||
});
|
});
|
||||||
@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
|
|||||||
|
|
||||||
export function useDeleteGroupMutation() {
|
export function useDeleteGroupMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Group deleted successfully" });
|
notifications.show({ message: t("Group deleted successfully") });
|
||||||
queryClient.refetchQueries({ queryKey: ["groups"] });
|
queryClient.refetchQueries({ queryKey: ["groups"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -119,11 +122,12 @@ export function useGroupMembersQuery(
|
|||||||
|
|
||||||
export function useAddGroupMemberMutation() {
|
export function useAddGroupMemberMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||||
mutationFn: (data) => addGroupMember(data),
|
mutationFn: (data) => addGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Added successfully" });
|
notifications.show({ message: t("Added successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["groupMembers", variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
|
|||||||
|
|
||||||
export function useRemoveGroupMemberMutation() {
|
export function useRemoveGroupMemberMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<
|
return useMutation<
|
||||||
void,
|
void,
|
||||||
@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
|
|||||||
>({
|
>({
|
||||||
mutationFn: (data) => removeGroupMember(data),
|
mutationFn: (data) => removeGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Removed successfully" });
|
notifications.show({ message: t("Removed successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["groupMembers", variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
||||||
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
||||||
@ -84,6 +84,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
||||||
const emit = useQueryEmit();
|
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 canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||||
|
|
||||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||||
@ -116,6 +122,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setFileTaskId(importTask.id);
|
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) {
|
} catch (err) {
|
||||||
console.log("Failed to upload import file", err);
|
console.log("Failed to upload import file", err);
|
||||||
notifications.update({
|
notifications.update({
|
||||||
@ -243,6 +258,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
setTreeData(fullTree);
|
setTreeData(fullTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset file inputs after successful upload
|
||||||
|
if (markdownFileRef.current) markdownFileRef.current();
|
||||||
|
if (htmlFileRef.current) htmlFileRef.current();
|
||||||
|
|
||||||
const pageCountText =
|
const pageCountText =
|
||||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||||
|
|
||||||
@ -272,7 +291,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
<FileButton onChange={handleFileUpload} accept=".md" multiple resetRef={markdownFileRef}>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@ -285,7 +304,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
|
<FileButton onChange={handleFileUpload} accept="text/html" multiple resetRef={htmlFileRef}>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
@ -301,6 +320,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
|
resetRef={notionFileRef}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Button
|
<Button
|
||||||
@ -316,6 +336,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
|
resetRef={confluenceFileRef}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@ -352,6 +373,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
<FileButton
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "generic")}
|
onChange={(file) => handleZipUpload(file, "generic")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
|
resetRef={zipFileRef}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
|
|||||||
@ -9,10 +9,11 @@ import {
|
|||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from '@/features/page/types/page.types';
|
} from '@/features/page/types/page.types';
|
||||||
import { QueryParams } from "@/lib/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 { saveAs } from "file-saver";
|
||||||
import { InfiniteData } from "@tanstack/react-query";
|
import { InfiniteData } from "@tanstack/react-query";
|
||||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
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> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
|||||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||||
import { SearchResultItem } from "./search-result-item.tsx";
|
import { SearchResultItem } from "./search-result-item.tsx";
|
||||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
@ -43,7 +44,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
|
|
||||||
// Determine result type for rendering
|
// Determine result type for rendering
|
||||||
const isAttachmentSearch =
|
const isAttachmentSearch =
|
||||||
filters.contentType === "attachment" && hasLicenseKey;
|
filters.contentType === "attachment" && (hasLicenseKey || isCloud());
|
||||||
|
|
||||||
const resultItems = (searchResults || []).map((result) => (
|
const resultItems = (searchResults || []).map((result) => (
|
||||||
<SearchResultItem
|
<SearchResultItem
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} 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 React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useCreateShareMutation,
|
useCreateShareMutation,
|
||||||
@ -18,23 +18,27 @@ import {
|
|||||||
useShareForPageQuery,
|
useShareForPageQuery,
|
||||||
useUpdateShareMutation,
|
useUpdateShareMutation,
|
||||||
} from "@/features/share/queries/share-query.ts";
|
} 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 { extractPageSlugId, getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
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 { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import classes from "@/features/share/components/share.module.css";
|
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 {
|
interface ShareModalProps {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
export default function ShareModal({ readOnly }: ShareModalProps) {
|
export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const pageId = extractPageSlugId(pageSlug);
|
const pageId = extractPageSlugId(pageSlug);
|
||||||
const { data: share } = useShareForPageQuery(pageId);
|
const { data: share } = useShareForPageQuery(pageId);
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
|
const { isTrial } = useTrial();
|
||||||
const createShareMutation = useCreateShareMutation();
|
const createShareMutation = useCreateShareMutation();
|
||||||
const updateShareMutation = useUpdateShareMutation();
|
const updateShareMutation = useUpdateShareMutation();
|
||||||
const deleteShareMutation = useDeleteShareMutation();
|
const deleteShareMutation = useDeleteShareMutation();
|
||||||
@ -61,7 +65,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
createShareMutation.mutateAsync({
|
createShareMutation.mutateAsync({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
includeSubPages: true,
|
includeSubPages: true,
|
||||||
searchIndexing: true,
|
searchIndexing: false,
|
||||||
});
|
});
|
||||||
setIsPagePublic(value);
|
setIsPagePublic(value);
|
||||||
} else {
|
} else {
|
||||||
@ -92,26 +96,29 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareLink = useMemo(() => (
|
const shareLink = useMemo(
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
() => (
|
||||||
<TextInput
|
<Group my="sm" gap={4} wrap="nowrap">
|
||||||
variant="filled"
|
<TextInput
|
||||||
value={publicLink}
|
variant="filled"
|
||||||
readOnly
|
value={publicLink}
|
||||||
rightSection={<CopyTextButton text={publicLink} />}
|
readOnly
|
||||||
style={{ width: "100%" }}
|
rightSection={<CopyTextButton text={publicLink} />}
|
||||||
/>
|
style={{ width: "100%" }}
|
||||||
<ActionIcon
|
/>
|
||||||
component="a"
|
<ActionIcon
|
||||||
variant="default"
|
component="a"
|
||||||
target="_blank"
|
variant="default"
|
||||||
href={publicLink}
|
target="_blank"
|
||||||
size="sm"
|
href={publicLink}
|
||||||
>
|
size="sm"
|
||||||
<IconExternalLink size={16} />
|
>
|
||||||
</ActionIcon>
|
<IconExternalLink size={16} />
|
||||||
</Group>
|
</ActionIcon>
|
||||||
), [publicLink]);
|
</Group>
|
||||||
|
),
|
||||||
|
[publicLink],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||||
@ -135,7 +142,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown style={{ userSelect: "none" }}>
|
<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>
|
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||||
<Anchor
|
<Anchor
|
||||||
|
|||||||
@ -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 SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.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 SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||||
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
@ -39,16 +39,18 @@ export default function SpaceSettingsModal({
|
|||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
>
|
>
|
||||||
<Modal.Overlay/>
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{overflow: "hidden"}}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
<Text fw={500} lineClamp={1}>{space?.name}</Text>
|
<Text fw={500} lineClamp={1}>
|
||||||
|
{space?.name}
|
||||||
|
</Text>
|
||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton/>
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<div style={{height: rem(600)}}>
|
<div style={{ height: rem(600) }}>
|
||||||
<Tabs defaultValue="members">
|
<Tabs defaultValue="members">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab fw={500} value="general">
|
<Tabs.Tab fw={500} value="general">
|
||||||
@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="general">
|
<Tabs.Panel value="general">
|
||||||
<SpaceDetails
|
<ScrollArea h={550} scrollbarSize={4} pr={8}>
|
||||||
spaceId={space?.id}
|
<SpaceDetails
|
||||||
readOnly={spaceAbility.cannot(
|
spaceId={space?.id}
|
||||||
SpaceCaslAction.Manage,
|
readOnly={spaceAbility.cannot(
|
||||||
SpaceCaslSubject.Settings,
|
SpaceCaslAction.Manage,
|
||||||
)}
|
SpaceCaslSubject.Settings,
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="members">
|
<Tabs.Panel value="members">
|
||||||
@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
|
|||||||
{spaceAbility.can(
|
{spaceAbility.can(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Member,
|
SpaceCaslSubject.Member,
|
||||||
) && <AddSpaceMembersModal spaceId={space?.id}/>}
|
) && <AddSpaceMembersModal spaceId={space?.id} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SpaceMembersList
|
<SpaceMembersList
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
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 { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { ISpace } from "../../types/space.types";
|
import { ISpace } from "../../types/space.types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface SpaceSelectProps {
|
interface SpaceSelectProps {
|
||||||
onChange: (value: ISpace) => void;
|
onChange: (value: ISpace) => void;
|
||||||
@ -16,7 +18,14 @@ interface SpaceSelectProps {
|
|||||||
|
|
||||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<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>
|
<div>
|
||||||
<Text size="sm" lineClamp={1}>
|
<Text size="sm" lineClamp={1}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@ -50,6 +59,7 @@ export function SpaceSelect({
|
|||||||
return {
|
return {
|
||||||
label: space.name,
|
label: space.name,
|
||||||
value: space.slug,
|
value: space.slug,
|
||||||
|
icon: space.logo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,12 +86,11 @@ export function SpaceSelect({
|
|||||||
onChange={(slug) =>
|
onChange={(slug) =>
|
||||||
onChange(spaces.items?.find((item) => item.slug === slug))
|
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||||
}
|
}
|
||||||
// duct tape
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
nothingFoundMessage={t("No space found")}
|
nothingFoundMessage={t("No space found")}
|
||||||
limit={50}
|
limit={50}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
comboboxProps={{ width, withinPortal: true, position: "bottom" }}
|
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||||
dropdownOpened={opened}
|
dropdownOpened={opened}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -74,7 +74,11 @@ export function SpaceSidebar() {
|
|||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
|
<SwitchSpace
|
||||||
|
spaceName={space?.name}
|
||||||
|
spaceSlug={space?.slug}
|
||||||
|
spaceIcon={space?.logo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
|
|||||||
@ -1,17 +1,25 @@
|
|||||||
import classes from './switch-space.module.css';
|
import classes from "./switch-space.module.css";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { SpaceSelect } from './space-select';
|
import { SpaceSelect } from "./space-select";
|
||||||
import { getSpaceUrl } from '@/lib/config';
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Avatar, Button, Popover, Text } from '@mantine/core';
|
import { Button, Popover, Text } from "@mantine/core";
|
||||||
import { IconChevronDown } from '@tabler/icons-react';
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
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 {
|
interface SwitchSpaceProps {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
|
spaceIcon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
export function SwitchSpace({
|
||||||
|
spaceName,
|
||||||
|
spaceSlug,
|
||||||
|
spaceIcon,
|
||||||
|
}: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
>
|
>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
size={20}
|
name={spaceName}
|
||||||
|
avatarUrl={spaceIcon}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={spaceName}
|
size={20}
|
||||||
/>
|
/>
|
||||||
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
||||||
{spaceName}
|
{spaceName}
|
||||||
@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
<SpaceSelect
|
<SpaceSelect
|
||||||
label={spaceName}
|
label={spaceName}
|
||||||
value={spaceSlug}
|
value={spaceSlug}
|
||||||
onChange={space => handleSelect(space.slug)}
|
onChange={(space) => handleSelect(space.slug)}
|
||||||
width={300}
|
width={300}
|
||||||
opened={true}
|
opened={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,11 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useState } from "react";
|
||||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
|
||||||
import { Button, Divider, Group, Text } from '@mantine/core';
|
import { Button, Divider, Text } from "@mantine/core";
|
||||||
import DeleteSpaceModal from './delete-space-modal';
|
import DeleteSpaceModal from "./delete-space-modal";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import ExportModal from "@/components/common/export-modal.tsx";
|
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 { 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 {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -13,9 +25,40 @@ interface SpaceDetailsProps {
|
|||||||
}
|
}
|
||||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
<Text my="md" fw={600}>
|
<Text my="md" fw={600}>
|
||||||
{t("Details")}
|
{t("Details")}
|
||||||
</Text>
|
</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} />
|
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<ResponsiveSettingsRow>
|
||||||
<div>
|
<ResponsiveSettingsContent>
|
||||||
<Text size="md">{t("Export space")}</Text>
|
<Text size="md">{t("Export space")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("Export all pages and attachments in this space.")}
|
{t("Export all pages and attachments in this space.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</ResponsiveSettingsContent>
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
<Button onClick={openExportModal}>
|
<Button onClick={openExportModal}>{t("Export")}</Button>
|
||||||
{t("Export")}
|
</ResponsiveSettingsControl>
|
||||||
</Button>
|
</ResponsiveSettingsRow>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<ResponsiveSettingsRow>
|
||||||
<div>
|
<ResponsiveSettingsContent>
|
||||||
<Text size="md">{t("Delete space")}</Text>
|
<Text size="md">{t("Delete space")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("Delete this space with all its pages and data.")}
|
{t("Delete this space with all its pages and data.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</ResponsiveSettingsContent>
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
<DeleteSpaceModal space={space} />
|
<DeleteSpaceModal space={space} />
|
||||||
</Group>
|
</ResponsiveSettingsControl>
|
||||||
|
</ResponsiveSettingsRow>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
type="space"
|
type="space"
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardSection {
|
.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 {
|
.title {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||||
import React, { useEffect } from 'react';
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
prefetchSpace,
|
prefetchSpace,
|
||||||
useGetSpacesQuery,
|
useGetSpacesQuery,
|
||||||
@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
|
|||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
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() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -27,8 +29,10 @@ export default function SpaceGrid() {
|
|||||||
withBorder
|
withBorder
|
||||||
>
|
>
|
||||||
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
name={space.name}
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
@ -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 React, { useState } from "react";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
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() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -39,8 +41,10 @@ export default function SpaceList() {
|
|||||||
>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
color="initials"
|
color="initials"
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,13 +6,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Space,
|
Space,
|
||||||
Menu,
|
Menu,
|
||||||
Avatar,
|
|
||||||
Anchor,
|
Anchor,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
|
|||||||
import NoTableResults from "@/components/common/no-table-results";
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
||||||
import classes from "./all-spaces-list.module.css";
|
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 {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
@ -87,11 +88,13 @@ export default function AllSpacesList({
|
|||||||
className={classes.spaceLink}
|
className={classes.spaceLink}
|
||||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
size="md"
|
||||||
size={40}
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
|||||||
@ -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) {
|
if (spaces) {
|
||||||
spaces.items = spaces.items?.filter(
|
spaces.items = spaces.items?.filter(
|
||||||
(space: ISpace) => space.id !== variables.id,
|
(space: ISpace) => space.id !== variables.id,
|
||||||
);
|
);
|
||||||
queryClient.setQueryData(["spaces"], spaces);
|
queryClient.setQueryData(["spaces"], spaces);
|
||||||
}
|
}*/
|
||||||
|
|
||||||
|
// Invalidate all spaces queries to refresh lists
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error["response"]?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
ISpaceMember,
|
ISpaceMember,
|
||||||
} from "@/features/space/types/space.types";
|
} from "@/features/space/types/space.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function getSpaces(
|
export async function getSpaces(
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export interface ISpace {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
logo?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
|
|||||||
@ -1,55 +1,58 @@
|
|||||||
import { focusAtom } from "jotai-optics";
|
import {
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
currentUserAtom,
|
||||||
|
userAtom,
|
||||||
|
} from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||||
import { FileButton, Tooltip } from "@mantine/core";
|
import {
|
||||||
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
uploadUserAvatar,
|
||||||
import { useTranslation } from "react-i18next";
|
removeAvatar,
|
||||||
|
} from "@/features/attachments/services/attachment-service.ts";
|
||||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function AccountAvatar() {
|
export default function AccountAvatar() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setUser] = useAtom(userAtom);
|
const [, setUser] = useAtom(userAtom);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = async (selectedFile: File) => {
|
const handleUpload = async (selectedFile: File) => {
|
||||||
if (!selectedFile) {
|
setIsLoading(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFile(selectedFile);
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
const avatar = await uploadUserAvatar(selectedFile);
|
||||||
const avatar = await uploadAvatar(selectedFile);
|
if (currentUser?.user) {
|
||||||
|
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
|
||||||
setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AvatarUploader
|
||||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
currentImageUrl={currentUser?.user.avatarUrl}
|
||||||
{(props) => (
|
fallbackName={currentUser?.user.name}
|
||||||
<Tooltip label={t("Change photo")} position="bottom">
|
size="60px"
|
||||||
<CustomAvatar
|
type={AvatarIconType.AVATAR}
|
||||||
{...props}
|
onUpload={handleUpload}
|
||||||
component="button"
|
onRemove={handleRemove}
|
||||||
size="60px"
|
isLoading={isLoading}
|
||||||
avatarUrl={currentUser?.user.avatarUrl}
|
/>
|
||||||
name={currentUser?.user.name}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,3 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
|
|||||||
const req = await api.post<IUser>("/users/update", data);
|
const req = await api.post<IUser>("/users/update", data);
|
||||||
return req.data as IUser;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -109,15 +109,3 @@ export async function getAppVersion(): Promise<IVersion> {
|
|||||||
return req.data;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -41,11 +42,14 @@ export function isCloud(): boolean {
|
|||||||
return castToBoolean(getConfigValue("CLOUD"));
|
return castToBoolean(getConfigValue("CLOUD"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(avatarUrl: string) {
|
export function getAvatarUrl(
|
||||||
|
avatarUrl: string,
|
||||||
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
) {
|
||||||
if (!avatarUrl) return null;
|
if (!avatarUrl) return null;
|
||||||
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||||
|
|
||||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSpaceUrl(spaceSlug: string) {
|
export function getSpaceUrl(spaceSlug: string) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export interface QueryParams {
|
|||||||
query?: string;
|
query?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
@ -36,20 +37,3 @@ export type IPagination<T> = {
|
|||||||
items: T[];
|
items: T[];
|
||||||
meta: IPaginationMeta;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/spotlight/styles.css";
|
import "@mantine/spotlight/styles.css";
|
||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { mantineCssResolver, theme } from "@/theme";
|
import { mantineCssResolver, theme } from "@/theme";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
@ -14,6 +15,7 @@ export default function WorkspaceSettings() {
|
|||||||
<title>Workspace Settings - {getAppName()}</title>
|
<title>Workspace Settings - {getAppName()}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("General")} />
|
<SettingsTitle title={t("General")} />
|
||||||
|
<WorkspaceIcon />
|
||||||
<WorkspaceNameForm />
|
<WorkspaceNameForm />
|
||||||
|
|
||||||
{isCloud() && (
|
{isCloud() && (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.23.0",
|
"version": "0.23.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -37,6 +37,7 @@
|
|||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.0.3",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.2",
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^11.1.3",
|
"@nestjs/common": "^11.1.3",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
@ -55,14 +56,15 @@
|
|||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.53.2",
|
"bullmq": "^5.61.0",
|
||||||
"cache-manager": "^6.4.3",
|
"cache-manager": "^6.4.3",
|
||||||
"cheerio": "^1.1.0",
|
"cheerio": "^1.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"happy-dom": "^15.11.6",
|
"happy-dom": "^18.0.1",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
@ -84,10 +86,12 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.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",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^17.5.0",
|
"stripe": "^17.5.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
|
"typesense": "^2.1.0",
|
||||||
"ws": "^8.18.2",
|
"ws": "^8.18.2",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import { ExportModule } from './integrations/export/export.module';
|
|||||||
import { ImportModule } from './integrations/import/import.module';
|
import { ImportModule } from './integrations/import/import.module';
|
||||||
import { SecurityModule } from './integrations/security/security.module';
|
import { SecurityModule } from './integrations/security/security.module';
|
||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.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 = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@ -36,6 +38,9 @@ try {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
RedisModule.forRootAsync({
|
||||||
|
useClass: RedisConfigService,
|
||||||
|
}),
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
WsModule,
|
WsModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
|
|||||||
@ -35,11 +35,10 @@ import {
|
|||||||
Subpages,
|
Subpages,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
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
|
// @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/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
import { generateJSON } from '@tiptap/html';
|
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
export enum EventName {
|
export enum EventName {
|
||||||
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
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',
|
||||||
}
|
}
|
||||||
@ -1,21 +1,29 @@
|
|||||||
import { Extensions, getSchema, JSONContent } from '@tiptap/core';
|
import { type Extensions, type JSONContent, getSchema } from '@tiptap/core';
|
||||||
import { DOMSerializer, Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { Window } from 'happy-dom';
|
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 {
|
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 schema = getSchema(extensions);
|
||||||
const contentNode = Node.fromJSON(schema, doc);
|
const contentNode = Node.fromJSON(schema, doc);
|
||||||
|
|
||||||
const window = new Window();
|
return getHTMLFromFragment(contentNode, schema);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,55 @@
|
|||||||
import { Extensions, getSchema } from '@tiptap/core';
|
import type { Extensions } from '@tiptap/core';
|
||||||
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
|
import { getSchema } from '@tiptap/core';
|
||||||
|
import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
|
||||||
import { Window } from 'happy-dom';
|
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(
|
export function generateJSON(
|
||||||
html: string,
|
html: string,
|
||||||
extensions: Extensions,
|
extensions: Extensions,
|
||||||
options?: ParseOptions,
|
options?: ParseOptions,
|
||||||
): Record<string, any> {
|
): 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 localWindow = new Window();
|
||||||
const document = window.document;
|
const localDOMParser = new localWindow.DOMParser();
|
||||||
document.body.innerHTML = html;
|
let result: Record<string, any>;
|
||||||
|
|
||||||
return DOMParser.fromSchema(schema)
|
try {
|
||||||
.parse(document as never, options)
|
const schema = getSchema(extensions);
|
||||||
.toJSON();
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -72,7 +72,9 @@ export function extractDateFromUuid7(uuid7: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeFileName(fileName: string): 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);
|
return sanitizedFilename.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
apps/server/src/common/validator/is-iso6391.ts
Normal file
34
apps/server/src/common/validator/is-iso6391.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
export enum AttachmentType {
|
export enum AttachmentType {
|
||||||
Avatar = 'avatar',
|
Avatar = 'avatar',
|
||||||
WorkspaceLogo = 'workspace-logo',
|
WorkspaceIcon = 'workspace-icon',
|
||||||
SpaceLogo = 'space-logo',
|
SpaceIcon = 'space-icon',
|
||||||
File = 'file',
|
File = 'file',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||||
export const MAX_AVATAR_SIZE = '5MB';
|
export const MAX_AVATAR_SIZE = '10MB';
|
||||||
|
|
||||||
export const inlineFileExtensions = [
|
export const inlineFileExtensions = [
|
||||||
'.jpg',
|
'.jpg',
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
@ -51,6 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
|||||||
import { TokenService } from '../auth/services/token.service';
|
import { TokenService } from '../auth/services/token.service';
|
||||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { RemoveIconDto } from './dto/attachment.dto';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@ -302,7 +304,7 @@ export class AttachmentController {
|
|||||||
throw new BadRequestException('Invalid image attachment type');
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
if (attachmentType === AttachmentType.WorkspaceIcon) {
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
if (
|
if (
|
||||||
ability.cannot(
|
ability.cannot(
|
||||||
@ -314,7 +316,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.SpaceLogo) {
|
if (attachmentType === AttachmentType.SpaceIcon) {
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
throw new BadRequestException('spaceId is required');
|
throw new BadRequestException('spaceId is required');
|
||||||
}
|
}
|
||||||
@ -372,8 +374,59 @@ export class AttachmentController {
|
|||||||
});
|
});
|
||||||
return res.send(fileStream);
|
return res.send(fileStream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
// this.logger.error(err);
|
||||||
throw new NotFoundException('File not found');
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AttachmentType } from './attachment.constants';
|
import { AttachmentType } from './attachment.constants';
|
||||||
|
import { sanitizeFileName } from '../../common/helpers';
|
||||||
|
import * as sharp from 'sharp';
|
||||||
|
|
||||||
export interface PreparedFile {
|
export interface PreparedFile {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
@ -22,10 +22,8 @@ export async function prepareFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rand = randomBytes(8).toString('hex');
|
|
||||||
|
|
||||||
const buffer = await file.toBuffer();
|
const buffer = await file.toBuffer();
|
||||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||||
const fileName = sanitizedFilename.slice(0, 255);
|
const fileName = sanitizedFilename.slice(0, 255);
|
||||||
const fileSize = buffer.length;
|
const fileSize = buffer.length;
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case AttachmentType.Avatar:
|
case AttachmentType.Avatar:
|
||||||
return `${workspaceId}/avatars`;
|
return `${workspaceId}/avatars`;
|
||||||
case AttachmentType.WorkspaceLogo:
|
case AttachmentType.WorkspaceIcon:
|
||||||
return `${workspaceId}/workspace-logo`;
|
return `${workspaceId}/workspace-logos`;
|
||||||
case AttachmentType.SpaceLogo:
|
case AttachmentType.SpaceIcon:
|
||||||
return `${workspaceId}/space-logos`;
|
return `${workspaceId}/space-logos`;
|
||||||
case AttachmentType.File:
|
case AttachmentType.File:
|
||||||
return `${workspaceId}/files`;
|
return `${workspaceId}/files`;
|
||||||
@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
apps/server/src/core/attachment/dto/attachment.dto.ts
Normal file
17
apps/server/src/core/attachment/dto/attachment.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,3 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class AvatarUploadDto {}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class GetFileDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
attachmentId: string;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -67,9 +67,15 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
|
|
||||||
@OnWorkerEvent('failed')
|
@OnWorkerEvent('failed')
|
||||||
onError(job: Job) {
|
onError(job: Job) {
|
||||||
this.logger.error(
|
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
|
||||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
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')
|
@OnWorkerEvent('completed')
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
|
compressAndResizeIcon,
|
||||||
getAttachmentFolderPath,
|
getAttachmentFolderPath,
|
||||||
PreparedFile,
|
PreparedFile,
|
||||||
prepareFile,
|
prepareFile,
|
||||||
@ -16,7 +17,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
|
|||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { AttachmentType, validImageExtensions } from '../attachment.constants';
|
import { AttachmentType, validImageExtensions } from '../attachment.constants';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
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 { InjectKysely } from 'nestjs-kysely';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
@ -132,8 +133,8 @@ export class AttachmentService {
|
|||||||
filePromise: Promise<MultipartFile>,
|
filePromise: Promise<MultipartFile>,
|
||||||
type:
|
type:
|
||||||
| AttachmentType.Avatar
|
| AttachmentType.Avatar
|
||||||
| AttachmentType.WorkspaceLogo
|
| AttachmentType.WorkspaceIcon
|
||||||
| AttachmentType.SpaceLogo,
|
| AttachmentType.SpaceIcon,
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
spaceId?: string,
|
spaceId?: string,
|
||||||
@ -141,6 +142,9 @@ export class AttachmentService {
|
|||||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||||
|
|
||||||
|
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
||||||
|
preparedFile.buffer = processedBuffer;
|
||||||
|
preparedFile.fileSize = processedBuffer.length;
|
||||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
||||||
@ -174,7 +178,7 @@ export class AttachmentService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
} else if (type === AttachmentType.WorkspaceLogo) {
|
} else if (type === AttachmentType.WorkspaceIcon) {
|
||||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
@ -186,7 +190,7 @@ export class AttachmentService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
} else if (type === AttachmentType.SpaceLogo && spaceId) {
|
} else if (type === AttachmentType.SpaceIcon && spaceId) {
|
||||||
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
|
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
@ -205,7 +209,6 @@ export class AttachmentService {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// delete uploaded file on db update failure
|
// delete uploaded file on db update failure
|
||||||
this.logger.error('Image upload error:', err);
|
|
||||||
await this.deleteRedundantFile(filePath);
|
await this.deleteRedundantFile(filePath);
|
||||||
throw new BadRequestException('Failed to upload image');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export enum JwtType {
|
|||||||
EXCHANGE = 'exchange',
|
EXCHANGE = 'exchange',
|
||||||
ATTACHMENT = 'attachment',
|
ATTACHMENT = 'attachment',
|
||||||
MFA_TOKEN = 'mfa_token',
|
MFA_TOKEN = 'mfa_token',
|
||||||
|
API_KEY = 'api_key',
|
||||||
}
|
}
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: 'mfa_token';
|
type: 'mfa_token';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JwtApiKeyPayload = {
|
||||||
|
sub: string;
|
||||||
|
workspaceId: string;
|
||||||
|
apiKeyId: string;
|
||||||
|
type: 'api_key';
|
||||||
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
|
JwtApiKeyPayload,
|
||||||
JwtAttachmentPayload,
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
@ -77,10 +78,7 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMfaToken(
|
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
|
||||||
user: User,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<string> {
|
|
||||||
if (user.deactivatedAt || user.deletedAt) {
|
if (user.deactivatedAt || user.deletedAt) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
@ -93,6 +91,27 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '5m' });
|
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) {
|
async verifyJwt(token: string, tokenType: string) {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: this.environmentService.getAppSecret(),
|
secret: this.environmentService.getAppSecret(),
|
||||||
|
|||||||
@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
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 { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private workspaceRepo: WorkspaceRepo,
|
private workspaceRepo: WorkspaceRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: FastifyRequest) => {
|
jwtFromRequest: (req: FastifyRequest) => {
|
||||||
@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(req: any, payload: JwtPayload) {
|
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
|
||||||
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
|
if (!payload.workspaceId) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
throw new UnauthorizedException('Workspace does not match');
|
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);
|
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return { user, workspace };
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
|
|||||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
||||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
|
|||||||
Space = 'space',
|
Space = 'space',
|
||||||
Group = 'group',
|
Group = 'group',
|
||||||
Attachment = 'attachment',
|
Attachment = 'attachment',
|
||||||
|
API = 'api_key',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IWorkspaceAbility =
|
export type IWorkspaceAbility =
|
||||||
@ -18,4 +19,5 @@ export type IWorkspaceAbility =
|
|||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
|
||||||
|
|||||||
@ -9,6 +9,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
|||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [StorageModule]
|
imports: [StorageModule],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
|
import { EventName } from '../../../common/events/event.contants';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
@ -49,6 +51,7 @@ export class PageService {
|
|||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@ -231,21 +234,28 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update spaceId in shares
|
|
||||||
if (pageIds.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
|
// update spaceId in shares
|
||||||
await trx
|
await trx
|
||||||
.updateTable('shares')
|
.updateTable('shares')
|
||||||
.set({ spaceId: spaceId })
|
.set({ spaceId: spaceId })
|
||||||
.where('pageId', 'in', pageIds)
|
.where('pageId', 'in', pageIds)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
|
||||||
|
|
||||||
// Update attachments
|
// Update comments
|
||||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
await trx
|
||||||
{ spaceId },
|
.updateTable('comments')
|
||||||
pageIds,
|
.set({ spaceId: spaceId })
|
||||||
trx,
|
.where('pageId', 'in', pageIds)
|
||||||
);
|
.execute();
|
||||||
|
|
||||||
|
// Update attachments
|
||||||
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
|
{ spaceId },
|
||||||
|
pageIds,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,15 +381,20 @@ export class PageService {
|
|||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
creatorId: authUser.id,
|
creatorId: authUser.id,
|
||||||
lastUpdatedById: authUser.id,
|
lastUpdatedById: authUser.id,
|
||||||
parentPageId: page.parentPageId
|
parentPageId: page.id === rootPage.id
|
||||||
? pageMap.get(page.parentPageId)?.newPageId
|
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
|
||||||
: null,
|
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
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
|
//TODO: best to handle this in a queue
|
||||||
const attachmentsIds = Array.from(attachmentMap.keys());
|
const attachmentsIds = Array.from(attachmentMap.keys());
|
||||||
if (attachmentsIds.length > 0) {
|
if (attachmentsIds.length > 0) {
|
||||||
@ -606,6 +621,9 @@ export class PageService {
|
|||||||
|
|
||||||
if (pageIds.length > 0) {
|
if (pageIds.length > 0) {
|
||||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||||
|
this.eventEmitter.emit(EventName.PAGE_DELETED, {
|
||||||
|
pageIds: pageIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Space } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
export class SearchResponseDto {
|
export class SearchResponseDto {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -8,4 +10,5 @@ export class SearchResponseDto {
|
|||||||
highlight: string;
|
highlight: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
space: Partial<Space>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -24,13 +25,19 @@ import {
|
|||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
import { Public } from 'src/common/decorators/public.decorator';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('search')
|
@Controller('search')
|
||||||
export class SearchController {
|
export class SearchController {
|
||||||
|
private readonly logger = new Logger(SearchController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly searchService: SearchService,
|
private readonly searchService: SearchService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@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,
|
userId: user.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
@ -81,8 +95,47 @@ export class SearchController {
|
|||||||
throw new BadRequestException('shareId is required');
|
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,
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,14 @@ export class SearchService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
query: string,
|
|
||||||
searchParams: SearchDTO,
|
searchParams: SearchDTO,
|
||||||
opts: {
|
opts: {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
},
|
},
|
||||||
): Promise<SearchResponseDto[]> {
|
): Promise<SearchResponseDto[]> {
|
||||||
|
const { query } = searchParams;
|
||||||
|
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
restrictApiToAdmins: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service';
|
|||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.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
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo
|
ShareRepo,
|
||||||
|
PageListener,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@ -90,7 +92,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo
|
ShareRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
49
apps/server/src/database/listeners/page.listener.ts
Normal file
49
apps/server/src/database/listeners/page.listener.ts
Normal 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
Reference in New Issue
Block a user