mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 02:02:06 +10: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",
|
||||
"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"
|
||||
}
|
||||
|
||||
165
apps/client/src/components/common/avatar-uploader.tsx
Normal file
165
apps/client/src/components/common/avatar-uploader.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Menu, Box, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface AvatarUploaderProps {
|
||||
currentImageUrl?: string | null;
|
||||
fallbackName?: string;
|
||||
radius?: string | number;
|
||||
size?: string | number;
|
||||
variant?: string;
|
||||
type: AvatarIconType;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AvatarUploader({
|
||||
currentImageUrl,
|
||||
fallbackName,
|
||||
radius,
|
||||
variant,
|
||||
size,
|
||||
type,
|
||||
onUpload,
|
||||
onRemove,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: AvatarUploaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileInputChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
notifications.show({
|
||||
message: t("Image exceeds 10MB limit."),
|
||||
color: "red",
|
||||
});
|
||||
// Reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
message: t("Failed to upload image"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
} else {
|
||||
console.error("File input ref is null!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (disabled) return;
|
||||
|
||||
try {
|
||||
await onRemove();
|
||||
notifications.show({
|
||||
message: t("Image removed successfully"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
message: t("Failed to remove image"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "relative", display: "inline-block" }}>
|
||||
<CustomAvatar
|
||||
component="button"
|
||||
size={size}
|
||||
avatarUrl={currentImageUrl}
|
||||
name={fallbackName}
|
||||
style={{
|
||||
cursor: disabled || isLoading ? "default" : "pointer",
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
}}
|
||||
radius={radius}
|
||||
variant={variant}
|
||||
type={type}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconUpload size={16} />}
|
||||
disabled={isLoading || disabled}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
{t("Upload image")}
|
||||
</Menu.Item>
|
||||
|
||||
{currentImageUrl && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{t("Remove image")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@ -10,7 +10,6 @@ import {
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
@ -50,6 +50,7 @@ export default function TopMenu() {
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { getAvatarUrl } from "@/lib/config.ts";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
interface CustomAvatarProps {
|
||||
avatarUrl: string;
|
||||
@ -11,13 +12,15 @@ interface CustomAvatarProps {
|
||||
variant?: string;
|
||||
style?: any;
|
||||
component?: any;
|
||||
type?: AvatarIconType;
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl);
|
||||
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
AvatarIconType,
|
||||
IAttachment,
|
||||
} from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export async function uploadIcon(
|
||||
file: File,
|
||||
type: AvatarIconType,
|
||||
spaceId?: string,
|
||||
): Promise<IAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("type", type);
|
||||
if (spaceId) {
|
||||
formData.append("spaceId", spaceId);
|
||||
}
|
||||
formData.append("image", file);
|
||||
|
||||
return await api.post("/attachments/upload-image", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
|
||||
return uploadIcon(file, AvatarIconType.AVATAR);
|
||||
}
|
||||
|
||||
export async function uploadSpaceIcon(
|
||||
file: File,
|
||||
spaceId: string,
|
||||
): Promise<IAttachment> {
|
||||
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
|
||||
}
|
||||
|
||||
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
|
||||
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
|
||||
}
|
||||
|
||||
async function removeIcon(
|
||||
type: AvatarIconType,
|
||||
spaceId?: string,
|
||||
): Promise<void> {
|
||||
const payload: { spaceId?: string; type: string } = { type };
|
||||
|
||||
if (spaceId) {
|
||||
payload.spaceId = spaceId;
|
||||
}
|
||||
|
||||
await api.post("/attachments/remove-icon", payload);
|
||||
}
|
||||
|
||||
export async function removeAvatar(): Promise<void> {
|
||||
await removeIcon(AvatarIconType.AVATAR);
|
||||
}
|
||||
|
||||
export async function removeSpaceIcon(spaceId: string): Promise<void> {
|
||||
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
|
||||
}
|
||||
|
||||
export async function removeWorkspaceIcon(): Promise<void> {
|
||||
await removeIcon(AvatarIconType.WORKSPACE_ICON);
|
||||
}
|
||||
9
apps/client/src/features/attachments/services/index.ts
Normal file
9
apps/client/src/features/attachments/services/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export {
|
||||
uploadIcon,
|
||||
uploadUserAvatar,
|
||||
uploadSpaceIcon,
|
||||
uploadWorkspaceIcon,
|
||||
removeAvatar,
|
||||
removeSpaceIcon,
|
||||
removeWorkspaceIcon,
|
||||
} from "./attachment-service.ts";
|
||||
@ -0,0 +1,29 @@
|
||||
export interface IAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
mimeType: string;
|
||||
type: string;
|
||||
creatorId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export enum AvatarIconType {
|
||||
AVATAR = "avatar",
|
||||
SPACE_ICON = "space-icon",
|
||||
WORKSPACE_ICON = "workspace-icon",
|
||||
}
|
||||
|
||||
export enum AttachmentType {
|
||||
AVATAR = "avatar",
|
||||
WORKSPACE_ICON = "workspace-icon",
|
||||
SPACE_ICON = "space-icon",
|
||||
FILE = "file",
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -94,7 +94,12 @@
|
||||
|
||||
hr {
|
||||
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 {
|
||||
cursor: pointer;
|
||||
|
||||
@ -9,10 +9,11 @@ import {
|
||||
SidebarPagesParams,
|
||||
} from '@/features/page/types/page.types';
|
||||
import { QueryParams } from "@/lib/types";
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
||||
import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
|
||||
import { Modal, Tabs, rem, Group, ScrollArea, Text } from "@mantine/core";
|
||||
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||
import React, {useMemo} from "react";
|
||||
import React from "react";
|
||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
|
||||
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
@ -39,16 +39,18 @@ export default function SpaceSettingsModal({
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay/>
|
||||
<Modal.Content style={{overflow: "hidden"}}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title>
|
||||
<Text fw={500} lineClamp={1}>{space?.name}</Text>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{space?.name}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton/>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div style={{height: rem(600)}}>
|
||||
<div style={{ height: rem(600) }}>
|
||||
<Tabs defaultValue="members">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="general">
|
||||
@ -60,13 +62,15 @@ export default function SpaceSettingsModal({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
<ScrollArea h={550} scrollbarSize={4} pr={8}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="members">
|
||||
@ -74,7 +78,7 @@ export default function SpaceSettingsModal({
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Member,
|
||||
) && <AddSpaceMembersModal spaceId={space?.id}/>}
|
||||
) && <AddSpaceMembersModal spaceId={space?.id} />}
|
||||
</Group>
|
||||
|
||||
<SpaceMembersList
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
|
||||
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { ISpace } from "../../types/space.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
interface SpaceSelectProps {
|
||||
onChange: (value: ISpace) => void;
|
||||
@ -16,7 +18,14 @@ interface SpaceSelectProps {
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
||||
<CustomAvatar
|
||||
name={option.label}
|
||||
avatarUrl={option?.["icon"]}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{option.label}
|
||||
@ -50,6 +59,7 @@ export function SpaceSelect({
|
||||
return {
|
||||
label: space.name,
|
||||
value: space.slug,
|
||||
icon: space.logo,
|
||||
};
|
||||
});
|
||||
|
||||
@ -76,7 +86,6 @@ export function SpaceSelect({
|
||||
onChange={(slug) =>
|
||||
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||
}
|
||||
// duct tape
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
|
||||
@ -74,7 +74,11 @@ export function SpaceSidebar() {
|
||||
marginBottom: 3,
|
||||
}}
|
||||
>
|
||||
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
|
||||
<SwitchSpace
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.section}>
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import classes from './switch-space.module.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SpaceSelect } from './space-select';
|
||||
import { getSpaceUrl } from '@/lib/config';
|
||||
import { Avatar, Button, Popover, Text } from '@mantine/core';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import classes from "./switch-space.module.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SpaceSelect } from "./space-select";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { Button, Popover, Text } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import React from "react";
|
||||
|
||||
interface SwitchSpaceProps {
|
||||
spaceName: string;
|
||||
spaceSlug: string;
|
||||
spaceIcon?: string;
|
||||
}
|
||||
|
||||
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
export function SwitchSpace({
|
||||
spaceName,
|
||||
spaceSlug,
|
||||
spaceIcon,
|
||||
}: SwitchSpaceProps) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||
|
||||
@ -40,11 +48,13 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
color="gray"
|
||||
onClick={open}
|
||||
>
|
||||
<Avatar
|
||||
size={20}
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
avatarUrl={spaceIcon}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={spaceName}
|
||||
size={20}
|
||||
/>
|
||||
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
||||
{spaceName}
|
||||
@ -55,7 +65,7 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
||||
<SpaceSelect
|
||||
label={spaceName}
|
||||
value={spaceSlug}
|
||||
onChange={space => handleSelect(space.slug)}
|
||||
onChange={(space) => handleSelect(space.slug)}
|
||||
width={300}
|
||||
opened={true}
|
||||
/>
|
||||
|
||||
@ -1,11 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
||||
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
||||
import { Button, Divider, Group, Text } from '@mantine/core';
|
||||
import DeleteSpaceModal from './delete-space-modal';
|
||||
import React, { useState } from "react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
|
||||
import { Button, Divider, Text } from "@mantine/core";
|
||||
import DeleteSpaceModal from "./delete-space-modal";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import ExportModal from "@/components/common/export-modal.tsx";
|
||||
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||
import {
|
||||
uploadSpaceIcon,
|
||||
removeSpaceIcon,
|
||||
} from "@/features/attachments/services/attachment-service.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
ResponsiveSettingsContent,
|
||||
ResponsiveSettingsControl,
|
||||
ResponsiveSettingsRow,
|
||||
} from "@/components/ui/responsive-settings-row.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@ -13,9 +25,40 @@ interface SpaceDetailsProps {
|
||||
}
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||
|
||||
const handleIconUpload = async (file: File) => {
|
||||
setIsIconUploading(true);
|
||||
try {
|
||||
await uploadSpaceIcon(file, spaceId);
|
||||
await refetch();
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
} catch (err) {
|
||||
// skip
|
||||
} finally {
|
||||
setIsIconUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconRemove = async () => {
|
||||
setIsIconUploading(true);
|
||||
try {
|
||||
await removeSpaceIcon(spaceId);
|
||||
await refetch();
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
} catch (err) {
|
||||
// skip
|
||||
} finally {
|
||||
setIsIconUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -24,38 +67,56 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
<Text my="md" fw={600}>
|
||||
{t("Details")}
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t("Icon")}
|
||||
</Text>
|
||||
<AvatarUploader
|
||||
currentImageUrl={space.logo}
|
||||
fallbackName={space.name}
|
||||
size={"60px"}
|
||||
variant="filled"
|
||||
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
onUpload={handleIconUpload}
|
||||
onRemove={handleIconRemove}
|
||||
isLoading={isIconUploading}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t("Export space")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Export all pages and attachments in this space.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={openExportModal}>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Group>
|
||||
</ResponsiveSettingsContent>
|
||||
<ResponsiveSettingsControl>
|
||||
<Button onClick={openExportModal}>{t("Export")}</Button>
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t("Delete space")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Delete this space with all its pages and data.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<DeleteSpaceModal space={space} />
|
||||
</Group>
|
||||
</ResponsiveSettingsContent>
|
||||
<ResponsiveSettingsControl>
|
||||
<DeleteSpaceModal space={space} />
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
.cardSection {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||
import React, { useEffect } from 'react';
|
||||
import { Text, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
prefetchSpace,
|
||||
useGetSpacesQuery,
|
||||
@ -10,6 +10,8 @@ import classes from "./space-grid.module.css";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export default function SpaceGrid() {
|
||||
const { t } = useTranslation();
|
||||
@ -27,8 +29,10 @@ export default function SpaceGrid() {
|
||||
withBorder
|
||||
>
|
||||
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
||||
<Avatar
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size="md"
|
||||
@ -54,7 +58,7 @@ export default function SpaceGrid() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||
|
||||
|
||||
{data?.items && data.items.length > 9 && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Table, Group, Text, Avatar } from "@mantine/core";
|
||||
import { Group, Table, Text } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export default function SpaceList() {
|
||||
const { t } = useTranslation();
|
||||
@ -39,8 +41,10 @@ export default function SpaceList() {
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar
|
||||
<CustomAvatar
|
||||
color="initials"
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
/>
|
||||
|
||||
@ -6,13 +6,12 @@ import {
|
||||
Box,
|
||||
Space,
|
||||
Menu,
|
||||
Avatar,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
@ -22,6 +21,8 @@ import Paginate from "@/components/common/paginate";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
||||
import classes from "./all-spaces-list.module.css";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
interface AllSpacesListProps {
|
||||
spaces: any[];
|
||||
@ -87,11 +88,13 @@ export default function AllSpacesList({
|
||||
className={classes.spaceLink}
|
||||
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
|
||||
>
|
||||
<Avatar
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
size={40}
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
ISpaceMember,
|
||||
} from "@/features/space/types/space.types";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
export async function getSpaces(
|
||||
|
||||
@ -9,7 +9,7 @@ export interface ISpace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
logo?: string;
|
||||
slug: string;
|
||||
hostname: string;
|
||||
creatorId: string;
|
||||
@ -74,4 +74,4 @@ export interface IExportSpaceParams {
|
||||
spaceId: string;
|
||||
format: ExportFormat;
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,55 +1,58 @@
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
currentUserAtom,
|
||||
userAtom,
|
||||
} from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { FileButton, Tooltip } from "@mantine/core";
|
||||
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||
import AvatarUploader from "@/components/common/avatar-uploader.tsx";
|
||||
import {
|
||||
uploadUserAvatar,
|
||||
removeAvatar,
|
||||
} from "@/features/attachments/services/attachment-service.ts";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
export default function AccountAvatar() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setUser] = useAtom(userAtom);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const handleFileChange = async (selectedFile: File) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
const handleUpload = async (selectedFile: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const avatar = await uploadAvatar(selectedFile);
|
||||
|
||||
setUser((prev) => ({ ...prev, avatarUrl: avatar.fileName }));
|
||||
const avatar = await uploadUserAvatar(selectedFile);
|
||||
if (currentUser?.user) {
|
||||
setUser({ ...currentUser.user, avatarUrl: avatar.fileName });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// skip
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await removeAvatar();
|
||||
if (currentUser?.user) {
|
||||
setUser({ ...currentUser.user, avatarUrl: null });
|
||||
}
|
||||
} catch (err) {
|
||||
// skip
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
||||
{(props) => (
|
||||
<Tooltip label={t("Change photo")} position="bottom">
|
||||
<CustomAvatar
|
||||
{...props}
|
||||
component="button"
|
||||
size="60px"
|
||||
avatarUrl={currentUser?.user.avatarUrl}
|
||||
name={currentUser?.user.name}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
</>
|
||||
<AvatarUploader
|
||||
currentImageUrl={currentUser?.user.avatarUrl}
|
||||
fallbackName={currentUser?.user.name}
|
||||
size="60px"
|
||||
type={AvatarIconType.AVATAR}
|
||||
onUpload={handleUpload}
|
||||
onRemove={handleRemove}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,16 +10,3 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
|
||||
const req = await api.post<IUser>("/users/update", data);
|
||||
return req.data as IUser;
|
||||
}
|
||||
|
||||
export async function uploadAvatar(file: File): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append("type", "avatar");
|
||||
formData.append("image", file);
|
||||
|
||||
const req = await api.post("/attachments/upload-image", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return req;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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 { castToBoolean } from "@/lib/utils.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -41,11 +42,14 @@ export function isCloud(): boolean {
|
||||
return castToBoolean(getConfigValue("CLOUD"));
|
||||
}
|
||||
|
||||
export function getAvatarUrl(avatarUrl: string) {
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
) {
|
||||
if (!avatarUrl) return null;
|
||||
if (avatarUrl?.startsWith("http")) return avatarUrl;
|
||||
|
||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
||||
return getBackendUrl() + `/attachments/img/${type}/` + encodeURI(avatarUrl);
|
||||
}
|
||||
|
||||
export function getSpaceUrl(spaceSlug: string) {
|
||||
|
||||
@ -36,20 +36,3 @@ export type IPagination<T> = {
|
||||
items: T[];
|
||||
meta: IPaginationMeta;
|
||||
};
|
||||
|
||||
export interface IAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
mimeType: string;
|
||||
type: string;
|
||||
creatorId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
|
||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@ -14,6 +15,7 @@ export default function WorkspaceSettings() {
|
||||
<title>Workspace Settings - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("General")} />
|
||||
<WorkspaceIcon />
|
||||
<WorkspaceNameForm />
|
||||
|
||||
{isCloud() && (
|
||||
|
||||
@ -84,7 +84,8 @@
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"sharp": "0.34.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
|
||||
@ -72,7 +72,9 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
||||
const sanitizedFilename = sanitize(fileName)
|
||||
.replace(/ /g, '_')
|
||||
.replace(/#/g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
export enum AttachmentType {
|
||||
Avatar = 'avatar',
|
||||
WorkspaceLogo = 'workspace-logo',
|
||||
SpaceLogo = 'space-logo',
|
||||
WorkspaceIcon = 'workspace-icon',
|
||||
SpaceIcon = 'space-icon',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
export const MAX_AVATAR_SIZE = '5MB';
|
||||
export const MAX_AVATAR_SIZE = '10MB';
|
||||
|
||||
export const inlineFileExtensions = [
|
||||
'.jpg',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
@ -51,6 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { RemoveIconDto } from './dto/attachment.dto';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
@ -302,7 +304,7 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
||||
if (attachmentType === AttachmentType.WorkspaceIcon) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
@ -314,7 +316,7 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentType === AttachmentType.SpaceLogo) {
|
||||
if (attachmentType === AttachmentType.SpaceIcon) {
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException('spaceId is required');
|
||||
}
|
||||
@ -372,8 +374,59 @@ export class AttachmentController {
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
// this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('attachments/remove-icon')
|
||||
async removeIcon(
|
||||
@Body() dto: RemoveIconDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const { type, spaceId } = dto;
|
||||
|
||||
// remove current user avatar
|
||||
if (type === AttachmentType.Avatar) {
|
||||
await this.attachmentService.removeUserAvatar(user);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove space icon
|
||||
if (type === AttachmentType.SpaceIcon) {
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException(
|
||||
'spaceId is required to change space icons',
|
||||
);
|
||||
}
|
||||
|
||||
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (
|
||||
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove workspace icon
|
||||
if (type === AttachmentType.WorkspaceIcon) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.attachmentService.removeWorkspaceIcon(workspace);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
import { sanitizeFileName } from '../../common/helpers';
|
||||
import * as sharp from 'sharp';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer: Buffer;
|
||||
@ -22,10 +22,8 @@ export async function prepareFile(
|
||||
}
|
||||
|
||||
try {
|
||||
const rand = randomBytes(8).toString('hex');
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
||||
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||
const fileName = sanitizedFilename.slice(0, 255);
|
||||
const fileSize = buffer.length;
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
|
||||
switch (type) {
|
||||
case AttachmentType.Avatar:
|
||||
return `${workspaceId}/avatars`;
|
||||
case AttachmentType.WorkspaceLogo:
|
||||
return `${workspaceId}/workspace-logo`;
|
||||
case AttachmentType.SpaceLogo:
|
||||
case AttachmentType.WorkspaceIcon:
|
||||
return `${workspaceId}/workspace-logos`;
|
||||
case AttachmentType.SpaceIcon:
|
||||
return `${workspaceId}/space-logos`;
|
||||
case AttachmentType.File:
|
||||
return `${workspaceId}/files`;
|
||||
@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
|
||||
}
|
||||
|
||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
||||
|
||||
export async function compressAndResizeIcon(
|
||||
buffer: Buffer,
|
||||
attachmentType?: AttachmentType,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
let sharpInstance = sharp(buffer);
|
||||
const metadata = await sharpInstance.metadata();
|
||||
|
||||
const targetWidth = 300;
|
||||
const targetHeight = 300;
|
||||
|
||||
// Only resize if image is larger than target dimensions
|
||||
if (metadata.width > targetWidth || metadata.height > targetHeight) {
|
||||
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle based on original format
|
||||
if (metadata.format === 'png') {
|
||||
// Only flatten avatars to remove transparency
|
||||
if (attachmentType === AttachmentType.Avatar) {
|
||||
sharpInstance = sharpInstance.flatten({
|
||||
background: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
}
|
||||
|
||||
return await sharpInstance
|
||||
.png({
|
||||
quality: 85,
|
||||
compressionLevel: 6,
|
||||
})
|
||||
.toBuffer();
|
||||
} else {
|
||||
return await sharpInstance
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
mozjpeg: true,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/server/src/core/attachment/dto/attachment.dto.ts
Normal file
17
apps/server/src/core/attachment/dto/attachment.dto.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { AttachmentType } from '../attachment.constants';
|
||||
|
||||
export class RemoveIconDto {
|
||||
@IsEnum(AttachmentType)
|
||||
@IsIn([
|
||||
AttachmentType.Avatar,
|
||||
AttachmentType.SpaceIcon,
|
||||
AttachmentType.WorkspaceIcon,
|
||||
])
|
||||
@IsNotEmpty()
|
||||
type: AttachmentType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvatarUploadDto {}
|
||||
@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class GetFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentId: string;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import {
|
||||
IsDefined,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
|
||||
@IsDefined()
|
||||
file: any;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
compressAndResizeIcon,
|
||||
getAttachmentFolderPath,
|
||||
PreparedFile,
|
||||
prepareFile,
|
||||
@ -16,7 +17,7 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { AttachmentType, validImageExtensions } from '../attachment.constants';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { Attachment } from '@docmost/db/types/entity.types';
|
||||
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
@ -132,8 +133,8 @@ export class AttachmentService {
|
||||
filePromise: Promise<MultipartFile>,
|
||||
type:
|
||||
| AttachmentType.Avatar
|
||||
| AttachmentType.WorkspaceLogo
|
||||
| AttachmentType.SpaceLogo,
|
||||
| AttachmentType.WorkspaceIcon
|
||||
| AttachmentType.SpaceIcon,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
spaceId?: string,
|
||||
@ -141,6 +142,9 @@ export class AttachmentService {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||
|
||||
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
||||
preparedFile.buffer = processedBuffer;
|
||||
preparedFile.fileSize = processedBuffer.length;
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
||||
@ -174,7 +178,7 @@ export class AttachmentService {
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.WorkspaceLogo) {
|
||||
} else if (type === AttachmentType.WorkspaceIcon) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
trx,
|
||||
});
|
||||
@ -186,7 +190,7 @@ export class AttachmentService {
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.SpaceLogo && spaceId) {
|
||||
} else if (type === AttachmentType.SpaceIcon && spaceId) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
@ -205,7 +209,6 @@ export class AttachmentService {
|
||||
});
|
||||
} catch (err) {
|
||||
// delete uploaded file on db update failure
|
||||
this.logger.error('Image upload error:', err);
|
||||
await this.deleteRedundantFile(filePath);
|
||||
throw new BadRequestException('Failed to upload image');
|
||||
}
|
||||
@ -389,4 +392,40 @@ export class AttachmentService {
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserAvatar(user: User) {
|
||||
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ avatarUrl: null },
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async removeSpaceIcon(spaceId: string, workspaceId: string) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
||||
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
|
||||
}
|
||||
|
||||
async removeWorkspaceIcon(workspace: Workspace) {
|
||||
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
289
pnpm-lock.yaml
generated
289
pnpm-lock.yaml
generated
@ -589,8 +589,11 @@ importers:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
sanitize-filename-ts:
|
||||
specifier: ^1.0.2
|
||||
specifier: 1.0.2
|
||||
version: 1.0.2
|
||||
sharp:
|
||||
specifier: 0.34.3
|
||||
version: 0.34.3
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
@ -1781,6 +1784,9 @@ packages:
|
||||
'@emnapi/runtime@1.2.0':
|
||||
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||
|
||||
'@emnapi/wasi-threads@1.0.1':
|
||||
resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==}
|
||||
|
||||
@ -2283,6 +2289,128 @@ packages:
|
||||
'@iconify/utils@3.0.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -5265,10 +5393,17 @@ packages:
|
||||
color-name@1.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
color@4.2.3:
|
||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||
engines: {node: '>=12.5.0'}
|
||||
|
||||
columnify@1.6.0:
|
||||
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@ -5733,6 +5868,10 @@ packages:
|
||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-libc@2.0.4:
|
||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-newline@3.1.0:
|
||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -6595,6 +6734,9 @@ packages:
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
is-async-function@2.0.0:
|
||||
resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -8783,6 +8925,10 @@ packages:
|
||||
shallowequal@1.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -8814,6 +8960,9 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
@ -11664,6 +11813,11 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.0.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@ -12124,6 +12278,92 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.7(@types/node@22.13.4)
|
||||
@ -15487,8 +15727,18 @@ snapshots:
|
||||
|
||||
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@4.2.3:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
|
||||
columnify@1.6.0:
|
||||
dependencies:
|
||||
strip-ansi: 6.0.1
|
||||
@ -15957,6 +16207,8 @@ snapshots:
|
||||
|
||||
detect-libc@2.0.3: {}
|
||||
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
detect-newline@3.1.0: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
@ -17053,6 +17305,8 @@ snapshots:
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-async-function@2.0.0:
|
||||
dependencies:
|
||||
has-tostringtag: 1.0.2
|
||||
@ -19622,6 +19876,35 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@ -19649,6 +19932,10 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user