feat: custom theming enhancements (#8342)

This commit is contained in:
Anmol Singh Bhatia 2025-12-16 18:17:59 +05:30 committed by GitHub
parent be1113b170
commit fa63964566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1203 additions and 465 deletions

View file

@ -0,0 +1,135 @@
import { useRef } from "react";
import { observer } from "mobx-react";
import type { UseFormGetValues, UseFormSetValue } from "react-hook-form";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { IUserTheme } from "@plane/types";
type Props = {
getValues: UseFormGetValues<IUserTheme>;
handleUpdateTheme: (formData: IUserTheme) => Promise<void>;
setValue: UseFormSetValue<IUserTheme>;
};
export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) {
const { getValues, handleUpdateTheme, setValue } = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
// translation
const { t } = useTranslation();
const handleDownloadConfig = () => {
try {
const currentValues = getValues();
const config = {
version: "1.0",
themeName: "Custom Theme",
primary: currentValues.primary,
background: currentValues.background,
darkPalette: currentValues.darkPalette,
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `plane-theme-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: "Theme configuration downloaded successfully.",
});
} catch (error) {
console.error("Failed to download config:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: "Failed to download theme configuration.",
});
}
};
const handleUploadConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const config = JSON.parse(text) as IUserTheme;
// Validate required fields
if (!config.primary || !config.background) {
throw new Error("Missing required fields: primary and background");
}
// Validate hex color format
const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
if (!hexPattern.test(config.primary)) {
throw new Error("Invalid brand color hex format");
}
if (!hexPattern.test(config.background)) {
throw new Error("Invalid neutral color hex format");
}
// Validate theme mode
const themeMode = config.darkPalette ?? false;
if (typeof themeMode !== "boolean") {
throw new Error("Invalid theme mode. Must be a boolean");
}
// Apply the configuration to form
const formData: IUserTheme = {
theme: "custom",
primary: config.primary,
background: config.background,
darkPalette: themeMode,
};
// Update form values
setValue("primary", formData.primary);
setValue("background", formData.background);
setValue("darkPalette", formData.darkPalette);
setValue("theme", "custom");
// Apply the theme
await handleUpdateTheme(formData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: "Theme configuration imported successfully",
});
} catch (error) {
console.error("Failed to upload config:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error instanceof Error ? error.message : "Failed to import theme configuration",
});
} finally {
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
return (
<div className="flex gap-2">
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
<Button variant="secondary" type="button" onClick={() => fileInputRef.current?.click()}>
Import config
</Button>
<Button variant="secondary" type="button" onClick={handleDownloadConfig}>
Download config
</Button>
</div>
);
});

View file

@ -1,126 +1,95 @@
import { useMemo } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// types
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUserTheme } from "@plane/types";
// ui
import { InputColorPicker } from "@plane/ui";
import { InputColorPicker, ToggleSwitch } from "@plane/ui";
import { applyCustomTheme } from "@plane/utils";
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useUserProfile } from "@/hooks/store/user";
// local imports
import { CustomThemeConfigHandler } from "./config-handler";
type TCustomThemeSelector = {
applyThemeChange: (theme: Partial<IUserTheme>) => void;
};
export const CustomThemeSelector = observer(function CustomThemeSelector(props: TCustomThemeSelector) {
const { applyThemeChange } = props;
// hooks
export const CustomThemeSelector = observer(function CustomThemeSelector() {
// store hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
// translation
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
watch,
} = useForm<IUserTheme>({
defaultValues: {
background: userProfile?.theme?.background !== "" ? userProfile?.theme?.background : "#0d101b",
text: userProfile?.theme?.text !== "" ? userProfile?.theme?.text : "#c5c5c5",
primary: userProfile?.theme?.primary !== "" ? userProfile?.theme?.primary : "#3f76ff",
sidebarBackground:
userProfile?.theme?.sidebarBackground !== "" ? userProfile?.theme?.sidebarBackground : "#0d101b",
sidebarText: userProfile?.theme?.sidebarText !== "" ? userProfile?.theme?.sidebarText : "#c5c5c5",
darkPalette: userProfile?.theme?.darkPalette || false,
palette: userProfile?.theme?.palette !== "" ? userProfile?.theme?.palette : "",
},
});
const inputRules = useMemo(
() => ({
minLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
maxLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
}),
[t] // Empty dependency array since these rules never change
);
// Loading state for async palette generation
const [isLoadingPalette, setIsLoadingPalette] = useState(false);
const handleUpdateTheme = async (formData: Partial<IUserTheme>) => {
const payload: IUserTheme = {
background: formData.background,
text: formData.text,
primary: formData.primary,
sidebarBackground: formData.sidebarBackground,
sidebarText: formData.sidebarText,
darkPalette: false,
palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`,
// Load saved theme from userProfile (fallback to defaults)
const getSavedTheme = (): IUserTheme => {
if (userProfile?.theme) {
const theme = userProfile.theme;
if (theme.primary && theme.background && theme.darkPalette !== undefined) {
return {
theme: "custom",
primary: theme.primary,
background: theme.background,
darkPalette: theme.darkPalette,
};
}
}
// Fallback to defaults
return {
theme: "custom",
primary: "#3f76ff",
background: "#1a1a1a",
darkPalette: false,
};
applyThemeChange(payload);
const updateCurrentUserThemePromise = updateUserTheme(payload);
setPromiseToast(updateCurrentUserThemePromise, {
loading: t("updating_theme"),
success: {
title: t("success"),
message: () => t("theme_updated_successfully"),
},
error: {
title: t("error"),
message: () => t("failed_to_update_the_theme"),
},
});
updateCurrentUserThemePromise
.then(() => {
captureElementAndEvent({
element: {
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
},
event: {
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
payload: {
theme: payload.theme,
},
state: "SUCCESS",
},
});
})
.catch(() => {
captureElementAndEvent({
element: {
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
},
event: {
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
payload: {
theme: payload.theme,
},
state: "ERROR",
},
});
});
return;
};
const handleValueChange = (val: string | undefined, onChange: any) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
getValues,
watch,
setValue,
} = useForm<IUserTheme>({
defaultValues: getSavedTheme(),
});
const handleUpdateTheme = async (formData: IUserTheme) => {
if (!formData.primary || !formData.background || formData.darkPalette === undefined) return;
try {
setIsLoadingPalette(true);
applyCustomTheme(formData.primary, formData.background, formData.darkPalette ? "dark" : "light");
// Save to profile endpoint
await updateUserTheme({
theme: "custom",
primary: formData.primary,
background: formData.background,
darkPalette: formData.darkPalette,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("theme_updated_successfully"),
});
} catch (error) {
console.error("Failed to apply theme:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("failed_to_update_the_theme"),
});
} finally {
setIsLoadingPalette(false);
}
};
const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => {
let hex = val;
// prepend a hashtag if it doesn't exist
if (val && val[0] !== "#") hex = `#${val}`;
onChange(hex);
};
@ -128,146 +97,98 @@ export const CustomThemeSelector = observer(function CustomThemeSelector(props:
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-16 font-semibold text-primary">{t("customize_your_theme")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
{/* Color Inputs */}
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
{/* Brand Color */}
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("background_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="background"
rules={{ ...inputRules, required: t("background_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full placeholder:text-placeholder/60"
style={{
backgroundColor: watch("background"),
color: watch("text"),
}}
hasError={Boolean(errors?.background)}
/>
)}
/>
{errors.background && <p className="mt-1 text-11 text-red-500">{errors.background.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="text"
rules={{ ...inputRules, required: t("text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full placeholder:text-placeholder/60"
style={{
backgroundColor: watch("text"),
color: watch("background"),
}}
hasError={Boolean(errors?.text)}
/>
)}
/>
{errors.text && <p className="mt-1 text-11 text-red-500">{errors.text.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("primary_color")}</h3>
<h3 className="text-left text-13 font-medium text-secondary">Brand color</h3>
<div className="w-full">
<Controller
control={control}
name="primary"
rules={{ ...inputRules, required: t("primary_color_is_required") }}
rules={{
required: "Brand color is required",
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code",
},
}}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#3f76ff"
className="w-full placeholder:text-placeholder/60"
className="w-full placeholder:text-placeholder"
style={{
backgroundColor: watch("primary"),
color: watch("text"),
backgroundColor: value,
color: "#ffffff",
}}
hasError={Boolean(errors?.primary)}
hasError={false}
/>
)}
/>
{errors.primary && <p className="mt-1 text-11 text-red-500">{errors.primary.message}</p>}
</div>
</div>
{/* Neutral Color */}
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_background_color")}</h3>
<h3 className="text-left text-13 font-medium text-secondary">Neutral color</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarBackground"
rules={{ ...inputRules, required: t("sidebar_background_color_is_required") }}
name="background"
rules={{
required: "Neutral color is required",
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code",
},
}}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
name="background"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full placeholder:text-placeholder/60"
placeholder="#1a1a1a"
className="w-full placeholder:text-placeholder"
style={{
backgroundColor: watch("sidebarBackground"),
color: watch("sidebarText"),
backgroundColor: value,
color: "#ffffff",
}}
hasError={Boolean(errors?.sidebarBackground)}
hasError={false}
/>
)}
/>
{errors.sidebarBackground && (
<p className="mt-1 text-11 text-red-500">{errors.sidebarBackground.message}</p>
)}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarText"
rules={{ ...inputRules, required: t("sidebar_text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full placeholder:text-placeholder/60"
style={{
backgroundColor: watch("sidebarText"),
color: watch("sidebarBackground"),
}}
hasError={Boolean(errors?.sidebarText)}
/>
)}
/>
{errors.sidebarText && <p className="mt-1 text-11 text-red-500">{errors.sidebarText.message}</p>}
</div>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? t("creating_theme") : t("set_theme")}
</Button>
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
{/* Import/Export Section */}
<CustomThemeConfigHandler getValues={getValues} handleUpdateTheme={handleUpdateTheme} setValue={setValue} />
<div className="flex items-center gap-4">
{/* Theme Mode Toggle */}
<div className="flex items-center gap-2">
<Controller
control={control}
name="darkPalette"
render={({ field: { value, onChange } }) => (
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
)}
/>
<span className="text-12 text-tertiary">{watch("darkPalette") ? "Dark mode" : "Light mode"}</span>
</div>
{/* Save Theme Button */}
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating..." : t("set_theme")}
</Button>
</div>
</div>
</form>
);

View file

@ -14,7 +14,9 @@ type Props = {
export function ThemeSwitch(props: Props) {
const { value, onChange } = props;
// translation
const { t } = useTranslation();
return (
<CustomSelect
value={value}
@ -48,6 +50,7 @@ export function ThemeSwitch(props: Props) {
)
}
onChange={onChange}
placement="bottom-end"
input
>
{THEME_OPTIONS.map((themeOption) => (