Compare commits

...

27 Commits

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

* improvements

* fix table

* fix route

* remove token suffix

* api settings

* Fix

* fix

* fix

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

* feat: typesense driver (EE) - WIP

* feat: typesense

* sync

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

* fix processor

* fix page name in notion import

* preserve confluence table bg color

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

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

* dark mode

* fixes

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

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

* sync

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

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "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",

View File

@ -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"
} }

View File

@ -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 />} />

View File

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

View File

@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps { 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>

View File

@ -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}

View File

@ -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 }),
});
};

View File

@ -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;
} }

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import {
BubbleMenuProps, 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();

View File

@ -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) }} />
) )
} }

View File

@ -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,
}, },
]; ];

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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";

View File

@ -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>

View File

@ -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";

View File

@ -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>
); );

View File

@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import 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}
/> />

View File

@ -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={{

View File

@ -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={() => {

View File

@ -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>
); );

View File

@ -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,

View File

@ -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} />

View File

@ -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;

View File

@ -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),

View File

@ -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),

View File

@ -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],
}); });

View File

@ -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">

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -1,10 +1,10 @@
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core"; import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx"; import 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

View File

@ -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}
/> />
); );

View File

@ -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}>

View File

@ -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}
/> />

View File

@ -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"

View File

@ -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 {

View File

@ -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"

View File

@ -1,4 +1,4 @@
import { Table, Group, Text, Avatar } from "@mantine/core"; import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react"; import 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}
/> />

View File

@ -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}>

View File

@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
}); });
} }
const spaces = queryClient.getQueryData(["spaces"]) as any; // Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) { 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;

View File

@ -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(

View File

@ -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;

View File

@ -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>
</>
); );
} }

View File

@ -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;
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import bytes from "bytes"; import 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) {

View File

@ -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;
}

View File

@ -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";

View File

@ -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() && (

View File

@ -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"
}, },

View File

@ -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,

View File

@ -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 = [

View File

@ -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',
} }

View File

@ -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);
} }

View File

@ -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;
} }

View File

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

View File

@ -72,7 +72,9 @@ export function extractDateFromUuid7(uuid7: string) {
} }
export function sanitizeFileName(fileName: string): string { 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);
} }

View File

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

View File

@ -1,12 +1,12 @@
export enum AttachmentType { 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',

View File

@ -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;
}
}
} }

View File

@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -67,9 +67,15 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed') @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')

View File

@ -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);
}
} }

View File

@ -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';
};

View File

@ -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(),

View File

@ -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');
}
} }

View File

@ -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();
} }

View File

@ -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];

View File

@ -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 {}

View File

@ -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,
});
} }
} }

View File

@ -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>;
} }

View File

@ -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');
}
} }

View File

@ -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;
} }

View File

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

View File

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

View File

@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { 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

View File

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

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