mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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
This commit is contained in:
@@ -527,5 +527,11 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
import { getAvatarUrl } from "@/lib/config.ts";
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface CustomAvatarProps {
|
interface CustomAvatarProps {
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
@@ -11,13 +12,15 @@ interface CustomAvatarProps {
|
|||||||
variant?: string;
|
variant?: string;
|
||||||
style?: any;
|
style?: any;
|
||||||
component?: any;
|
component?: any;
|
||||||
|
type?: AvatarIconType;
|
||||||
|
mt?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
uploadIcon,
|
||||||
|
uploadUserAvatar,
|
||||||
|
uploadSpaceIcon,
|
||||||
|
uploadWorkspaceIcon,
|
||||||
|
removeAvatar,
|
||||||
|
removeSpaceIcon,
|
||||||
|
removeWorkspaceIcon,
|
||||||
|
} from "./attachment-service.ts";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface IAttachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
mimeType: string;
|
||||||
|
type: string;
|
||||||
|
creatorId: string;
|
||||||
|
pageId: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AvatarIconType {
|
||||||
|
AVATAR = "avatar",
|
||||||
|
SPACE_ICON = "space-icon",
|
||||||
|
WORKSPACE_ICON = "workspace-icon",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AttachmentType {
|
||||||
|
AVATAR = "avatar",
|
||||||
|
WORKSPACE_ICON = "workspace-icon",
|
||||||
|
SPACE_ICON = "space-icon",
|
||||||
|
FILE = "file",
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -94,7 +94,12 @@
|
|||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #ced4da;
|
@mixin light {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
|
import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core";
|
||||||
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||||
import React, {useMemo} from "react";
|
import React from "react";
|
||||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||||
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
@@ -39,16 +39,18 @@ export default function SpaceSettingsModal({
|
|||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
>
|
>
|
||||||
<Modal.Overlay/>
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{overflow: "hidden"}}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
<Text fw={500} lineClamp={1}>{space?.name}</Text>
|
<Text fw={500} lineClamp={1}>
|
||||||
|
{space?.name}
|
||||||
|
</Text>
|
||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Modal.CloseButton/>
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<div style={{height: rem(600)}}>
|
<div style={{ height: rem(600) }}>
|
||||||
<Tabs defaultValue="members">
|
<Tabs defaultValue="members">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab fw={500} value="general">
|
<Tabs.Tab fw={500} value="general">
|
||||||
@@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="general">
|
<Tabs.Panel value="general">
|
||||||
<SpaceDetails
|
<ScrollArea h={550} scrollbarSize={4} pr={8}>
|
||||||
spaceId={space?.id}
|
<SpaceDetails
|
||||||
readOnly={spaceAbility.cannot(
|
spaceId={space?.id}
|
||||||
SpaceCaslAction.Manage,
|
readOnly={spaceAbility.cannot(
|
||||||
SpaceCaslSubject.Settings,
|
SpaceCaslAction.Manage,
|
||||||
)}
|
SpaceCaslSubject.Settings,
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="members">
|
<Tabs.Panel value="members">
|
||||||
@@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
|
|||||||
{spaceAbility.can(
|
{spaceAbility.can(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Member,
|
SpaceCaslSubject.Member,
|
||||||
) && <AddSpaceMembersModal spaceId={space?.id}/>}
|
) && <AddSpaceMembersModal spaceId={space?.id} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SpaceMembersList
|
<SpaceMembersList
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
|
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { ISpace } from "../../types/space.types";
|
import { ISpace } from "../../types/space.types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface SpaceSelectProps {
|
interface SpaceSelectProps {
|
||||||
onChange: (value: ISpace) => void;
|
onChange: (value: ISpace) => void;
|
||||||
@@ -16,7 +18,14 @@ interface SpaceSelectProps {
|
|||||||
|
|
||||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
<CustomAvatar
|
||||||
|
name={option.label}
|
||||||
|
avatarUrl={option?.["icon"]}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
color="initials"
|
||||||
|
variant="filled"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" lineClamp={1}>
|
<Text size="sm" lineClamp={1}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -50,6 +59,7 @@ export function SpaceSelect({
|
|||||||
return {
|
return {
|
||||||
label: space.name,
|
label: space.name,
|
||||||
value: space.slug,
|
value: space.slug,
|
||||||
|
icon: space.logo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +86,6 @@ 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}
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ export function SpaceSidebar() {
|
|||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
|
<SwitchSpace
|
||||||
|
spaceName={space?.name}
|
||||||
|
spaceSlug={space?.slug}
|
||||||
|
spaceIcon={space?.logo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import classes from './switch-space.module.css';
|
import classes from "./switch-space.module.css";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { SpaceSelect } from './space-select';
|
import { SpaceSelect } from "./space-select";
|
||||||
import { getSpaceUrl } from '@/lib/config';
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Avatar, Button, Popover, Text } from '@mantine/core';
|
import { Button, Popover, Text } from "@mantine/core";
|
||||||
import { IconChevronDown } from '@tabler/icons-react';
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface SwitchSpaceProps {
|
interface SwitchSpaceProps {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
|
spaceIcon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
export function SwitchSpace({
|
||||||
|
spaceName,
|
||||||
|
spaceSlug,
|
||||||
|
spaceIcon,
|
||||||
|
}: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
@@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
>
|
>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
size={20}
|
name={spaceName}
|
||||||
|
avatarUrl={spaceIcon}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={spaceName}
|
size={20}
|
||||||
/>
|
/>
|
||||||
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
||||||
{spaceName}
|
{spaceName}
|
||||||
@@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
<SpaceSelect
|
<SpaceSelect
|
||||||
label={spaceName}
|
label={spaceName}
|
||||||
value={spaceSlug}
|
value={spaceSlug}
|
||||||
onChange={space => handleSelect(space.slug)}
|
onChange={(space) => handleSelect(space.slug)}
|
||||||
width={300}
|
width={300}
|
||||||
opened={true}
|
opened={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useState } from "react";
|
||||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
|
||||||
import { Button, Divider, Group, Text } from '@mantine/core';
|
import { Button, Divider, Text } from "@mantine/core";
|
||||||
import DeleteSpaceModal from './delete-space-modal';
|
import DeleteSpaceModal from "./delete-space-modal";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import ExportModal from "@/components/common/export-modal.tsx";
|
import ExportModal from "@/components/common/export-modal.tsx";
|
||||||
|
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||||
|
import {
|
||||||
|
uploadSpaceIcon,
|
||||||
|
removeSpaceIcon,
|
||||||
|
} from "@/features/attachments/services/attachment-service.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
ResponsiveSettingsContent,
|
||||||
|
ResponsiveSettingsControl,
|
||||||
|
ResponsiveSettingsRow,
|
||||||
|
} from "@/components/ui/responsive-settings-row.tsx";
|
||||||
|
|
||||||
interface SpaceDetailsProps {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -13,9 +25,40 @@ interface SpaceDetailsProps {
|
|||||||
}
|
}
|
||||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleIconUpload = async (file: File) => {
|
||||||
|
setIsIconUploading(true);
|
||||||
|
try {
|
||||||
|
await uploadSpaceIcon(file, spaceId);
|
||||||
|
await refetch();
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// skip
|
||||||
|
} finally {
|
||||||
|
setIsIconUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconRemove = async () => {
|
||||||
|
setIsIconUploading(true);
|
||||||
|
try {
|
||||||
|
await removeSpaceIcon(spaceId);
|
||||||
|
await refetch();
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// skip
|
||||||
|
} finally {
|
||||||
|
setIsIconUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
<Text my="md" fw={600}>
|
<Text my="md" fw={600}>
|
||||||
{t("Details")}
|
{t("Details")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
{t("Icon")}
|
||||||
|
</Text>
|
||||||
|
<AvatarUploader
|
||||||
|
currentImageUrl={space.logo}
|
||||||
|
fallbackName={space.name}
|
||||||
|
size={"60px"}
|
||||||
|
variant="filled"
|
||||||
|
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
onUpload={handleIconUpload}
|
||||||
|
onRemove={handleIconRemove}
|
||||||
|
isLoading={isIconUploading}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<ResponsiveSettingsRow>
|
||||||
<div>
|
<ResponsiveSettingsContent>
|
||||||
<Text size="md">{t("Export space")}</Text>
|
<Text size="md">{t("Export space")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("Export all pages and attachments in this space.")}
|
{t("Export all pages and attachments in this space.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</ResponsiveSettingsContent>
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
<Button onClick={openExportModal}>
|
<Button onClick={openExportModal}>{t("Export")}</Button>
|
||||||
{t("Export")}
|
</ResponsiveSettingsControl>
|
||||||
</Button>
|
</ResponsiveSettingsRow>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<ResponsiveSettingsRow>
|
||||||
<div>
|
<ResponsiveSettingsContent>
|
||||||
<Text size="md">{t("Delete space")}</Text>
|
<Text size="md">{t("Delete space")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("Delete this space with all its pages and data.")}
|
{t("Delete this space with all its pages and data.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</ResponsiveSettingsContent>
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
<DeleteSpaceModal space={space} />
|
<DeleteSpaceModal space={space} />
|
||||||
</Group>
|
</ResponsiveSettingsControl>
|
||||||
|
</ResponsiveSettingsRow>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
type="space"
|
type="space"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardSection {
|
.cardSection {
|
||||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||||
import React, { useEffect } from 'react';
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
prefetchSpace,
|
prefetchSpace,
|
||||||
useGetSpacesQuery,
|
useGetSpacesQuery,
|
||||||
@@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
|
|||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -27,8 +29,10 @@ export default function SpaceGrid() {
|
|||||||
withBorder
|
withBorder
|
||||||
>
|
>
|
||||||
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
name={space.name}
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -54,7 +58,7 @@ export default function SpaceGrid() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||||
|
|
||||||
{data?.items && data.items.length > 9 && (
|
{data?.items && data.items.length > 9 && (
|
||||||
<Group justify="flex-end" mt="lg">
|
<Group justify="flex-end" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Table, Group, Text, Avatar } from "@mantine/core";
|
import { Group, Table, Text } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
@@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -39,8 +41,10 @@ export default function SpaceList() {
|
|||||||
>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
color="initials"
|
color="initials"
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Space,
|
Space,
|
||||||
Menu,
|
Menu,
|
||||||
Avatar,
|
|
||||||
Anchor,
|
Anchor,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
@@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
|
|||||||
import NoTableResults from "@/components/common/no-table-results";
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
||||||
import classes from "./all-spaces-list.module.css";
|
import classes from "./all-spaces-list.module.css";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
@@ -87,11 +88,13 @@ export default function AllSpacesList({
|
|||||||
className={classes.spaceLink}
|
className={classes.spaceLink}
|
||||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<CustomAvatar
|
||||||
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
color="initials"
|
color="initials"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
name={space.name}
|
size="md"
|
||||||
size={40}
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ISpaceMember,
|
ISpaceMember,
|
||||||
} from "@/features/space/types/space.types";
|
} from "@/features/space/types/space.types";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
export async function getSpaces(
|
export async function getSpaces(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ISpace {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
logo?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
@@ -74,4 +74,4 @@ export interface IExportSpaceParams {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,58 @@
|
|||||||
import { focusAtom } from "jotai-optics";
|
import {
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
currentUserAtom,
|
||||||
|
userAtom,
|
||||||
|
} from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||||
import { FileButton, Tooltip } from "@mantine/core";
|
import {
|
||||||
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
uploadUserAvatar,
|
||||||
import { useTranslation } from "react-i18next";
|
removeAvatar,
|
||||||
|
} from "@/features/attachments/services/attachment-service.ts";
|
||||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function AccountAvatar() {
|
export default function AccountAvatar() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setUser] = useAtom(userAtom);
|
const [, setUser] = useAtom(userAtom);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = async (selectedFile: File) => {
|
const handleUpload = async (selectedFile: File) => {
|
||||||
if (!selectedFile) {
|
setIsLoading(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFile(selectedFile);
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
const avatar = await uploadUserAvatar(selectedFile);
|
||||||
const avatar = await uploadAvatar(selectedFile);
|
if (currentUser?.user) {
|
||||||
|
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
|
||||||
setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
// skip
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await removeAvatar();
|
||||||
|
if (currentUser?.user) {
|
||||||
|
setUser({ ...currentUser.user, avatarUrl: null });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// skip
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AvatarUploader
|
||||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
currentImageUrl={currentUser?.user.avatarUrl}
|
||||||
{(props) => (
|
fallbackName={currentUser?.user.name}
|
||||||
<Tooltip label={t("Change photo")} position="bottom">
|
size="60px"
|
||||||
<CustomAvatar
|
type={AvatarIconType.AVATAR}
|
||||||
{...props}
|
onUpload={handleUpload}
|
||||||
component="button"
|
onRemove={handleRemove}
|
||||||
size="60px"
|
isLoading={isLoading}
|
||||||
avatarUrl={currentUser?.user.avatarUrl}
|
/>
|
||||||
name={currentUser?.user.name}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,3 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
|
|||||||
const req = await api.post<IUser>("/users/update", data);
|
const req = await api.post<IUser>("/users/update", data);
|
||||||
return req.data as IUser;
|
return req.data as IUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadAvatar(file: File): Promise<any> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("type", "avatar");
|
|
||||||
formData.append("image", file);
|
|
||||||
|
|
||||||
const req = await api.post("/attachments/upload-image", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||||
|
import {
|
||||||
|
uploadWorkspaceIcon,
|
||||||
|
removeWorkspaceIcon,
|
||||||
|
} from "@/features/attachments/services/attachment-service.ts";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
|
export default function WorkspaceIcon() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
|
const handleIconUpload = async (file: File) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await uploadWorkspaceIcon(file);
|
||||||
|
if (workspace) {
|
||||||
|
setWorkspace({ ...workspace, logo: result.fileName });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
//
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconRemove = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await removeWorkspaceIcon();
|
||||||
|
if (workspace) {
|
||||||
|
setWorkspace({ ...workspace, logo: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
//
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "24px" }}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
{t("Icon")}
|
||||||
|
</Text>
|
||||||
|
<AvatarUploader
|
||||||
|
currentImageUrl={workspace?.logo}
|
||||||
|
fallbackName={workspace?.name}
|
||||||
|
type={AvatarIconType.WORKSPACE_ICON}
|
||||||
|
size="60px"
|
||||||
|
radius="sm"
|
||||||
|
variant="filled"
|
||||||
|
onUpload={handleIconUpload}
|
||||||
|
onRemove={handleIconRemove}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -109,15 +109,3 @@ export async function getAppVersion(): Promise<IVersion> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadLogo(file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("type", "workspace-logo");
|
|
||||||
formData.append("image", file);
|
|
||||||
|
|
||||||
const req = await api.post("/attachments/upload-image", formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -41,11 +42,14 @@ export function isCloud(): boolean {
|
|||||||
return castToBoolean(getConfigValue("CLOUD"));
|
return castToBoolean(getConfigValue("CLOUD"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(avatarUrl: string) {
|
export function getAvatarUrl(
|
||||||
|
avatarUrl: string,
|
||||||
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
) {
|
||||||
if (!avatarUrl) return null;
|
if (!avatarUrl) return null;
|
||||||
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||||
|
|
||||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSpaceUrl(spaceSlug: string) {
|
export function getSpaceUrl(spaceSlug: string) {
|
||||||
|
|||||||
@@ -36,20 +36,3 @@ export type IPagination<T> = {
|
|||||||
items: T[];
|
items: T[];
|
||||||
meta: IPaginationMeta;
|
meta: IPaginationMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IAttachment {
|
|
||||||
id: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
fileSize: number;
|
|
||||||
fileExt: string;
|
|
||||||
mimeType: string;
|
|
||||||
type: string;
|
|
||||||
creatorId: string;
|
|
||||||
pageId: string | null;
|
|
||||||
spaceId: string | null;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
deletedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,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() && (
|
||||||
|
|||||||
@@ -84,7 +84,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
export enum AttachmentType {
|
export enum AttachmentType {
|
||||||
Avatar = 'avatar',
|
Avatar = 'avatar',
|
||||||
WorkspaceLogo = 'workspace-logo',
|
WorkspaceIcon = 'workspace-icon',
|
||||||
SpaceLogo = 'space-logo',
|
SpaceIcon = 'space-icon',
|
||||||
File = 'file',
|
File = 'file',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||||
export const MAX_AVATAR_SIZE = '5MB';
|
export const MAX_AVATAR_SIZE = '10MB';
|
||||||
|
|
||||||
export const inlineFileExtensions = [
|
export const inlineFileExtensions = [
|
||||||
'.jpg',
|
'.jpg',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
@@ -51,6 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
|||||||
import { TokenService } from '../auth/services/token.service';
|
import { TokenService } from '../auth/services/token.service';
|
||||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { RemoveIconDto } from './dto/attachment.dto';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@@ -302,7 +304,7 @@ export class AttachmentController {
|
|||||||
throw new BadRequestException('Invalid image attachment type');
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
if (attachmentType === AttachmentType.WorkspaceIcon) {
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
if (
|
if (
|
||||||
ability.cannot(
|
ability.cannot(
|
||||||
@@ -314,7 +316,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.SpaceLogo) {
|
if (attachmentType === AttachmentType.SpaceIcon) {
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
throw new BadRequestException('spaceId is required');
|
throw new BadRequestException('spaceId is required');
|
||||||
}
|
}
|
||||||
@@ -372,8 +374,59 @@ export class AttachmentController {
|
|||||||
});
|
});
|
||||||
return res.send(fileStream);
|
return res.send(fileStream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
// this.logger.error(err);
|
||||||
throw new NotFoundException('File not found');
|
throw new NotFoundException('File not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('attachments/remove-icon')
|
||||||
|
async removeIcon(
|
||||||
|
@Body() dto: RemoveIconDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const { type, spaceId } = dto;
|
||||||
|
|
||||||
|
// remove current user avatar
|
||||||
|
if (type === AttachmentType.Avatar) {
|
||||||
|
await this.attachmentService.removeUserAvatar(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove space icon
|
||||||
|
if (type === AttachmentType.SpaceIcon) {
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'spaceId is required to change space icons',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
|
if (
|
||||||
|
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove workspace icon
|
||||||
|
if (type === AttachmentType.WorkspaceIcon) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(
|
||||||
|
WorkspaceCaslAction.Manage,
|
||||||
|
WorkspaceCaslSubject.Settings,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
await this.attachmentService.removeWorkspaceIcon(workspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AttachmentType } from './attachment.constants';
|
import { AttachmentType } from './attachment.constants';
|
||||||
|
import { sanitizeFileName } from '../../common/helpers';
|
||||||
|
import * as sharp from 'sharp';
|
||||||
|
|
||||||
export interface PreparedFile {
|
export interface PreparedFile {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
@@ -22,10 +22,8 @@ export async function prepareFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rand = randomBytes(8).toString('hex');
|
|
||||||
|
|
||||||
const buffer = await file.toBuffer();
|
const buffer = await file.toBuffer();
|
||||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||||
const fileName = sanitizedFilename.slice(0, 255);
|
const fileName = sanitizedFilename.slice(0, 255);
|
||||||
const fileSize = buffer.length;
|
const fileSize = buffer.length;
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
@@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case AttachmentType.Avatar:
|
case AttachmentType.Avatar:
|
||||||
return `${workspaceId}/avatars`;
|
return `${workspaceId}/avatars`;
|
||||||
case AttachmentType.WorkspaceLogo:
|
case AttachmentType.WorkspaceIcon:
|
||||||
return `${workspaceId}/workspace-logo`;
|
return `${workspaceId}/workspace-logos`;
|
||||||
case AttachmentType.SpaceLogo:
|
case AttachmentType.SpaceIcon:
|
||||||
return `${workspaceId}/space-logos`;
|
return `${workspaceId}/space-logos`;
|
||||||
case AttachmentType.File:
|
case AttachmentType.File:
|
||||||
return `${workspaceId}/files`;
|
return `${workspaceId}/files`;
|
||||||
@@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
export const validAttachmentTypes = Object.values(AttachmentType);
|
||||||
|
|
||||||
|
export async function compressAndResizeIcon(
|
||||||
|
buffer: Buffer,
|
||||||
|
attachmentType?: AttachmentType,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
let sharpInstance = sharp(buffer);
|
||||||
|
const metadata = await sharpInstance.metadata();
|
||||||
|
|
||||||
|
const targetWidth = 300;
|
||||||
|
const targetHeight = 300;
|
||||||
|
|
||||||
|
// Only resize if image is larger than target dimensions
|
||||||
|
if (metadata.width > targetWidth || metadata.height > targetHeight) {
|
||||||
|
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle based on original format
|
||||||
|
if (metadata.format === 'png') {
|
||||||
|
// Only flatten avatars to remove transparency
|
||||||
|
if (attachmentType === AttachmentType.Avatar) {
|
||||||
|
sharpInstance = sharpInstance.flatten({
|
||||||
|
background: { r: 255, g: 255, b: 255 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sharpInstance
|
||||||
|
.png({
|
||||||
|
quality: 85,
|
||||||
|
compressionLevel: 6,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
} else {
|
||||||
|
return await sharpInstance
|
||||||
|
.jpeg({
|
||||||
|
quality: 85,
|
||||||
|
progressive: true,
|
||||||
|
mozjpeg: true,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { AttachmentType } from '../attachment.constants';
|
||||||
|
|
||||||
|
export class RemoveIconDto {
|
||||||
|
@IsEnum(AttachmentType)
|
||||||
|
@IsIn([
|
||||||
|
AttachmentType.Avatar,
|
||||||
|
AttachmentType.SpaceIcon,
|
||||||
|
AttachmentType.WorkspaceIcon,
|
||||||
|
])
|
||||||
|
@IsNotEmpty()
|
||||||
|
type: AttachmentType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class AvatarUploadDto {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class GetFileDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
attachmentId: string;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {
|
|
||||||
IsDefined,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class UploadFileDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
attachmentType: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
pageId: string;
|
|
||||||
|
|
||||||
@IsDefined()
|
|
||||||
file: any;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+288
-1
@@ -589,8 +589,11 @@ importers:
|
|||||||
specifier: ^7.8.2
|
specifier: ^7.8.2
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
sanitize-filename-ts:
|
sanitize-filename-ts:
|
||||||
specifier: ^1.0.2
|
specifier: 1.0.2
|
||||||
version: 1.0.2
|
version: 1.0.2
|
||||||
|
sharp:
|
||||||
|
specifier: 0.34.3
|
||||||
|
version: 0.34.3
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.1
|
specifier: ^4.8.1
|
||||||
version: 4.8.1
|
version: 4.8.1
|
||||||
@@ -1781,6 +1784,9 @@ packages:
|
|||||||
'@emnapi/runtime@1.2.0':
|
'@emnapi/runtime@1.2.0':
|
||||||
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
|
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.5.0':
|
||||||
|
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.0.1':
|
'@emnapi/wasi-threads@1.0.1':
|
||||||
resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==}
|
resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==}
|
||||||
|
|
||||||
@@ -2283,6 +2289,128 @@ packages:
|
|||||||
'@iconify/utils@3.0.1':
|
'@iconify/utils@3.0.1':
|
||||||
resolution: {integrity: sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==}
|
resolution: {integrity: sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.0':
|
||||||
|
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.0':
|
||||||
|
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
|
||||||
|
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.3':
|
||||||
|
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.3':
|
||||||
|
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.3':
|
||||||
|
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.3':
|
||||||
|
resolution: {integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.3':
|
||||||
|
resolution: {integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@inquirer/checkbox@4.1.2':
|
'@inquirer/checkbox@4.1.2':
|
||||||
resolution: {integrity: sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==}
|
resolution: {integrity: sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5265,10 +5393,17 @@ packages:
|
|||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
|
color-string@1.9.1:
|
||||||
|
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||||
|
|
||||||
color-support@1.1.3:
|
color-support@1.1.3:
|
||||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||||
|
engines: {node: '>=12.5.0'}
|
||||||
|
|
||||||
columnify@1.6.0:
|
columnify@1.6.0:
|
||||||
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
|
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -5733,6 +5868,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
detect-libc@2.0.4:
|
||||||
|
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
detect-newline@3.1.0:
|
detect-newline@3.1.0:
|
||||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6595,6 +6734,9 @@ packages:
|
|||||||
is-arrayish@0.2.1:
|
is-arrayish@0.2.1:
|
||||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||||
|
|
||||||
|
is-arrayish@0.3.4:
|
||||||
|
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||||
|
|
||||||
is-async-function@2.0.0:
|
is-async-function@2.0.0:
|
||||||
resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
|
resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -8783,6 +8925,10 @@ packages:
|
|||||||
shallowequal@1.1.0:
|
shallowequal@1.1.0:
|
||||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
||||||
|
|
||||||
|
sharp@0.34.3:
|
||||||
|
resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -8814,6 +8960,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
simple-swizzle@0.2.4:
|
||||||
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
sisteransi@1.0.5:
|
sisteransi@1.0.5:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
@@ -11664,6 +11813,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.5.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.0.1':
|
'@emnapi/wasi-threads@1.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -12124,6 +12278,92 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.3':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.5.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@inquirer/checkbox@4.1.2(@types/node@22.13.4)':
|
'@inquirer/checkbox@4.1.2(@types/node@22.13.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.1.7(@types/node@22.13.4)
|
'@inquirer/core': 10.1.7(@types/node@22.13.4)
|
||||||
@@ -15487,8 +15727,18 @@ snapshots:
|
|||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
color-string@1.9.1:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.4
|
||||||
|
simple-swizzle: 0.2.4
|
||||||
|
|
||||||
color-support@1.1.3: {}
|
color-support@1.1.3: {}
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 2.0.1
|
||||||
|
color-string: 1.9.1
|
||||||
|
|
||||||
columnify@1.6.0:
|
columnify@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
@@ -15957,6 +16207,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.0.3: {}
|
detect-libc@2.0.3: {}
|
||||||
|
|
||||||
|
detect-libc@2.0.4: {}
|
||||||
|
|
||||||
detect-newline@3.1.0: {}
|
detect-newline@3.1.0: {}
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
@@ -17053,6 +17305,8 @@ snapshots:
|
|||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
|
|
||||||
|
is-arrayish@0.3.4: {}
|
||||||
|
|
||||||
is-async-function@2.0.0:
|
is-async-function@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
@@ -19622,6 +19876,35 @@ snapshots:
|
|||||||
|
|
||||||
shallowequal@1.1.0: {}
|
shallowequal@1.1.0: {}
|
||||||
|
|
||||||
|
sharp@0.34.3:
|
||||||
|
dependencies:
|
||||||
|
color: 4.2.3
|
||||||
|
detect-libc: 2.0.4
|
||||||
|
semver: 7.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.3
|
||||||
|
'@img/sharp-darwin-x64': 0.34.3
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.0
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.0
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.0
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.0
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.0
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.0
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.0
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.0
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.0
|
||||||
|
'@img/sharp-linux-arm': 0.34.3
|
||||||
|
'@img/sharp-linux-arm64': 0.34.3
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.3
|
||||||
|
'@img/sharp-linux-s390x': 0.34.3
|
||||||
|
'@img/sharp-linux-x64': 0.34.3
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.3
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.3
|
||||||
|
'@img/sharp-wasm32': 0.34.3
|
||||||
|
'@img/sharp-win32-arm64': 0.34.3
|
||||||
|
'@img/sharp-win32-ia32': 0.34.3
|
||||||
|
'@img/sharp-win32-x64': 0.34.3
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -19649,6 +19932,10 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
simple-swizzle@0.2.4:
|
||||||
|
dependencies:
|
||||||
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
sisteransi@1.0.5: {}
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user