Skip to content

Commit fa63964

Browse files
feat: custom theming enhancements (#8342)
1 parent be1113b commit fa63964

File tree

24 files changed

+1195
-457
lines changed

24 files changed

+1195
-457
lines changed

apps/space/core/store/profile.store.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,9 @@ export class ProfileStore implements IProfileStore {
3232
last_workspace_id: undefined,
3333
theme: {
3434
theme: undefined,
35-
text: undefined,
36-
palette: undefined,
3735
primary: undefined,
3836
background: undefined,
3937
darkPalette: undefined,
40-
sidebarText: undefined,
41-
sidebarBackground: undefined,
4238
},
4339
onboarding_step: {
4440
workspace_join: false,

apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { SettingsHeading } from "@/components/settings/heading";
1010
// hooks
1111
import { useUserProfile } from "@/hooks/store/user";
1212

13-
function ProfileAppearancePage() {
13+
const ProfileAppearancePage = observer(() => {
1414
const { t } = useTranslation();
1515
// hooks
1616
const { data: userProfile } = useUserProfile();
@@ -34,6 +34,6 @@ function ProfileAppearancePage() {
3434
</div>
3535
</>
3636
);
37-
}
37+
});
3838

39-
export default observer(ProfileAppearancePage);
39+
export default ProfileAppearancePage;

apps/web/app/(all)/profile/appearance/page.tsx

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import { useEffect, useState } from "react";
1+
import { useCallback, useMemo } from "react";
22
import { observer } from "mobx-react";
33
import { useTheme } from "next-themes";
44
// plane imports
55
import type { I_THEME_OPTION } from "@plane/constants";
66
import { THEME_OPTIONS } from "@plane/constants";
77
import { useTranslation } from "@plane/i18n";
88
import { setPromiseToast } from "@plane/propel/toast";
9-
import type { IUserTheme } from "@plane/types";
109
// components
11-
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
1210
import { LogoSpinner } from "@/components/common/logo-spinner";
1311
import { PageHead } from "@/components/core/page-title";
1412
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
@@ -19,46 +17,36 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti
1917
import { useUserProfile } from "@/hooks/store/user";
2018

2119
function ProfileAppearancePage() {
22-
const { t } = useTranslation();
23-
const { setTheme } = useTheme();
24-
// states
25-
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
26-
// hooks
20+
// store hooks
2721
const { data: userProfile, updateUserTheme } = useUserProfile();
28-
29-
useEffect(() => {
30-
if (userProfile?.theme?.theme) {
31-
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
32-
if (userThemeOption) {
33-
setCurrentTheme(userThemeOption);
34-
}
35-
}
22+
// theme
23+
const { setTheme } = useTheme();
24+
// translation
25+
const { t } = useTranslation();
26+
// derived values
27+
const currentTheme = useMemo(() => {
28+
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
29+
return userThemeOption || null;
3630
}, [userProfile?.theme?.theme]);
3731

38-
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
39-
applyThemeChange({ theme: themeOption.value });
40-
41-
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
42-
setPromiseToast(updateCurrentUserThemePromise, {
43-
loading: "Updating theme...",
44-
success: {
45-
title: "Success!",
46-
message: () => "Theme updated successfully!",
47-
},
48-
error: {
49-
title: "Error!",
50-
message: () => "Failed to Update the theme",
51-
},
52-
});
53-
};
54-
55-
const applyThemeChange = (theme: Partial<IUserTheme>) => {
56-
setTheme(theme?.theme || "system");
57-
58-
if (theme?.theme === "custom" && theme?.palette) {
59-
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
60-
} else unsetCustomCssVariables();
61-
};
32+
const handleThemeChange = useCallback(
33+
(themeOption: I_THEME_OPTION) => {
34+
setTheme(themeOption.value);
35+
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
36+
setPromiseToast(updateCurrentUserThemePromise, {
37+
loading: "Updating theme...",
38+
success: {
39+
title: "Success!",
40+
message: () => "Theme updated successfully.",
41+
},
42+
error: {
43+
title: "Error!",
44+
message: () => "Failed to update the theme.",
45+
},
46+
});
47+
},
48+
[updateUserTheme]
49+
);
6250

6351
return (
6452
<>
@@ -75,7 +63,7 @@ function ProfileAppearancePage() {
7563
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
7664
</div>
7765
</div>
78-
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
66+
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
7967
</ProfileSettingContentWrapper>
8068
) : (
8169
<div className="grid h-full w-full place-items-center px-4 sm:px-0">

apps/web/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
7878
<div id="context-menu-portal" />
7979
<div id="editor-portal" />
8080
<AppProvider>
81-
<div className={cn("h-screen w-full overflow-hidden bg-canvas relative flex flex-col", "app-container")}>
81+
<div className={cn("h-screen w-full overflow-hidden relative flex flex-col", "app-container")}>
8282
<main className="w-full h-full overflow-hidden relative">{children}</main>
8383
</div>
8484
</AppProvider>
Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import { useEffect, useState, useCallback } from "react";
1+
import { useCallback, useMemo } from "react";
22
import { observer } from "mobx-react";
33
import { useTheme } from "next-themes";
44
// plane imports
55
import type { I_THEME_OPTION } from "@plane/constants";
66
import { THEME_OPTIONS } from "@plane/constants";
77
import { useTranslation } from "@plane/i18n";
88
import { setPromiseToast } from "@plane/propel/toast";
9-
import type { IUserTheme } from "@plane/types";
10-
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
119
// components
1210
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
1311
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
@@ -23,48 +21,22 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
2321
description: string;
2422
};
2523
}) {
26-
// hooks
27-
const { setTheme } = useTheme();
24+
// store hooks
2825
const { data: userProfile, updateUserTheme } = useUserProfile();
29-
30-
// states
31-
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
32-
26+
// theme
27+
const { setTheme } = useTheme();
28+
// translation
3329
const { t } = useTranslation();
34-
35-
// initialize theme
36-
useEffect(() => {
37-
if (!userProfile?.theme?.theme) return;
38-
39-
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
40-
41-
if (userThemeOption) {
42-
setCurrentTheme(userThemeOption);
43-
}
30+
// derived values
31+
const currentTheme = useMemo(() => {
32+
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
33+
return userThemeOption || null;
4434
}, [userProfile?.theme?.theme]);
4535

46-
// handlers
47-
const applyThemeChange = useCallback(
48-
(theme: Partial<IUserTheme>) => {
49-
const themeValue = theme?.theme || "system";
50-
setTheme(themeValue);
51-
52-
if (theme?.theme === "custom" && theme?.palette) {
53-
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
54-
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
55-
applyTheme(palette, false);
56-
} else {
57-
unsetCustomCssVariables();
58-
}
59-
},
60-
[setTheme]
61-
);
62-
6336
const handleThemeChange = useCallback(
64-
async (themeOption: I_THEME_OPTION) => {
37+
(themeOption: I_THEME_OPTION) => {
6538
try {
66-
applyThemeChange({ theme: themeOption.value });
67-
39+
setTheme(themeOption.value);
6840
const updatePromise = updateUserTheme({ theme: themeOption.value });
6941
setPromiseToast(updatePromise, {
7042
loading: "Updating theme...",
@@ -81,7 +53,7 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
8153
console.error("Error updating theme:", error);
8254
}
8355
},
84-
[applyThemeChange, updateUserTheme]
56+
[updateUserTheme]
8557
);
8658

8759
if (!userProfile) return null;
@@ -92,12 +64,12 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
9264
title={t(props.option.title)}
9365
description={t(props.option.description)}
9466
control={
95-
<div className="">
67+
<div>
9668
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
9769
</div>
9870
}
9971
/>
100-
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
72+
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
10173
</>
10274
);
10375
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useRef } from "react";
2+
import { observer } from "mobx-react";
3+
import type { UseFormGetValues, UseFormSetValue } from "react-hook-form";
4+
// plane imports
5+
import { useTranslation } from "@plane/i18n";
6+
import { Button } from "@plane/propel/button";
7+
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
8+
import type { IUserTheme } from "@plane/types";
9+
10+
type Props = {
11+
getValues: UseFormGetValues<IUserTheme>;
12+
handleUpdateTheme: (formData: IUserTheme) => Promise<void>;
13+
setValue: UseFormSetValue<IUserTheme>;
14+
};
15+
16+
export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) {
17+
const { getValues, handleUpdateTheme, setValue } = props;
18+
// refs
19+
const fileInputRef = useRef<HTMLInputElement>(null);
20+
// translation
21+
const { t } = useTranslation();
22+
23+
const handleDownloadConfig = () => {
24+
try {
25+
const currentValues = getValues();
26+
const config = {
27+
version: "1.0",
28+
themeName: "Custom Theme",
29+
primary: currentValues.primary,
30+
background: currentValues.background,
31+
darkPalette: currentValues.darkPalette,
32+
};
33+
34+
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
35+
const url = URL.createObjectURL(blob);
36+
const link = document.createElement("a");
37+
link.href = url;
38+
link.download = `plane-theme-${Date.now()}.json`;
39+
document.body.appendChild(link);
40+
link.click();
41+
document.body.removeChild(link);
42+
URL.revokeObjectURL(url);
43+
44+
setToast({
45+
type: TOAST_TYPE.SUCCESS,
46+
title: t("success"),
47+
message: "Theme configuration downloaded successfully.",
48+
});
49+
} catch (error) {
50+
console.error("Failed to download config:", error);
51+
setToast({
52+
type: TOAST_TYPE.ERROR,
53+
title: t("error"),
54+
message: "Failed to download theme configuration.",
55+
});
56+
}
57+
};
58+
59+
const handleUploadConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
60+
const file = event.target.files?.[0];
61+
if (!file) return;
62+
63+
try {
64+
const text = await file.text();
65+
const config = JSON.parse(text) as IUserTheme;
66+
67+
// Validate required fields
68+
if (!config.primary || !config.background) {
69+
throw new Error("Missing required fields: primary and background");
70+
}
71+
72+
// Validate hex color format
73+
const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
74+
if (!hexPattern.test(config.primary)) {
75+
throw new Error("Invalid brand color hex format");
76+
}
77+
if (!hexPattern.test(config.background)) {
78+
throw new Error("Invalid neutral color hex format");
79+
}
80+
81+
// Validate theme mode
82+
const themeMode = config.darkPalette ?? false;
83+
if (typeof themeMode !== "boolean") {
84+
throw new Error("Invalid theme mode. Must be a boolean");
85+
}
86+
87+
// Apply the configuration to form
88+
const formData: IUserTheme = {
89+
theme: "custom",
90+
primary: config.primary,
91+
background: config.background,
92+
darkPalette: themeMode,
93+
};
94+
95+
// Update form values
96+
setValue("primary", formData.primary);
97+
setValue("background", formData.background);
98+
setValue("darkPalette", formData.darkPalette);
99+
setValue("theme", "custom");
100+
101+
// Apply the theme
102+
await handleUpdateTheme(formData);
103+
104+
setToast({
105+
type: TOAST_TYPE.SUCCESS,
106+
title: t("success"),
107+
message: "Theme configuration imported successfully",
108+
});
109+
} catch (error) {
110+
console.error("Failed to upload config:", error);
111+
setToast({
112+
type: TOAST_TYPE.ERROR,
113+
title: t("error"),
114+
message: error instanceof Error ? error.message : "Failed to import theme configuration",
115+
});
116+
} finally {
117+
// Reset file input
118+
if (fileInputRef.current) {
119+
fileInputRef.current.value = "";
120+
}
121+
}
122+
};
123+
124+
return (
125+
<div className="flex gap-2">
126+
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
127+
<Button variant="secondary" type="button" onClick={() => fileInputRef.current?.click()}>
128+
Import config
129+
</Button>
130+
<Button variant="secondary" type="button" onClick={handleDownloadConfig}>
131+
Download config
132+
</Button>
133+
</div>
134+
);
135+
});

0 commit comments

Comments
 (0)