forked from github/plane
style: redesigned create project modal
style: changed image picker to pop-over instread of modal
This commit is contained in:
parent
ea509211a0
commit
e96a755a2e
152
apps/app/components/core/image-picker-popover.tsx
Normal file
152
apps/app/components/core/image-picker-popover.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
// next
|
||||
import Image from "next/image";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileService from "services/file.service";
|
||||
|
||||
// components
|
||||
import { Input, Spinner } from "components/ui";
|
||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
title: "Upload",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
label: string | React.ReactNode;
|
||||
value: string | null;
|
||||
onChange: (data: string) => void;
|
||||
};
|
||||
|
||||
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useState("");
|
||||
const [formData, setFormData] = useState({
|
||||
search: "",
|
||||
});
|
||||
|
||||
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
|
||||
fileService.getUnsplashImages(1, searchParams)
|
||||
);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!images || value !== null) return;
|
||||
onChange(images[0].urls.regular);
|
||||
}, [value, onChange, images]);
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-gray-500 bg-white px-2 py-1 text-xs text-gray-700"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
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"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-white shadow-lg">
|
||||
<div className="h-96 w-80 overflow-auto rounded border bg-white p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
|
||||
<Tab.Group>
|
||||
<Tab.List as="span" className="inline-block rounded bg-gray-200 p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
|
||||
selected ? "bg-theme text-white" : "text-black"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<Tab.Panel className="h-full w-full space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchParams(formData.search);
|
||||
}}
|
||||
className="flex gap-x-2 pt-7"
|
||||
>
|
||||
<Input
|
||||
name="search"
|
||||
className="text-sm"
|
||||
id="search"
|
||||
value={formData.search}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<PrimaryButton className="bg-indigo-600" size="sm">
|
||||
Search
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
{images ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
>
|
||||
<Image
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="cursor-pointer rounded"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image.urls.regular);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center pt-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
|
||||
<p>Coming Soon...</p>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
@ -9,4 +9,4 @@ export * from "./issues-view";
|
||||
export * from "./link-modal";
|
||||
export * from "./not-authorized-view";
|
||||
export * from "./multi-level-select";
|
||||
export * from "./unsplash-modal";
|
||||
export * from "./image-picker-popover";
|
||||
|
@ -1,108 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import fileService from "services/file.service";
|
||||
// ui
|
||||
import { Button, Input, Spinner } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onSelect: (url: string) => void;
|
||||
};
|
||||
|
||||
export const UnsplashImageModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, onSelect } = props;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [formData, setFormData] = useState({
|
||||
search: "",
|
||||
});
|
||||
|
||||
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchQuery.toUpperCase()}`, () =>
|
||||
fileService.getUnsplashImages(1, searchQuery)
|
||||
);
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<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-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 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 h-[35rem] space-y-5 transform overflow-y-auto rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Select an image
|
||||
</Dialog.Title>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchQuery(formData.search);
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Input
|
||||
name="search"
|
||||
value={formData.search}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
placeholder="Search for images"
|
||||
/>
|
||||
<Button type="submit">Search</Button>
|
||||
</form>
|
||||
|
||||
{images ? (
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
{images.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
className="w-full relative h-32 bg-gray-200 rounded-md overflow-hidden"
|
||||
type="button"
|
||||
onClick={() => onSelect(image.urls.small)}
|
||||
>
|
||||
<Image src={image.urls.small} layout="fill" objectFit="cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-96 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
@ -44,7 +44,7 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<Popover className="relative z-[1]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-gray-300 p-2 outline-none sm:text-sm"
|
||||
className="rounded-full bg-gray-100 p-2 outline-none sm:text-sm"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
@ -7,17 +8,19 @@ import useSWR, { mutate } from "swr";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui/button/primary-button";
|
||||
import { Button, Input, TextArea, CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { PhotoIcon } from "@heroicons/react/24/outline";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import { UnsplashImageModal } from "components/core";
|
||||
import { ImagePickerPopover } from "components/core";
|
||||
import EmojiIconPicker from "components/emoji-icon-picker";
|
||||
// helpers
|
||||
import { getRandomEmoji } from "helpers/common.helper";
|
||||
@ -62,7 +65,6 @@ const IsGuestCondition: React.FC<{
|
||||
export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, setIsOpen } = props;
|
||||
|
||||
const [isUnsplashModalOpen, setIsUnsplashModalOpen] = useState(false);
|
||||
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -177,150 +179,149 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
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-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<div className="absolute border-2 rounded-md p-2 bg-red-a500 right-4 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full h-full flex items-center justify-center outline-none"
|
||||
onClick={() => setIsUnsplashModalOpen(true)}
|
||||
>
|
||||
<PhotoIcon className="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
<Dialog.Panel className="transform rounded-lg bg-white text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<div className="relative h-36 w-full rounded-t-lg bg-gray-300">
|
||||
{watch("cover_image") !== null && (
|
||||
<Image
|
||||
src={watch("cover_image")!}
|
||||
layout="fill"
|
||||
alt="cover image"
|
||||
objectFit="cover"
|
||||
className="rounded-t-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute right-2 top-2 p-2">
|
||||
<button type="button" onClick={handleClose}>
|
||||
<XMarkIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 flex w-full justify-between px-6 py-5">
|
||||
<div className="absolute left-0 bottom-0 h-16 w-full bg-gradient-to-t from-black opacity-60" />
|
||||
<h3 className="z-[1] text-xl text-white">Create Project</h3>
|
||||
<div>
|
||||
<ImagePickerPopover
|
||||
label="Change Cover"
|
||||
onChange={(image) => {
|
||||
setValue("cover_image", image);
|
||||
}}
|
||||
value={watch("cover_image")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Create Project
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Create a new project to start working on it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<label htmlFor="icon" className="mb-2 text-gray-500">
|
||||
Icon
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="icon"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiIconPicker
|
||||
label={
|
||||
value ? String.fromCodePoint(parseInt(value)) : "Select Icon"
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
name="name"
|
||||
type="name"
|
||||
placeholder="Enter name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4 px-4 py-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div>
|
||||
<h6 className="text-gray-500">Network</h6>
|
||||
<Controller
|
||||
name="network"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
|
||||
? NETWORK_CHOICES[
|
||||
value.toString() as keyof typeof NETWORK_CHOICES
|
||||
]
|
||||
: "Select network"
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.keys(NETWORK_CHOICES).map((key) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
<EmojiIconPicker
|
||||
label={String.fromCodePoint(parseInt(watch("icon")))}
|
||||
onChange={(emoji) => {
|
||||
setValue("icon", emoji);
|
||||
}}
|
||||
value={watch("icon")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Enter description"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className="flex-shrink-0 flex-grow">
|
||||
<Input
|
||||
id="identifier"
|
||||
label="Identifier"
|
||||
name="identifier"
|
||||
type="text"
|
||||
placeholder="Enter Project Identifier"
|
||||
error={errors.identifier}
|
||||
id="name"
|
||||
name="name"
|
||||
type="name"
|
||||
placeholder="Enter name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
onChange={() => setIsChangeIdentifierRequired(false)}
|
||||
className="text-xl"
|
||||
mode="transparent"
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
validate: (value) =>
|
||||
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
},
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 5,
|
||||
message: "Identifier must at most be of 5 characters",
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
type="text"
|
||||
mode="transparent"
|
||||
className="text-sm"
|
||||
placeholder="Enter Project Identifier"
|
||||
error={errors.identifier}
|
||||
register={register}
|
||||
onChange={() => setIsChangeIdentifierRequired(false)}
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
validate: (value) =>
|
||||
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
},
|
||||
maxLength: {
|
||||
value: 5,
|
||||
message: "Identifier must at most be of 5 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
mode="transparent"
|
||||
className="text-sm"
|
||||
placeholder="Enter description"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-40">
|
||||
<Controller
|
||||
name="network"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
|
||||
? NETWORK_CHOICES[value.toString() as keyof typeof NETWORK_CHOICES]
|
||||
: "Select network"
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.keys(NETWORK_CHOICES).map((key) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2 border-t-2 px-4 py-3">
|
||||
<Button theme="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating Project..." : "Create Project"}
|
||||
</Button>
|
||||
<PrimaryButton type="submit" size="sm" loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding project..." : "Add Project"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
<UnsplashImageModal
|
||||
handleClose={() => setIsUnsplashModalOpen(false)}
|
||||
isOpen={isUnsplashModalOpen}
|
||||
onSelect={(imageUrl) => {
|
||||
setValue("cover_image", imageUrl);
|
||||
setIsUnsplashModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user