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:
Anmol Singh Bhatia 2023-05-11 18:40:17 +05:30 committed by GitHub
parent 44d49b5500
commit 1329145173
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 575 additions and 373 deletions

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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";

View file

@ -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)} /> */}
</>
);
};