diff --git a/apps/client/package.json b/apps/client/package.json index 20a8539c..031646e8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.23.0", + "version": "0.23.1", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 1dd6ef58..6d9e548b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -527,5 +527,11 @@ "Delete SSO provider": "Delete SSO provider", "Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?", "Action": "Action", - "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration" + "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration", + "Icon": "Icon", + "Upload image": "Upload image", + "Remove image": "Remove image", + "Failed to remove image": "Failed to remove image", + "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", + "Image removed successfully": "Image removed successfully" } diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx new file mode 100644 index 00000000..0c83411c --- /dev/null +++ b/apps/client/src/components/common/avatar-uploader.tsx @@ -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; + onRemove: () => Promise; + 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(null); + + const handleFileInputChange = async ( + event: React.ChangeEvent, + ) => { + 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 ( + + + + + + + + {isLoading && ( + + + + )} + + + + + } + disabled={isLoading || disabled} + onClick={handleUploadClick} + > + {t("Upload image")} + + + {currentImageUrl && ( + } + color="red" + onClick={handleRemove} + disabled={isLoading || disabled} + > + {t("Remove image")} + + )} + + + + ); +} diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx index d3a89ecc..84925080 100644 --- a/apps/client/src/components/layouts/global/top-menu.tsx +++ b/apps/client/src/components/layouts/global/top-menu.tsx @@ -1,8 +1,8 @@ import { Group, Menu, - UnstyledButton, Text, + UnstyledButton, useMantineColorScheme, } from "@mantine/core"; import { @@ -10,7 +10,6 @@ import { IconBrush, IconCheck, IconChevronDown, - IconChevronRight, IconDeviceDesktop, IconLogout, IconMoon, @@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts"; import useAuth from "@/features/auth/hooks/use-auth.ts"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { useTranslation } from "react-i18next"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; export default function TopMenu() { const { t } = useTranslation(); @@ -50,6 +50,7 @@ export default function TopMenu() { name={workspace?.name} variant="filled" size="sm" + type={AvatarIconType.WORKSPACE_ICON} /> {workspace?.name} diff --git a/apps/client/src/components/ui/custom-avatar.tsx b/apps/client/src/components/ui/custom-avatar.tsx index de4456e5..54730127 100644 --- a/apps/client/src/components/ui/custom-avatar.tsx +++ b/apps/client/src/components/ui/custom-avatar.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Avatar } from "@mantine/core"; import { getAvatarUrl } from "@/lib/config.ts"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; interface CustomAvatarProps { avatarUrl: string; @@ -11,13 +12,15 @@ interface CustomAvatarProps { variant?: string; style?: any; component?: any; + type?: AvatarIconType; + mt?: string | number; } export const CustomAvatar = React.forwardRef< HTMLInputElement, CustomAvatarProps ->(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => { - const avatarLink = getAvatarUrl(avatarUrl); +>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => { + const avatarLink = getAvatarUrl(avatarUrl, type); return ( { + 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 { + return uploadIcon(file, AvatarIconType.AVATAR); +} + +export async function uploadSpaceIcon( + file: File, + spaceId: string, +): Promise { + return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId); +} + +export async function uploadWorkspaceIcon(file: File): Promise { + return uploadIcon(file, AvatarIconType.WORKSPACE_ICON); +} + +async function removeIcon( + type: AvatarIconType, + spaceId?: string, +): Promise { + const payload: { spaceId?: string; type: string } = { type }; + + if (spaceId) { + payload.spaceId = spaceId; + } + + await api.post("/attachments/remove-icon", payload); +} + +export async function removeAvatar(): Promise { + await removeIcon(AvatarIconType.AVATAR); +} + +export async function removeSpaceIcon(spaceId: string): Promise { + await removeIcon(AvatarIconType.SPACE_ICON, spaceId); +} + +export async function removeWorkspaceIcon(): Promise { + await removeIcon(AvatarIconType.WORKSPACE_ICON); +} diff --git a/apps/client/src/features/attachments/services/index.ts b/apps/client/src/features/attachments/services/index.ts new file mode 100644 index 00000000..1732ba9f --- /dev/null +++ b/apps/client/src/features/attachments/services/index.ts @@ -0,0 +1,9 @@ +export { + uploadIcon, + uploadUserAvatar, + uploadSpaceIcon, + uploadWorkspaceIcon, + removeAvatar, + removeSpaceIcon, + removeWorkspaceIcon, +} from "./attachment-service.ts"; diff --git a/apps/client/src/features/attachments/types/attachment.types.ts b/apps/client/src/features/attachments/types/attachment.types.ts new file mode 100644 index 00000000..018d8c7c --- /dev/null +++ b/apps/client/src/features/attachments/types/attachment.types.ts @@ -0,0 +1,29 @@ +export interface IAttachment { + id: string; + fileName: string; + filePath: string; + fileSize: number; + fileExt: string; + mimeType: string; + type: string; + creatorId: string; + pageId: string | null; + spaceId: string | null; + workspaceId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export enum AvatarIconType { + AVATAR = "avatar", + SPACE_ICON = "space-icon", + WORKSPACE_ICON = "workspace-icon", +} + +export enum AttachmentType { + AVATAR = "avatar", + WORKSPACE_ICON = "workspace-icon", + SPACE_ICON = "space-icon", + FILE = "file", +} diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index bb901044..c09c628e 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -17,7 +17,7 @@ import { EventExit, EventSave, } from "react-drawio"; -import { IAttachment } from "@/lib/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 35d8de94..11b5e363 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks"; import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; -import { IAttachment } from "@/lib/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 51009b43..92ed8c29 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -165,7 +165,7 @@ export const mainExtensions = [ }), CustomTable.configure({ resizable: true, - lastColumnResizable: false, + lastColumnResizable: true, allowTableNodeSelection: true, }), TableRow, diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 051921de..479d000d 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -94,8 +94,12 @@ hr { border: none; - border-top: 2px solid #ced4da; - margin: 2rem 0; + @mixin light { + border-top: 1px solid var(--mantine-color-gray-4); + } + @mixin dark { + border-top: 1px solid var(--mantine-color-dark-4); + } &:hover { cursor: pointer; diff --git a/apps/client/src/features/page/components/page-import-modal.tsx b/apps/client/src/features/page/components/page-import-modal.tsx index 0223d78f..a2df380e 100644 --- a/apps/client/src/features/page/components/page-import-modal.tsx +++ b/apps/client/src/features/page/components/page-import-modal.tsx @@ -24,7 +24,7 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { useAtom } from "jotai"; import { buildTree } from "@/features/page/tree/utils"; import { IPage } from "@/features/page/types/page.types.ts"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; @@ -84,6 +84,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { const [fileTaskId, setFileTaskId] = useState(null); const emit = useQueryEmit(); + const markdownFileRef = useRef<() => void>(null); + const htmlFileRef = useRef<() => void>(null); + const notionFileRef = useRef<() => void>(null); + const confluenceFileRef = useRef<() => void>(null); + const zipFileRef = useRef<() => void>(null); + const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const handleZipUpload = async (selectedFile: File, source: string) => { @@ -116,6 +122,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { }); setFileTaskId(importTask.id); + + // Reset file input after successful upload + if (source === "notion" && notionFileRef.current) { + notionFileRef.current(); + } else if (source === "confluence" && confluenceFileRef.current) { + confluenceFileRef.current(); + } else if (source === "generic" && zipFileRef.current) { + zipFileRef.current(); + } } catch (err) { console.log("Failed to upload import file", err); notifications.update({ @@ -243,6 +258,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { setTreeData(fullTree); } + // Reset file inputs after successful upload + if (markdownFileRef.current) markdownFileRef.current(); + if (htmlFileRef.current) htmlFileRef.current(); + const pageCountText = pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; @@ -272,7 +291,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { return ( <> - + {(props) => ( - + + + + + - -
+ + {t("Delete space")} {t("Delete this space with all its pages and data.")} -
- - -
+ + + + + - {cards} - + {data?.items && data.items.length > 9 && (