refactor: image upload modals, file size limit added to config (#2868)

* chore: add file size limit as config in the config api

* refactor: image upload modals

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Nikhil 2023-11-24 13:23:46 +05:30 committed by sriram veeraghanta
parent af3267ac5a
commit 1bd38ad4c7
13 changed files with 470 additions and 130 deletions

View File

@ -102,4 +102,6 @@ class ConfigurationEndpoint(BaseAPIView):
)
)
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
return Response(data, status=status.HTTP_200_OK)

View File

@ -14,6 +14,8 @@ import { FileService } from "services/file.service";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { Button, Input, Loader } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "constants/common";
const tabOptions = [
{
@ -58,8 +60,10 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
@ -86,7 +90,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
});
const handleSubmit = async () => {
@ -112,7 +116,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
if (isUnsplashImage) return;
if (oldValue && workspaceDetails) fileService.deleteFile(workspaceDetails.id, oldValue);
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
})
.catch((err) => {
console.log(err);

View File

@ -1,5 +1,6 @@
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./link-modal";
export * from "./user-image-upload-modal";
export * from "./workspace-image-upload-modal";

View File

@ -0,0 +1,199 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
handleDelete?: () => void;
isOpen: boolean;
isRemoving: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const { setToastAlert } = useToast();
const {
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
console.log("Submit triggered");
if (!image) return;
console.log("Inside submit");
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setImage(null);
if (value) fileService.deleteUserFile(value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
)
.finally(() => setIsImageUploading(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
</Dialog.Title>
<div className="space-y-3">
<div className="flex items-center justify-center gap-3">
<div
{...getRootProps()}
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
}`}
>
{image !== null || (value && value !== "") ? (
<>
<button
type="button"
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
>
Edit
</button>
<img
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/>
</>
) : (
<div>
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
</div>
</div>
<p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
{handleDelete && (
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useDropzone } from "react-dropzone";
@ -7,89 +7,89 @@ import { Transition, Dialog } from "@headlessui/react";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// icons
import { UserCircle2 } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
value?: string | null;
onClose: () => void;
handleRemove?: () => void;
isOpen: boolean;
onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean;
onClose: () => void;
onSuccess: (url: string) => void;
value: string | null;
};
// services
const fileService = new FileService();
export const ImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete, userImage } = props;
export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props;
// states
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore } = useMobxStore();
const { currentWorkspace: workspaceDetails } = workspaceStore;
const { setToastAlert } = useToast();
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const {
workspace: { currentWorkspace },
appConfig: { envConfig },
} = useMobxStore();
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
});
const handleClose = () => {
setImage(null);
setIsImageUploading(false);
onClose();
};
const handleSubmit = async () => {
if (!image || (!workspaceSlug && router.pathname != "/onboarding")) return;
if (!image || (!workspaceSlug && router.pathname !== "/onboarding")) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
if (userImage) {
fileService
.uploadUserFile(formData)
.then((res) => {
const imageUrl = res.asset;
if (!workspaceSlug) return;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
if (value) fileService.deleteUserFile(value);
onSuccess(imageUrl);
setImage(null);
if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value);
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
.catch((err) => {
console.error(err);
});
} else
fileService
.uploadFile(workspaceSlug as string, formData)
.then((res) => {
const imageUrl = res.asset;
onSuccess(imageUrl);
setIsImageUploading(false);
setImage(null);
if (value && workspaceDetails) fileService.deleteFile(workspaceDetails.id, value);
})
.catch((err) => {
console.error(err);
});
};
const handleClose = () => {
setImage(null);
onClose();
)
.finally(() => setIsImageUploading(false));
};
return (
@ -172,11 +172,11 @@ export const ImageUploadModal: React.FC<Props> = observer((props) => {
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
{handleRemove && (
<Button variant="danger" size="sm" onClick={handleRemove} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel

View File

@ -1,7 +1,10 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { useDropzone } from "react-dropzone";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueAttachmentService } from "services/issue";
// hooks
@ -10,8 +13,8 @@ import useToast from "hooks/use-toast";
import { IIssueAttachment } from "types";
// fetch-keys
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const maxFileSize = 5 * 1024 * 1024; // 5 MB
// constants
import { MAX_FILE_SIZE } from "constants/common";
type Props = {
disabled?: boolean;
@ -19,14 +22,20 @@ type Props = {
const issueAttachmentService = new IssueAttachmentService();
export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) => {
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
const { disabled = false } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const {
appConfig: { envConfig },
} = useMobxStore();
const onDrop = useCallback((acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
@ -70,13 +79,15 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: maxFileSize,
maxSize: envConfig?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
const fileError =
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;
fileRejections.length > 0
? `Invalid file type or size (max ${envConfig?.file_size_limit ?? MAX_FILE_SIZE / 1024 / 1024} MB)`
: null;
return (
<div
@ -99,4 +110,4 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
</span>
</div>
);
};
});

View File

@ -9,15 +9,13 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { Button, Input } from "@plane/ui";
import DummySidebar from "components/account/sidebar";
import OnboardingStepIndicator from "components/account/step-indicator";
import { UserImageUploadModal } from "components/core";
// types
import { IUser } from "types";
// constants
import { TIME_ZONES } from "constants/timezones";
// services
import { FileService } from "services/file.service";
// assets
import IssuesSvg from "public/onboarding/onboarding-issues.svg";
import { ImageUploadModal } from "components/core";
const defaultValues: Partial<IUser> = {
first_name: "",
@ -29,13 +27,7 @@ type Props = {
user?: IUser;
};
// const timeZoneOptions = TIME_ZONES.map((timeZone) => ({
// value: timeZone.value,
// query: timeZone.label + " " + timeZone.value,
// content: timeZone.label,
// }));
const useCases = [
const USE_CASES = [
"Build Products",
"Manage Feedbacks",
"Service delivery",
@ -43,15 +35,15 @@ const useCases = [
"Code Repository Integration",
"Bug Tracking",
"Test Case Management",
"Rescource allocation",
"Resource allocation",
];
const fileService = new FileService();
export const UserDetails: React.FC<Props> = observer((props) => {
const { user } = props;
// states
const [isRemoving, setIsRemoving] = useState(false);
// const [selectedUsecase, setSelectedUsecase] = useState<number | null>();
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const {
user: userStore,
@ -100,19 +92,22 @@ export const UserDetails: React.FC<Props> = observer((props) => {
<div className="h-full fixed hidden lg:block w-1/5 max-w-[320px]">
<DummySidebar showProject workspaceName={workspaceName} />
</div>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => {
handleDelete(getValues("avatar"));
}}
onSuccess={(url) => {
setValue("avatar", url);
setIsImageUploadModalOpen(false);
}}
value={watch("avatar") !== "" ? watch("avatar") : undefined}
userImage
<Controller
control={control}
name="avatar"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(getValues("avatar"))}
onSuccess={(url) => {
onChange(url);
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<div className="lg:w-2/3 w-full flex flex-col justify-between ml-auto ">
<div className="flex lg:w-4/5 md:px-0 px-7 pt-3 mx-auto flex-col">
@ -189,7 +184,7 @@ export const UserDetails: React.FC<Props> = observer((props) => {
name="use_case"
render={({ field: { value, onChange } }) => (
<div className="flex flex-wrap break-all overflow-auto">
{useCases.map((useCase) => (
{USE_CASES.map((useCase) => (
<div
className={`border mb-3 hover:cursor-pointer hover:bg-onboarding-background-300/30 flex-shrink-0 ${
value === useCase ? "border-custom-primary-100" : "border-onboarding-border-100"

View File

@ -11,7 +11,7 @@ import { FileService } from "services/file.service";
import useToast from "hooks/use-toast";
// components
import { DeleteWorkspaceModal } from "components/workspace";
import { ImageUploadModal } from "components/core";
import { WorkspaceImageUploadModal } from "components/core";
// ui
import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// types
@ -19,6 +19,7 @@ import { IWorkspace } from "types";
// constants
import { ORGANIZATION_SIZE } from "constants/workspace";
import { trackEvent } from "helpers/event-tracker.helper";
import { copyUrlToClipboard } from "helpers/string.helper";
const defaultValues: Partial<IWorkspace> = {
name: "",
@ -33,8 +34,6 @@ const fileService = new FileService();
export const WorkspaceDetails: FC = observer(() => {
// states
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isImageUploading, setIsImageUploading] = useState(false);
const [isImageRemoving, setIsImageRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
// store
@ -51,7 +50,6 @@ export const WorkspaceDetails: FC = observer(() => {
control,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({
defaultValues: { ...defaultValues, ...currentWorkspace },
@ -78,8 +76,12 @@ export const WorkspaceDetails: FC = observer(() => {
.catch((err) => console.error(err));
};
const handleDelete = (url: string | null | undefined) => {
if (!currentWorkspace || !url) return;
const handleRemoveLogo = () => {
if (!currentWorkspace) return;
const url = currentWorkspace.logo;
if (!url) return;
setIsImageRemoving(true);
@ -104,6 +106,17 @@ export const WorkspaceDetails: FC = observer(() => {
});
};
const handleCopyUrl = () => {
if (!currentWorkspace) return;
copyUrlToClipboard(`${currentWorkspace.slug}`).then(() => {
setToastAlert({
type: "success",
title: "Workspace URL copied to the clipboard.",
});
});
};
useEffect(() => {
if (currentWorkspace) reset({ ...currentWorkspace });
}, [currentWorkspace, reset]);
@ -118,22 +131,27 @@ export const WorkspaceDetails: FC = observer(() => {
return (
<>
<DeleteWorkspaceModal
data={currentWorkspace}
isOpen={deleteWorkspaceModal}
onClose={() => setDeleteWorkspaceModal(false)}
data={currentWorkspace}
/>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleDelete={() => handleDelete(currentWorkspace?.logo)}
onSuccess={(imageUrl) => {
setIsImageUploading(true);
setValue("logo", imageUrl);
setIsImageUploadModalOpen(false);
handleSubmit(onSubmit)().then(() => setIsImageUploading(false));
}}
value={watch("logo")}
<Controller
control={control}
name="logo"
render={({ field: { onChange, value } }) => (
<WorkspaceImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleRemove={handleRemoveLogo}
onSuccess={(imageUrl) => {
onChange(imageUrl);
setIsImageUploadModalOpen(false);
handleSubmit(onSubmit)();
}}
value={value}
/>
)}
/>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-100">
@ -156,9 +174,9 @@ export const WorkspaceDetails: FC = observer(() => {
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
<button type="button" onClick={handleCopyUrl} className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${currentWorkspace.slug}`}</span>
}/${currentWorkspace.slug}`}</button>
<div className="flex item-center gap-2.5">
<button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"

View File

@ -148,10 +148,7 @@ export const WorkspaceSidebarDropdown = observer(() => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 shadow-lg outline-none"
>
<Menu.Items className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 shadow-lg outline-none">
<div className="flex flex-col items-start justify-start gap-3 p-3">
<span className="text-sm font-medium text-custom-sidebar-text-200">Workspace</span>
{workspaces ? (

1
web/constants/common.ts Normal file
View File

@ -0,0 +1 @@
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -1,10 +1,18 @@
import Link from "next/link";
import { observer } from "mobx-react-lite";
import { MoveLeft, Plus, UserPlus } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
import { LogIn, LogOut, MoveLeft, Plus, User, UserPlus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { AuthService } from "services/auth.service";
// ui
import { Tooltip } from "@plane/ui";
import { Avatar, Tooltip } from "@plane/ui";
import { Fragment } from "react";
import { mutate } from "swr";
import { useRouter } from "next/router";
import useToast from "hooks/use-toast";
import { useTheme } from "next-themes";
const SIDEBAR_LINKS = [
{
@ -21,12 +29,44 @@ const SIDEBAR_LINKS = [
},
];
const authService = new AuthService();
export const ProfileLayoutSidebar = observer(() => {
const router = useRouter();
const { setTheme } = useTheme();
const { setToastAlert } = useToast();
const {
theme: { sidebarCollapsed, toggleSidebar },
workspace: { workspaces },
user: { currentUser, currentUserSettings },
} = useMobxStore();
// redirect url for normal mode
const redirectWorkspaceSlug =
currentUserSettings?.workspace?.last_workspace_slug ||
currentUserSettings?.workspace?.fallback_workspace_slug ||
"";
const handleSignOut = async () => {
await authService
.signOut()
.then(() => {
mutate("CURRENT_USER_DETAILS", null);
setTheme("system");
router.push("/");
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Failed to sign out. Please try again.",
})
);
};
return (
<div
className={`fixed md:relative inset-y-0 flex flex-col bg-custom-sidebar-background-100 h-full flex-shrink-0 flex-grow-0 border-r border-custom-sidebar-border-200 z-20 duration-300 ${
@ -34,6 +74,73 @@ export const ProfileLayoutSidebar = observer(() => {
} ${sidebarCollapsed ? "left-0" : "-left-full md:left-0"}`}
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center gap-x-3 gap-y-2 px-4 pt-4">
<div className="w-full h-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 rounded p-1 truncate ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<div
className={`flex-shrink-0 flex items-center justify-center h-6 w-6 bg-custom-sidebar-background-80 rounded`}
>
<User className="h-5 w-5 text-custom-text-200" />
</div>
{!sidebarCollapsed && <h4 className="text-custom-text-200 font-medium text-base truncate">My Profile</h4>}
</div>
</div>
{!sidebarCollapsed && (
<Tooltip position="bottom-left" tooltipContent="Go back to your workspace">
<div className="flex-shrink-0">
<Link href={`/${redirectWorkspaceSlug}`}>
<a>
<LogIn className="h-5 w-5 text-custom-text-200 rotate-180" />
</a>
</Link>
</div>
</Tooltip>
)}
{!sidebarCollapsed && (
<Menu as="div" className="relative flex-shrink-0 ">
<Menu.Button className="flex gap-4 place-items-center outline-none">
<Avatar
name={currentUser?.display_name}
src={currentUser?.avatar}
size={24}
shape="square"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-0 z-20 mt-1 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 shadow-custom-shadow-rg text-xs space-y-2 outline-none">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
<div className="w-full cursor-pointer space-y-1 p-4 flex-shrink-0">
{SIDEBAR_LINKS.map((link) => (
<Link key={link.key} href={link.href}>

View File

@ -10,7 +10,7 @@ import useToast from "hooks/use-toast";
// layouts
import { ProfileSettingsLayout } from "layouts/settings-layout";
// components
import { ImagePickerPopover, ImageUploadModal } from "components/core";
import { ImagePickerPopover, UserImageUploadModal } from "components/core";
import { ProfileSettingsHeader } from "components/headers";
import { DeactivateAccountModal } from "components/account";
// ui
@ -48,7 +48,6 @@ const ProfileSettingsPage: NextPageWithLayout = () => {
handleSubmit,
reset,
watch,
setValue,
control,
formState: { errors, isSubmitting },
} = useForm<IUser>({ defaultValues });
@ -151,18 +150,23 @@ const ProfileSettingsPage: NextPageWithLayout = () => {
return (
<>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(myProfile?.avatar, true)}
onSuccess={(url) => {
setValue("avatar", url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={watch("avatar") !== "" ? watch("avatar") : undefined}
userImage
<Controller
control={control}
name="avatar"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(myProfile?.avatar, true)}
onSuccess={(url) => {
onChange(url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<div className="h-full w-full flex flex-col py-9 pr-9 space-y-10 overflow-y-auto">

1
web/types/app.d.ts vendored
View File

@ -4,6 +4,7 @@ export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
export interface IAppConfig {
email_password_login: boolean;
file_size_limit: number;
google_client_id: string | null;
github_app_name: string | null;
github_client_id: string | null;