mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 11:57:56 +01:00
chore: custom theme enhancements
This commit is contained in:
@@ -32,11 +32,20 @@ export class ProfileStore implements IProfileStore {
|
||||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
darkPalette: undefined,
|
||||
// New custom theme fields
|
||||
brand: undefined,
|
||||
neutral: undefined,
|
||||
isDarkModeToggled: undefined,
|
||||
brandColor: undefined,
|
||||
neutralColor: undefined,
|
||||
themeMode: undefined,
|
||||
darkModeLightnessOffset: undefined,
|
||||
// Legacy fields
|
||||
text: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
@@ -34,6 +34,6 @@ function ProfileAppearancePage() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default observer(ProfileAppearancePage);
|
||||
export default ProfileAppearancePage;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
// components
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { applyTheme, applyCustomTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
@@ -35,6 +35,28 @@ function ProfileAppearancePage() {
|
||||
}
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
// Load custom theme from profile on mount
|
||||
useEffect(() => {
|
||||
const loadCustomTheme = async () => {
|
||||
if (currentTheme?.value === "custom" && userProfile?.theme) {
|
||||
try {
|
||||
const theme = userProfile.theme;
|
||||
if (theme.brandColor && theme.neutralColor && theme.themeMode) {
|
||||
await applyCustomTheme(
|
||||
theme.brandColor,
|
||||
theme.neutralColor,
|
||||
theme.themeMode,
|
||||
theme.darkModeLightnessOffset
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom theme from profile:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCustomTheme();
|
||||
}, [currentTheme, userProfile?.theme]);
|
||||
|
||||
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
@@ -52,12 +74,28 @@ function ProfileAppearancePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
const applyThemeChange = async (
|
||||
theme: Partial<IUserTheme> & {
|
||||
brandColor?: string;
|
||||
neutralColor?: string;
|
||||
themeMode?: "light" | "dark";
|
||||
darkModeLightnessOffset?: number;
|
||||
}
|
||||
) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
} else unsetCustomCssVariables();
|
||||
if (theme?.theme === "custom") {
|
||||
// New 2-color palette system loaded from profile
|
||||
if (theme?.brandColor && theme?.neutralColor && theme?.themeMode) {
|
||||
await applyCustomTheme(theme.brandColor, theme.neutralColor, theme.themeMode, theme.darkModeLightnessOffset);
|
||||
} else if (theme?.palette) {
|
||||
// Legacy 5-color system (backward compatibility)
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
}
|
||||
} else {
|
||||
// Clear custom theme when switching away
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { applyTheme, applyCustomTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
@@ -43,16 +43,59 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
}
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
// Load custom theme from profile when theme is custom
|
||||
useEffect(() => {
|
||||
const loadCustomTheme = async () => {
|
||||
if (currentTheme?.value === "custom" && userProfile?.theme) {
|
||||
try {
|
||||
const theme = userProfile.theme;
|
||||
if (theme.brandColor && theme.neutralColor && theme.themeMode) {
|
||||
await applyCustomTheme(
|
||||
theme.brandColor,
|
||||
theme.neutralColor,
|
||||
theme.themeMode,
|
||||
theme.darkModeLightnessOffset
|
||||
);
|
||||
} else if (theme.palette) {
|
||||
// Legacy support
|
||||
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
|
||||
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
|
||||
applyTheme(palette, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom theme from profile:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCustomTheme();
|
||||
}, [currentTheme, userProfile?.theme]);
|
||||
|
||||
// handlers
|
||||
const applyThemeChange = useCallback(
|
||||
(theme: Partial<IUserTheme>) => {
|
||||
async (theme: Partial<IUserTheme> & {
|
||||
brandColor?: string;
|
||||
neutralColor?: string;
|
||||
themeMode?: "light" | "dark";
|
||||
darkModeLightnessOffset?: number;
|
||||
}) => {
|
||||
const themeValue = theme?.theme || "system";
|
||||
setTheme(themeValue);
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
|
||||
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
|
||||
applyTheme(palette, false);
|
||||
if (theme?.theme === "custom") {
|
||||
// New 2-color palette system loaded from profile
|
||||
if (theme?.brandColor && theme?.neutralColor && theme?.themeMode) {
|
||||
await applyCustomTheme(
|
||||
theme.brandColor,
|
||||
theme.neutralColor,
|
||||
theme.themeMode,
|
||||
theme.darkModeLightnessOffset
|
||||
);
|
||||
} else if (theme?.palette) {
|
||||
// Legacy 5-color system (backward compatibility)
|
||||
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
|
||||
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
|
||||
applyTheme(palette, false);
|
||||
}
|
||||
} else {
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
|
||||
@@ -1,272 +1,487 @@
|
||||
import { useMemo } from "react";
|
||||
import { useState, useEffect as React_useEffect, useRef } 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";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// ui
|
||||
import { InputColorPicker } from "@plane/ui";
|
||||
import { InputColorPicker, ToggleSwitch } from "@plane/ui";
|
||||
import { applyCustomTheme, generateThemePalettes, invertPalette } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
interface CustomThemeFormData {
|
||||
brandColor: string;
|
||||
neutralColor: string;
|
||||
themeMode: "light" | "dark";
|
||||
darkModeLightnessOffset: number;
|
||||
}
|
||||
|
||||
type TCustomThemeSelector = {
|
||||
applyThemeChange: (theme: Partial<IUserTheme>) => void;
|
||||
applyThemeChange: (themeData: CustomThemeFormData & { theme: "custom" }) => void;
|
||||
};
|
||||
|
||||
export const CustomThemeSelector = observer(function CustomThemeSelector(props: TCustomThemeSelector) {
|
||||
const { applyThemeChange } = props;
|
||||
// hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
// Loading state for async palette generation
|
||||
const [isLoadingPalette, setIsLoadingPalette] = useState(false);
|
||||
|
||||
// File input ref for upload
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load saved theme from userProfile (fallback to defaults)
|
||||
const getSavedTheme = (): CustomThemeFormData => {
|
||||
if (userProfile?.theme) {
|
||||
const theme = userProfile.theme;
|
||||
if (theme.brandColor && theme.neutralColor) {
|
||||
return {
|
||||
brandColor: theme.brandColor,
|
||||
neutralColor: theme.neutralColor,
|
||||
themeMode: theme.themeMode || (theme.isDarkModeToggled ? "dark" : "light"),
|
||||
darkModeLightnessOffset: theme.darkModeLightnessOffset || -0.15,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback to defaults
|
||||
return {
|
||||
brandColor: "#3f76ff",
|
||||
neutralColor: "#1a1a1a",
|
||||
themeMode: "light",
|
||||
darkModeLightnessOffset: -0.15,
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { 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 : "",
|
||||
},
|
||||
setValue,
|
||||
} = useForm<CustomThemeFormData>({
|
||||
defaultValues: getSavedTheme(),
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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}`,
|
||||
theme: "custom",
|
||||
const [previewPalettes, setPreviewPalettes] = useState<{
|
||||
brandPalette: any;
|
||||
neutralPalette: any;
|
||||
neutralPaletteDark: any;
|
||||
}>(() => {
|
||||
const values = getSavedTheme();
|
||||
// Initialize with empty palettes, will be loaded async
|
||||
return {
|
||||
brandPalette: {},
|
||||
neutralPalette: {},
|
||||
neutralPaletteDark: {},
|
||||
};
|
||||
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",
|
||||
},
|
||||
// No need for local invertPalette - using the one from @plane/utils
|
||||
|
||||
// Load initial palettes on mount
|
||||
React_useEffect(() => {
|
||||
const loadInitialPalettes = () => {
|
||||
const values = getSavedTheme();
|
||||
setIsLoadingPalette(true);
|
||||
try {
|
||||
const palettes = generateThemePalettes(values.brandColor, values.neutralColor);
|
||||
setPreviewPalettes({
|
||||
brandPalette: palettes.brandPalette,
|
||||
neutralPalette: palettes.neutralPalette,
|
||||
neutralPaletteDark: palettes.neutralPalette, // Will be inverted in display
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial palettes:", error);
|
||||
} finally {
|
||||
setIsLoadingPalette(false);
|
||||
}
|
||||
};
|
||||
loadInitialPalettes();
|
||||
}, []);
|
||||
|
||||
// Watch colors and theme mode for live preview
|
||||
const brandColor = watch("brandColor");
|
||||
const neutralColor = watch("neutralColor");
|
||||
const themeMode = watch("themeMode");
|
||||
const darkModeLightnessOffset = watch("darkModeLightnessOffset");
|
||||
|
||||
// Update preview when colors change (no longer depends on themeMode or darkModeLightnessOffset)
|
||||
React_useEffect(() => {
|
||||
const updatePreview = () => {
|
||||
if (brandColor && neutralColor) {
|
||||
setIsLoadingPalette(true);
|
||||
try {
|
||||
const palettes = generateThemePalettes(brandColor, neutralColor);
|
||||
setPreviewPalettes({
|
||||
brandPalette: palettes.brandPalette,
|
||||
neutralPalette: palettes.neutralPalette,
|
||||
neutralPaletteDark: palettes.neutralPalette, // Will be inverted in display
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to generate preview:", error);
|
||||
} finally {
|
||||
setIsLoadingPalette(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the update
|
||||
const timeoutId = setTimeout(updatePreview, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [brandColor, neutralColor]);
|
||||
|
||||
const handleUpdateTheme = async (formData: CustomThemeFormData) => {
|
||||
try {
|
||||
setIsLoadingPalette(true);
|
||||
|
||||
// Apply theme immediately (now synchronous)
|
||||
applyCustomTheme(formData.brandColor, formData.neutralColor, formData.themeMode);
|
||||
|
||||
// Generate palettes to save
|
||||
const palettes = generateThemePalettes(formData.brandColor, formData.neutralColor);
|
||||
|
||||
// Generate light mode palette (brand + neutral combined)
|
||||
const lightModePalette = {
|
||||
brand: palettes.brandPalette,
|
||||
neutral: palettes.neutralPalette,
|
||||
};
|
||||
|
||||
// Generate dark mode palette (inverted neutral palette)
|
||||
const darkModeNeutralPalette = invertPalette(palettes.neutralPalette);
|
||||
const darkModeBrandPalette = invertPalette(palettes.brandPalette);
|
||||
const darkModePalette = {
|
||||
brand: darkModeBrandPalette,
|
||||
neutral: darkModeNeutralPalette,
|
||||
};
|
||||
|
||||
// Save to profile endpoint
|
||||
await updateUserTheme({
|
||||
theme: "custom",
|
||||
brandColor: formData.brandColor,
|
||||
neutralColor: formData.neutralColor,
|
||||
themeMode: formData.themeMode,
|
||||
isDarkModeToggled: formData.themeMode === "dark",
|
||||
darkModeLightnessOffset: formData.darkModeLightnessOffset,
|
||||
// New palette fields
|
||||
brand: JSON.stringify(palettes.brandPalette),
|
||||
neutral: JSON.stringify(palettes.neutralPalette),
|
||||
// Save light and dark mode full palettes
|
||||
palette: JSON.stringify(lightModePalette),
|
||||
darkPalette: JSON.stringify(darkModePalette),
|
||||
});
|
||||
|
||||
return;
|
||||
// Notify parent component
|
||||
applyThemeChange({ ...formData, theme: "custom" });
|
||||
|
||||
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: any) => {
|
||||
let hex = val;
|
||||
// prepend a hashtag if it doesn't exist
|
||||
if (val && val[0] !== "#") hex = `#${val}`;
|
||||
|
||||
onChange(hex);
|
||||
// useEffect will handle preview update with debouncing
|
||||
};
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
try {
|
||||
const currentValues = watch();
|
||||
const config = {
|
||||
version: "1.0",
|
||||
themeName: "Custom Theme",
|
||||
brandColor: currentValues.brandColor,
|
||||
neutralColor: currentValues.neutralColor,
|
||||
themeMode: currentValues.themeMode,
|
||||
darkModeLightnessOffset: currentValues.darkModeLightnessOffset,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.brandColor || !config.neutralColor) {
|
||||
throw new Error("Missing required fields: brandColor and neutralColor");
|
||||
}
|
||||
|
||||
// Validate hex color format
|
||||
const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
||||
if (!hexPattern.test(config.brandColor)) {
|
||||
throw new Error("Invalid brand color hex format");
|
||||
}
|
||||
if (!hexPattern.test(config.neutralColor)) {
|
||||
throw new Error("Invalid neutral color hex format");
|
||||
}
|
||||
|
||||
// Validate theme mode
|
||||
const themeMode = config.themeMode || "light";
|
||||
if (themeMode !== "light" && themeMode !== "dark") {
|
||||
throw new Error("Invalid theme mode. Must be 'light' or 'dark'");
|
||||
}
|
||||
|
||||
// Validate darkModeLightnessOffset
|
||||
const offset = config.darkModeLightnessOffset ?? -0.15;
|
||||
if (typeof offset !== "number" || offset < -0.4 || offset > -0.05) {
|
||||
throw new Error("Invalid darkModeLightnessOffset. Must be a number between -0.4 and -0.05");
|
||||
}
|
||||
|
||||
// Apply the configuration to form
|
||||
const formData: CustomThemeFormData = {
|
||||
brandColor: config.brandColor,
|
||||
neutralColor: config.neutralColor,
|
||||
themeMode,
|
||||
darkModeLightnessOffset: offset,
|
||||
};
|
||||
|
||||
// Update form values
|
||||
setValue("brandColor", formData.brandColor);
|
||||
setValue("neutralColor", formData.neutralColor);
|
||||
setValue("themeMode", formData.themeMode);
|
||||
setValue("darkModeLightnessOffset", formData.darkModeLightnessOffset);
|
||||
|
||||
// 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 (
|
||||
<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>
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Brand Color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
rules={{ ...inputRules, required: t("background_color_is_required") }}
|
||||
name="brandColor"
|
||||
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="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>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="primary"
|
||||
rules={{ ...inputRules, required: t("primary_color_is_required") }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="primary"
|
||||
name="brandColor"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#3f76ff"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
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="neutralColor"
|
||||
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="neutralColor"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#0d101b"
|
||||
placeholder="#1a1a1a"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
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>
|
||||
|
||||
{/* Theme Mode Toggle */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Theme Mode</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name="themeMode"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value === "dark"}
|
||||
onChange={(checked) => onChange(checked ? "dark" : "light")}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-12 text-tertiary">{watch("themeMode") === "light" ? "Light Mode" : "Dark Mode"}</span>
|
||||
</div>
|
||||
|
||||
{/* Preview Section */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Palette Preview</h3>
|
||||
|
||||
{isLoadingPalette ? (
|
||||
<div className="w-full h-24 flex items-center justify-center bg-layer-1 rounded-md">
|
||||
<span className="text-13 text-tertiary">Generating palette...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Brand Palette */}
|
||||
<div>
|
||||
<p className="text-11 text-tertiary mb-1">Brand Colors</p>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{Object.entries(previewPalettes.brandPalette).map(([shade, color]) => (
|
||||
<div
|
||||
key={shade}
|
||||
className="flex-shrink-0 h-8 w-8 rounded border border-subtle"
|
||||
style={{ background: String(color) }}
|
||||
title={`${shade}: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neutral Palette */}
|
||||
<div>
|
||||
<p className="text-11 text-tertiary mb-1">
|
||||
Neutral Colors {themeMode === "dark" && "(Inverted for Dark Mode)"}
|
||||
</p>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{Object.entries(
|
||||
themeMode === "dark"
|
||||
? invertPalette(previewPalettes.neutralPalette)
|
||||
: previewPalettes.neutralPalette
|
||||
).map(([shade, color]) => (
|
||||
<div
|
||||
key={shade}
|
||||
className="flex-shrink-0 h-8 w-8 rounded border border-subtle"
|
||||
style={{ background: String(color) }}
|
||||
title={`${shade}: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Palette (also show inverted in dark mode) */}
|
||||
{themeMode === "dark" && (
|
||||
<div>
|
||||
<p className="text-11 text-tertiary mb-1">Brand Colors (Inverted for Dark Mode)</p>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{Object.entries(invertPalette(previewPalettes.brandPalette)).map(([shade, color]) => (
|
||||
<div
|
||||
key={shade}
|
||||
className="flex-shrink-0 h-8 w-8 rounded border border-subtle"
|
||||
style={{ background: String(color) }}
|
||||
title={`${shade}: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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")}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
{/* Import/Export Section */}
|
||||
<div className="flex gap-2">
|
||||
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
|
||||
<Button variant="neutral-primary" type="button" onClick={() => fileInputRef.current?.click()} size="sm">
|
||||
Upload Config
|
||||
</Button>
|
||||
<Button variant="neutral-primary" type="button" onClick={handleDownloadConfig} size="sm">
|
||||
Download Config
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save Theme Button */}
|
||||
<Button variant="primary" type="submit" loading={isSubmitting || isLoadingPalette}>
|
||||
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating..." : t("set_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
|
||||
import type { TLanguage } from "@plane/i18n";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { applyTheme, applyCustomTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||
@@ -38,22 +38,36 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
||||
}, [sidebarCollapsed, setTheme, toggleSidebar]);
|
||||
|
||||
/**
|
||||
* Setting up the theme of the user by fetching it from local storage
|
||||
* Setting up the theme of the user by fetching it from profile
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
const currentTheme = userProfile?.theme?.theme || "system";
|
||||
const currentThemePalette = userProfile?.theme?.palette;
|
||||
const theme = userProfile?.theme;
|
||||
|
||||
if (currentTheme) {
|
||||
setTheme(currentTheme);
|
||||
if (currentTheme === "custom" && currentThemePalette) {
|
||||
applyTheme(
|
||||
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
false
|
||||
);
|
||||
} else unsetCustomCssVariables();
|
||||
if (currentTheme === "custom") {
|
||||
// New 2-color palette system
|
||||
if (theme.brandColor && theme.neutralColor && theme.themeMode) {
|
||||
applyCustomTheme(theme.brandColor, theme.neutralColor, theme.themeMode, theme.darkModeLightnessOffset);
|
||||
} else if (theme.palette) {
|
||||
// Legacy 5-color system (backward compatibility)
|
||||
applyTheme(theme.palette !== ",,,," ? theme.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
}
|
||||
} else {
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
}
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
|
||||
}, [
|
||||
userProfile?.theme?.theme,
|
||||
userProfile?.theme?.brandColor,
|
||||
userProfile?.theme?.neutralColor,
|
||||
userProfile?.theme?.themeMode,
|
||||
userProfile?.theme?.darkModeLightnessOffset,
|
||||
userProfile?.theme?.palette,
|
||||
setTheme,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.language) return;
|
||||
|
||||
@@ -38,6 +38,13 @@ export class ProfileStore implements IUserProfileStore {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
// New custom theme fields
|
||||
neutral: undefined,
|
||||
isDarkModeToggled: undefined,
|
||||
brandColor: undefined,
|
||||
neutralColor: undefined,
|
||||
themeMode: undefined,
|
||||
darkModeLightnessOffset: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
|
||||
Reference in New Issue
Block a user