mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: custom theming (#1028)
* chore: custom theme types and constants * feat: custom theming * feat: preferences tab added in profile settings * feat: remove unneccessary file * feat:theme apply on page load * fix: theme switch dropdown fix * feat: color picker input, theme icon added, chore: code refactor * style: color picker icon added * fix: mutation fix * fix: palette sequence fix * chore: default custom theme palette updated * style: join project and not authorized page theming * fix: merge conflict * fix: build fix and preferences tab layout fix
This commit is contained in:
parent
44d49b5500
commit
1329145173
@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
description: "You are not authorized to view this page",
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image
|
||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||
@ -36,7 +36,9 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
||||
alt="ProjectSettingImg"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-medium">Oops! You are not authorized to view this page</h1>
|
||||
<h1 className="text-xl font-medium text-brand-base">
|
||||
Oops! You are not authorized to view this page
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
{user ? (
|
||||
|
@ -41,11 +41,11 @@ export const JoinProject: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium">You are not a member of this project</h1>
|
||||
<h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
|
121
apps/app/components/core/color-picker-input.tsx
Normal file
121
apps/app/components/core/color-picker-input.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
|
||||
// react-form
|
||||
import {
|
||||
FieldError,
|
||||
FieldErrorsImpl,
|
||||
Merge,
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
// react-color
|
||||
import { ColorResult, SketchPicker } from "react-color";
|
||||
// component
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Input } from "components/ui";
|
||||
// icons
|
||||
import { ColorPickerIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
watch: UseFormWatch<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
register: UseFormRegister<any>;
|
||||
};
|
||||
|
||||
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => {
|
||||
const handleColorChange = (newColor: ColorResult) => {
|
||||
const { hex } = newColor;
|
||||
setValue(name, hex);
|
||||
};
|
||||
|
||||
const getColorText = (colorName: string) => {
|
||||
switch (colorName) {
|
||||
case "accent":
|
||||
return "Accent";
|
||||
case "bgBase":
|
||||
return "Background";
|
||||
case "bgSurface1":
|
||||
return "Background surface 1";
|
||||
case "bgSurface2":
|
||||
return "Background surface 2";
|
||||
case "border":
|
||||
return "Border";
|
||||
case "sidebar":
|
||||
return "Sidebar";
|
||||
case "textBase":
|
||||
return "Text primary";
|
||||
case "textSecondary":
|
||||
return "Text secondary";
|
||||
default:
|
||||
return "Color";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative ">
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={error}
|
||||
value={watch(name)}
|
||||
register={register}
|
||||
validations={{
|
||||
required: `${getColorText(name)} color is required`,
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: `${getColorText(name)} color should be hex format`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-4 top-2.5">
|
||||
<Popover className="relative grid place-items-center">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className={`group inline-flex items-center outline-none ${
|
||||
open ? "text-brand-base" : "text-brand-secondary"
|
||||
}`}
|
||||
>
|
||||
{watch(name) && watch(name) !== "" ? (
|
||||
<span
|
||||
className="h-4 w-4 rounded border border-brand-base"
|
||||
style={{
|
||||
backgroundColor: `${watch(name)}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ColorPickerIcon
|
||||
height={14}
|
||||
width={14}
|
||||
className="fill-current text-brand-base"
|
||||
/>
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
|
||||
<SketchPicker color={watch(name)} onChange={handleColorChange} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,267 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
|
||||
const defaultValues = {
|
||||
palette: "",
|
||||
};
|
||||
|
||||
export const ThemeForm: React.FC<any> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<any>({
|
||||
defaultValues,
|
||||
});
|
||||
const [darkPalette, setDarkPalette] = useState(false);
|
||||
|
||||
const handleUpdateTheme = async (formData: any) => {
|
||||
await handleFormSubmit({ ...formData, darkPalette });
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
// --color-bg-base: 25, 27, 27;
|
||||
// --color-bg-surface-1: 31, 32, 35;
|
||||
// --color-bg-surface-2: 39, 42, 45;
|
||||
|
||||
// --color-border: 46, 50, 52;
|
||||
// --color-bg-sidebar: 19, 20, 22;
|
||||
// --color-accent: 60, 133, 217;
|
||||
|
||||
// --color-text-base: 255, 255, 255;
|
||||
// --color-text-secondary: 142, 148, 146;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-brand-base">Customize your theme</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgBase"
|
||||
label="Background"
|
||||
name="bgBase"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgBase}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgSurface1"
|
||||
label="Background surface 1"
|
||||
name="bgSurface1"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgSurface1}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background surface 1 color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background surface 1 color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="bgSurface2"
|
||||
label="Background surface 2"
|
||||
name="bgSurface1"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.bgSurface1}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Background surface 2 color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Background surface 2 color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="border"
|
||||
label="Border"
|
||||
name="border"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.border}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Border color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Border color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="sidebar"
|
||||
label="Sidebar"
|
||||
name="sidebar"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.sidebar}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Sidebar color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Sidebar color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
id="accent"
|
||||
label="Accent"
|
||||
name="accent"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.accent}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Accent color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Accent color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Input
|
||||
id="textBase"
|
||||
label="Text primary"
|
||||
name="textBase"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.textBase}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Text primary color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Text primary color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Input
|
||||
id="textSecondary"
|
||||
label="Text secondary"
|
||||
name="textSecondary"
|
||||
type="name"
|
||||
placeholder="#FFFFFF"
|
||||
autoComplete="off"
|
||||
error={errors.textSecondary}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Text secondary color is required",
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Text secondary color should be hex format",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="palette"
|
||||
label="All colors"
|
||||
name="palette"
|
||||
type="name"
|
||||
placeholder="Enter comma separated hex colors"
|
||||
autoComplete="off"
|
||||
error={errors.palette}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Color values is required",
|
||||
pattern: {
|
||||
value: /^(#(?:[0-9a-fA-F]{3}){1,2},){7}#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: "Color values should be hex format, separated by commas",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => setDarkPalette((prevData) => !prevData)}
|
||||
>
|
||||
<span className="text-xs">Dark palette</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
|
||||
darkPalette ? "bg-brand-accent" : "bg-gray-300"
|
||||
} transition-colors duration-300 ease-in-out focus:outline-none`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="sr-only">Dark palette</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-3 w-3 ${
|
||||
darkPalette ? "translate-x-3" : "translate-x-0"
|
||||
} transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-300 ease-in-out`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Theme..."
|
||||
: "Update Theme"
|
||||
: isSubmitting
|
||||
? "Creating Theme..."
|
||||
: "Set Theme"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,65 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { ThemeForm } from "./custom-theme-form";
|
||||
// helpers
|
||||
import { applyTheme } from "helpers/theme.helper";
|
||||
// fetch-keys
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CustomThemeModal: React.FC<Props> = ({ isOpen, handleClose }) => {
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: any) => {
|
||||
applyTheme(formData.palette, formData.darkPalette);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" 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-[#131313] bg-opacity-50 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 transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<ThemeForm
|
||||
handleClose={handleClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
status={false}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
198
apps/app/components/core/custom-theme-selector.tsx
Normal file
198
apps/app/components/core/custom-theme-selector.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
import { ColorPickerInput } from "components/core";
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
// helper
|
||||
import { applyTheme } from "helpers/theme.helper";
|
||||
// types
|
||||
import { ICustomTheme } from "types";
|
||||
|
||||
type Props = {
|
||||
preLoadedData?: Partial<ICustomTheme> | null;
|
||||
};
|
||||
|
||||
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
|
||||
const [darkPalette, setDarkPalette] = useState(false);
|
||||
|
||||
const defaultValues = {
|
||||
accent: preLoadedData?.accent ?? "#FE5050",
|
||||
bgBase: preLoadedData?.bgBase ?? "#FFF7F7",
|
||||
bgSurface1: preLoadedData?.bgSurface1 ?? "#FFE0E0",
|
||||
bgSurface2: preLoadedData?.bgSurface2 ?? "#FFF7F7",
|
||||
border: preLoadedData?.border ?? "#FFC9C9",
|
||||
darkPalette: preLoadedData?.darkPalette ?? false,
|
||||
palette: preLoadedData?.palette ?? "",
|
||||
sidebar: preLoadedData?.sidebar ?? "#FFFFFF",
|
||||
textBase: preLoadedData?.textBase ?? "#430000",
|
||||
textSecondary: preLoadedData?.textSecondary ?? "#323232",
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
} = useForm<any>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const { mutateUser } = useUser();
|
||||
|
||||
const handleFormSubmit = async (formData: any) => {
|
||||
await userService
|
||||
.updateUser({
|
||||
theme: {
|
||||
accent: formData.accent,
|
||||
bgBase: formData.bgBase,
|
||||
bgSurface1: formData.bgSurface1,
|
||||
bgSurface2: formData.bgSurface2,
|
||||
border: formData.border,
|
||||
darkPalette: darkPalette,
|
||||
palette: `${formData.bgBase},${formData.bgSurface1},${formData.bgSurface2},${formData.border},${formData.sidebar},${formData.accent},${formData.textBase},${formData.textSecondary}`,
|
||||
sidebar: formData.sidebar,
|
||||
textBase: formData.textBase,
|
||||
textSecondary: formData.textSecondary,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
mutateUser((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return { ...prevData, user: res };
|
||||
}, false);
|
||||
applyTheme(formData.palette, darkPalette);
|
||||
setTheme("custom");
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async (formData: any) => {
|
||||
await handleFormSubmit({ ...formData, darkPalette });
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...preLoadedData,
|
||||
});
|
||||
}, [preLoadedData, reset]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-semibold text-brand-base">Customize your theme</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Background</h3>
|
||||
<ColorPickerInput
|
||||
name="bgBase"
|
||||
error={errors.bgBase}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Background surface 1</h3>
|
||||
<ColorPickerInput
|
||||
name="bgSurface1"
|
||||
error={errors.bgSurface1}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Background surface 2</h3>
|
||||
<ColorPickerInput
|
||||
name="bgSurface2"
|
||||
error={errors.bgSurface2}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Border</h3>
|
||||
<ColorPickerInput
|
||||
name="border"
|
||||
error={errors.border}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Sidebar</h3>
|
||||
<ColorPickerInput
|
||||
name="sidebar"
|
||||
error={errors.sidebar}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Accent</h3>
|
||||
<ColorPickerInput
|
||||
name="accent"
|
||||
error={errors.accent}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Text primary</h3>
|
||||
<ColorPickerInput
|
||||
name="textBase"
|
||||
error={errors.textBase}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-base text-brand-secondary">Text secondary</h3>
|
||||
<ColorPickerInput
|
||||
name="textSecondary"
|
||||
error={errors.textSecondary}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Creating Theme..." : "Set Theme"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -13,3 +13,5 @@ export * from "./link-modal";
|
||||
export * from "./image-picker-popover";
|
||||
export * from "./feeds";
|
||||
export * from "./theme-switch";
|
||||
export * from "./custom-theme-selector";
|
||||
export * from "./color-picker-input";
|
||||
|
@ -1,12 +1,30 @@
|
||||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { THEMES_OBJ } from "constants/themes";
|
||||
import { CustomSelect } from "components/ui";
|
||||
import { CustomThemeModal } from "./custom-theme-modal";
|
||||
import { useState, useEffect, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export const ThemeSwitch = () => {
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// constants
|
||||
import { THEMES_OBJ } from "constants/themes";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// helper
|
||||
import { applyTheme } from "helpers/theme.helper";
|
||||
// types
|
||||
import { ICustomTheme, IUser } from "types";
|
||||
|
||||
type Props = {
|
||||
user: IUser | undefined;
|
||||
setPreLoadedData: Dispatch<SetStateAction<ICustomTheme | null>>;
|
||||
customThemeSelectorOptions: boolean;
|
||||
setCustomThemeSelectorOptions: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ThemeSwitch: React.FC<Props> = ({
|
||||
user,
|
||||
setPreLoadedData,
|
||||
customThemeSelectorOptions,
|
||||
setCustomThemeSelectorOptions,
|
||||
}) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [customThemeModal, setCustomThemeModal] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
@ -18,15 +36,49 @@ export const ThemeSwitch = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomSelect
|
||||
value={theme}
|
||||
label={theme ? THEMES_OBJ.find((t) => t.value === theme)?.label : "Select your theme"}
|
||||
label={
|
||||
currentThemeObj ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
style={{
|
||||
borderColor: currentThemeObj.icon.border,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: currentThemeObj.icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: currentThemeObj.icon.border,
|
||||
background: currentThemeObj.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{currentThemeObj.label}
|
||||
</div>
|
||||
) : (
|
||||
"Select your theme"
|
||||
)
|
||||
}
|
||||
onChange={({ value, type }: { value: string; type: string }) => {
|
||||
if (value === "custom") {
|
||||
if (!customThemeModal) setCustomThemeModal(true);
|
||||
if (user?.theme.palette) {
|
||||
setPreLoadedData(user.theme);
|
||||
}
|
||||
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
|
||||
} else {
|
||||
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
|
||||
const cssVars = [
|
||||
"--color-bg-base",
|
||||
"--color-bg-surface-1",
|
||||
@ -41,20 +93,41 @@ export const ThemeSwitch = () => {
|
||||
];
|
||||
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
|
||||
}
|
||||
document.documentElement.style.setProperty("color-scheme", type);
|
||||
setTheme(value);
|
||||
document.documentElement.style.setProperty("color-scheme", type);
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
position="right"
|
||||
>
|
||||
{THEMES_OBJ.map(({ value, label, type }) => (
|
||||
{THEMES_OBJ.map(({ value, label, type, icon }) => (
|
||||
<CustomSelect.Option key={value} value={{ value, type }}>
|
||||
{label}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
style={{
|
||||
borderColor: icon.border,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: icon.border,
|
||||
background: icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{label}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
{/* <CustomThemeModal isOpen={customThemeModal} handleClose={() => setCustomThemeModal(false)} /> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
16
apps/app/components/icons/color-picker-icon.tsx
Normal file
16
apps/app/components/icons/color-picker-icon.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ColorPickerIcon: React.FC<Props> = ({ width = 14, height = 14, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0.8125 13.7508C0.65 13.7508 0.515625 13.6977 0.409375 13.5914C0.303125 13.4852 0.25 13.3508 0.25 13.1883V10.8258C0.25 10.7508 0.2625 10.682 0.2875 10.6195C0.3125 10.557 0.35625 10.4945 0.41875 10.432L7.31875 3.53203L6.34375 2.55703C6.24375 2.45703 6.19688 2.32891 6.20312 2.17266C6.20938 2.01641 6.2625 1.88828 6.3625 1.78828C6.4625 1.68828 6.59063 1.63828 6.74688 1.63828C6.90313 1.63828 7.03125 1.68828 7.13125 1.78828L8.4625 3.13828L11.125 0.475781C11.2625 0.338281 11.4094 0.269531 11.5656 0.269531C11.7219 0.269531 11.8688 0.338281 12.0063 0.475781L13.525 1.99453C13.6625 2.13203 13.7313 2.27891 13.7313 2.43516C13.7313 2.59141 13.6625 2.73828 13.525 2.87578L10.8625 5.53828L12.2125 6.88828C12.3125 6.98828 12.3625 7.11328 12.3625 7.26328C12.3625 7.41328 12.3125 7.53828 12.2125 7.63828C12.1125 7.73828 11.9844 7.78828 11.8281 7.78828C11.6719 7.78828 11.5438 7.73828 11.4438 7.63828L10.4688 6.68203L3.56875 13.582C3.50625 13.6445 3.44375 13.6883 3.38125 13.7133C3.31875 13.7383 3.25 13.7508 3.175 13.7508H0.8125ZM1.375 12.6258H3.00625L9.6625 5.96953L8.03125 4.33828L1.375 10.9945V12.6258ZM10.0563 4.75078L12.3813 2.42578L11.575 1.61953L9.25 3.94453L10.0563 4.75078Z" />
|
||||
</svg>
|
||||
);
|
@ -74,3 +74,4 @@ export * from "./default-file-icon";
|
||||
export * from "./video-file-icon";
|
||||
export * from "./audio-file-icon";
|
||||
export * from "./command-icon";
|
||||
export * from "./color-picker-icon";
|
||||
|
@ -1,35 +1,54 @@
|
||||
export const THEMES = [
|
||||
"light",
|
||||
"dark",
|
||||
"light-contrast",
|
||||
"dark-contrast",
|
||||
// "custom"
|
||||
];
|
||||
export const THEMES = ["light", "dark", "light-contrast", "dark-contrast", "custom"];
|
||||
|
||||
export const THEMES_OBJ = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
type: "light",
|
||||
icon: {
|
||||
border: "#DEE2E6",
|
||||
color1: "#FAFAFA",
|
||||
color2: "#3F76FF",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
type: "dark",
|
||||
icon: {
|
||||
border: "#2E3234",
|
||||
color1: "#191B1B",
|
||||
color2: "#3C85D9",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "light-contrast",
|
||||
label: "Light High Contrast",
|
||||
type: "light",
|
||||
icon: {
|
||||
border: "#000000",
|
||||
color1: "#FFFFFF",
|
||||
color2: "#3F76FF",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "dark-contrast",
|
||||
label: "Dark High Contrast",
|
||||
type: "dark",
|
||||
icon: {
|
||||
border: "#FFFFFF",
|
||||
color1: "#030303",
|
||||
color2: "#3A8BE9",
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom",
|
||||
type: "light",
|
||||
icon: {
|
||||
border: "#FFC9C9",
|
||||
color1: "#FFF7F7",
|
||||
color2: "#FF5151",
|
||||
},
|
||||
},
|
||||
// {
|
||||
// value: "custom",
|
||||
// label: "Custom",
|
||||
// type: "light",
|
||||
// },
|
||||
];
|
||||
|
@ -4,10 +4,14 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import ToastAlert from "components/toast-alert";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// fetch-keys
|
||||
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
|
||||
// helper
|
||||
import { applyTheme } from "helpers/theme.helper";
|
||||
// constants
|
||||
|
||||
export const themeContext = createContext<ContextType>({} as ContextType);
|
||||
@ -61,6 +65,7 @@ export const reducer: ReducerFunctionType = (state, action) => {
|
||||
|
||||
export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { user } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -85,6 +90,15 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
});
|
||||
}, [myViewProps]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = localStorage.getItem("theme");
|
||||
if (theme && theme === "custom") {
|
||||
if (user && user.theme.palette) {
|
||||
applyTheme(user.theme.palette, user.theme.darkPalette);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<themeContext.Provider
|
||||
value={{
|
||||
|
@ -85,6 +85,10 @@ const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
|
||||
label: "Activity",
|
||||
href: `/${workspaceSlug}/me/profile/activity`,
|
||||
},
|
||||
{
|
||||
label: "Preferences",
|
||||
href: `/${workspaceSlug}/me/profile/preferences`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
||||
// components
|
||||
import { ImageUploadModal, ThemeSwitch } from "components/core";
|
||||
import { ImageUploadModal } from "components/core";
|
||||
// ui
|
||||
import { CustomSelect, DangerButton, Input, SecondaryButton, Spinner } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
@ -280,17 +280,6 @@ const Profile: NextPage = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold text-brand-base">Theme</h4>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Select or customize your interface color scheme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<SecondaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update profile"}
|
||||
|
81
apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx
Normal file
81
apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// layouts
|
||||
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
||||
import SettingsNavbar from "layouts/settings-navbar";
|
||||
// components
|
||||
import { CustomThemeSelector, ThemeSwitch } from "components/core";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// types
|
||||
import { ICustomTheme } from "types";
|
||||
|
||||
const ProfilePreferences = () => {
|
||||
const { user: myProfile } = useUser();
|
||||
const { theme } = useTheme();
|
||||
const [customThemeSelectorOptions, setCustomThemeSelectorOptions] = useState(false);
|
||||
const [preLoadedData, setPreLoadedData] = useState<ICustomTheme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "custom") {
|
||||
if (myProfile?.theme.palette) setPreLoadedData(myProfile.theme);
|
||||
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
|
||||
}
|
||||
}, [myProfile, theme]);
|
||||
|
||||
return (
|
||||
<WorkspaceAuthorizationLayout
|
||||
meta={{
|
||||
title: "Plane - My Profile",
|
||||
}}
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="My Profile Preferences" />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
>
|
||||
{myProfile ? (
|
||||
<div className="px-24 py-8">
|
||||
<div className="mb-12 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold">Profile Settings</h3>
|
||||
<p className="mt-1 text-brand-secondary">
|
||||
This information will be visible to only you.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsNavbar profilePage />
|
||||
</div>
|
||||
<div className="space-y-8 sm:space-y-12">
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold text-brand-base">Theme</h4>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Select or customize your interface color scheme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch
|
||||
user={myProfile}
|
||||
setPreLoadedData={setPreLoadedData}
|
||||
customThemeSelectorOptions={customThemeSelectorOptions}
|
||||
setCustomThemeSelectorOptions={setCustomThemeSelectorOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{customThemeSelectorOptions && <CustomThemeSelector preLoadedData={preLoadedData} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceAuthorizationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePreferences;
|
14
apps/app/types/users.d.ts
vendored
14
apps/app/types/users.d.ts
vendored
@ -18,6 +18,7 @@ export interface IUser {
|
||||
is_onboarded: boolean;
|
||||
token: string;
|
||||
role: string;
|
||||
theme: ICustomTheme;
|
||||
|
||||
my_issues_prop?: {
|
||||
properties: Properties;
|
||||
@ -27,6 +28,19 @@ export interface IUser {
|
||||
[...rest: string]: any;
|
||||
}
|
||||
|
||||
export interface ICustomTheme {
|
||||
accent: string;
|
||||
bgBase: string;
|
||||
bgSurface1: string;
|
||||
bgSurface2: string;
|
||||
border: string;
|
||||
darkPalette: boolean;
|
||||
palette: string;
|
||||
sidebar: string;
|
||||
textBase: string;
|
||||
textSecondary: string;
|
||||
}
|
||||
|
||||
export interface ICurrentUserResponse {
|
||||
assigned_issues: number;
|
||||
user: IUser;
|
||||
|
Loading…
Reference in New Issue
Block a user