feat: custom theming enhancements (#8342)
This commit is contained in:
parent
be1113b170
commit
fa63964566
24 changed files with 1203 additions and 465 deletions
|
|
@ -32,13 +32,9 @@ export class ProfileStore implements IProfileStore {
|
|||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
|
|
@ -6,9 +6,7 @@ import type { I_THEME_OPTION } from "@plane/constants";
|
|||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
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 { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
|
|
@ -19,46 +17,36 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti
|
|||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
const { t } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
// hooks
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.theme?.theme) {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
}
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to Update the theme",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
} else unsetCustomCssVariables();
|
||||
};
|
||||
const handleThemeChange = useCallback(
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
setTheme(themeOption.value);
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update the theme.",
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -75,7 +63,7 @@ function ProfileAppearancePage() {
|
|||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<div id="context-menu-portal" />
|
||||
<div id="editor-portal" />
|
||||
<AppProvider>
|
||||
<div className={cn("h-screen w-full overflow-hidden bg-canvas relative flex flex-col", "app-container")}>
|
||||
<div className={cn("h-screen w-full overflow-hidden relative flex flex-col", "app-container")}>
|
||||
<main className="w-full h-full overflow-hidden relative">{children}</main>
|
||||
</div>
|
||||
</AppProvider>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
|
|
@ -6,8 +6,6 @@ import type { I_THEME_OPTION } from "@plane/constants";
|
|||
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";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
|
|
@ -23,48 +21,22 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
|||
description: string;
|
||||
};
|
||||
}) {
|
||||
// hooks
|
||||
const { setTheme } = useTheme();
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// initialize theme
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
|
||||
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
// handlers
|
||||
const applyThemeChange = useCallback(
|
||||
(theme: Partial<IUserTheme>) => {
|
||||
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);
|
||||
} else {
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
async (themeOption: I_THEME_OPTION) => {
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
try {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
setTheme(themeOption.value);
|
||||
const updatePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updatePromise, {
|
||||
loading: "Updating theme...",
|
||||
|
|
@ -81,7 +53,7 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
|||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[applyThemeChange, updateUserTheme]
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
|
@ -92,12 +64,12 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
|||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={
|
||||
<div className="">
|
||||
<div>
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
135
apps/web/core/components/core/theme/config-handler.tsx
Normal file
135
apps/web/core/components/core/theme/config-handler.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 { applyCustomTheme, clearCustomTheme } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||
|
|
@ -16,7 +16,7 @@ type TStoreWrapper = {
|
|||
children: ReactNode;
|
||||
};
|
||||
|
||||
const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
||||
function StoreWrapper(props: TStoreWrapper) {
|
||||
const { children } = props;
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
|
|
@ -38,22 +38,25 @@ 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.primary && theme.background && theme.darkPalette !== undefined) {
|
||||
applyCustomTheme(theme.primary, theme.background, theme.darkPalette ? "dark" : "light");
|
||||
}
|
||||
} else {
|
||||
clearCustomTheme();
|
||||
}
|
||||
}
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
|
||||
}, [userProfile?.theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.language) return;
|
||||
|
|
@ -66,6 +69,6 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
|||
}, [params, setQuery]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
}
|
||||
|
||||
export default StoreWrapper;
|
||||
export default observer(StoreWrapper);
|
||||
|
|
|
|||
|
|
@ -36,13 +36,9 @@ export class ProfileStore implements IUserProfileStore {
|
|||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
darkPalette: false,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
|
|
@ -219,12 +215,14 @@ export class ProfileStore implements IUserProfileStore {
|
|||
const currentProfileTheme = cloneDeep(this.data.theme);
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
const userKey: keyof IUserTheme = key as keyof IUserTheme;
|
||||
if (this.data.theme) set(this.data.theme, userKey, data[userKey]);
|
||||
Object.keys(data).forEach((key) => {
|
||||
const dataKey = key as keyof IUserTheme;
|
||||
if (this.data.theme) set(this.data.theme, dataKey, data[dataKey]);
|
||||
});
|
||||
});
|
||||
const userProfile = await this.userService.updateCurrentUserProfile({ theme: this.data.theme });
|
||||
const userProfile = await this.userService.updateCurrentUserProfile({
|
||||
theme: this.data.theme,
|
||||
});
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue