feat: add theme editor with Monaco and custom CSS injection

This commit is contained in:
Hachi-R 2025-01-29 03:46:22 -03:00
parent 3e2d7a751c
commit 5a19e9fd12
23 changed files with 516 additions and 20 deletions

View File

@ -36,6 +36,7 @@
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",

View File

@ -297,7 +297,25 @@
"subscription_renew_cancelled": "Automatic renewal is disabled",
"subscription_renews_on": "Your subscription renews on {{date}}",
"bill_sent_until": "Your next bill will be sent until this day",
"no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece."
"no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece.",
"editor_tab_code": "Code",
"editor_tab_info": "Info",
"editor_tab_save": "Save",
"web_store": "Web store",
"clear_themes": "Clear",
"add_theme": "Add",
"add_theme_modal_title": "Add custom theme",
"add_theme_modal_description": "Create a new theme to customize Hydra's appearance",
"theme_name": "Name",
"insert_theme_name": "Insert theme name",
"set_theme": "Set theme",
"unset_theme": "Unset theme",
"delete_theme": "Delete theme",
"edit_theme": "Edit theme",
"delete_all_themes": "Delete all themes",
"delete_all_themes_description": "This will delete all your custom themes",
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel"
},
"notifications": {
"download_complete": "Download complete",

View File

@ -78,6 +78,11 @@ import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";
import "./themes/delete-all-custom-themes";
import "./themes/update-custom-theme";
import "./themes/open-editor-window";
import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/css-injector";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const injectCSS = async (
_event: Electron.IpcMainInvokeEvent,
cssString: string
) => {
WindowManager.mainWindow?.webContents.send("css-injected", cssString);
return;
};
registerEvent("injectCSS", injectCSS);

View File

@ -0,0 +1,10 @@
import { registerEvent } from "../register-event";
import { themes } from "@main/level/sublevels/themes";
import { Theme } from "@types";
const getActiveCustomTheme = async () => {
const allThemes = await themes.values().all();
return allThemes.find((theme: Theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View File

@ -0,0 +1,8 @@
import { themes } from "@main/level/sublevels/themes";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (_event: Electron.IpcMainInvokeEvent, themeId: string) => {
return await themes.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View File

@ -0,0 +1,8 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const openEditorWindow = async (_event: Electron.IpcMainInvokeEvent, themeId: string) => {
WindowManager.openEditorWindow(themeId);
};
registerEvent("openEditorWindow", openEditorWindow);

View File

@ -0,0 +1,13 @@
import { themes } from "@main/level/sublevels/themes";
import { registerEvent } from "../register-event";
import { Theme } from "@types";
const updateCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
theme: Theme
) => {
await themes.put(themeId, theme);
};
registerEvent("updateCustomTheme", updateCustomTheme);

View File

@ -194,6 +194,58 @@ export class WindowManager {
}
}
public static openEditorWindow(themeId: string) {
if (this.mainWindow) {
const editorWindow = new BrowserWindow({
width: 600,
height: 720,
minWidth: 600,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
if (!app.isPackaged) {
editorWindow.webContents.openDevTools();
} else {
this.mainWindow?.webContents.openDevTools();
}
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "editor",
}
);
}
editorWindow.once("ready-to-show", () => {
editorWindow.show();
});
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);

View File

@ -342,4 +342,19 @@ contextBridge.exposeInMainWorld("electron", {
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, theme: Theme) =>
ipcRenderer.invoke("updateCustomTheme", themeId, theme),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
/* Editor */
openEditorWindow: (themeId: string) => ipcRenderer.invoke("openEditorWindow", themeId),
injectCSS: (cssString: string) =>
ipcRenderer.invoke("injectCSS", cssString),
onCssInjected: (cb: (cssString: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, cssString: string) => cb(cssString);
ipcRenderer.on("css-injected", listener);
return () => ipcRenderer.removeListener("css-injected", listener);
},
});

View File

@ -28,7 +28,9 @@ import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss } from "./helpers";
import "./app.scss";
import { Theme } from "@types";
export interface AppProps {
children: React.ReactNode;
@ -233,6 +235,29 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme: Theme = await window.electron.getActiveCustomTheme();
if (activeTheme.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
if (cssString) {
injectCustomCss(cssString);
}
});
return () => {
unsubscribe();
};
}, []);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View File

@ -275,6 +275,14 @@ declare global {
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, theme: Theme) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;
injectCSS: (cssString: string) => Promise<void>;
onCssInjected: (cb: (cssString: string) => void) => () => Electron.IpcRenderer;
}
interface Window {

View File

@ -53,3 +53,29 @@ export const buildGameAchievementPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const injectCustomCss = (css: string) => {
try {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
const style = document.createElement("style");
style.id = "custom-css";
style.type = "text/css";
style.textContent = `
${css}
`;
document.head.appendChild(style);
} catch (error) {
console.error("failed to inject custom css:", error);
}
};
export const removeCustomCss = () => {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
};

View File

@ -33,6 +33,7 @@ const Profile = React.lazy(() => import("./pages/profile/profile"));
const Achievements = React.lazy(
() => import("./pages/achievements/achievements")
);
const Editor = React.lazy(() => import("./pages/editor/editor"));
import * as Sentry from "@sentry/react";
@ -104,6 +105,11 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
element={<SuspenseWrapper Component={Achievements} />}
/>
</Route>
<Route
path="/editor"
element={<SuspenseWrapper Component={Editor} />}
/>
</Routes>
</HashRouter>
</Provider>

View File

@ -0,0 +1,74 @@
@use "../../scss/globals.scss" as globals;
.editor {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&__header {
height: 35px;
display: flex;
align-items: center;
padding: 10px;
background-color: globals.$dark-background-color;
font-size: 8px;
z-index: 50;
-webkit-app-region: drag;
gap: 8px;
h1 {
margin: 0;
line-height: 1;
}
&__status {
display: flex;
width: 9px;
height: 9px;
background-color: globals.$muted-color;
border-radius: 50%;
margin-top: 3px;
}
}
&__footer {
background-color: globals.$dark-background-color;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
&-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
&__tabs {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 8px;
.active {
background-color: darken(globals.$dark-background-color, 2%);
}
}
}
}
&__info {
padding: 16px;
p {
font-size: 16px;
font-weight: 600;
color: globals.$muted-color;
margin-bottom: 8px;
}
}
}

View File

@ -0,0 +1,131 @@
import { useEffect, useState } from 'react';
import "./editor.scss";
import Editor from '@monaco-editor/react';
import { Theme } from '@types';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@renderer/components';
import { CheckIcon, CodeIcon, ProjectRoadmapIcon } from '@primer/octicons-react';
import { useTranslation } from 'react-i18next';
const EditorPage = () => {
const [searchParams] = useSearchParams();
const [theme, setTheme] = useState<Theme | null>(null);
const [code, setCode] = useState('');
const [activeTab, setActiveTab] = useState('code');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const themeId = searchParams.get('themeId');
const { t } = useTranslation('settings');
const handleTabChange = (tab: string) => {
setActiveTab(tab);
};
useEffect(() => {
if (themeId) {
window.electron.getCustomThemeById(themeId).then(loadedTheme => {
if (loadedTheme) {
setTheme(loadedTheme);
setCode(loadedTheme.code);
}
});
}
}, [themeId]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [code, theme]);
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
setCode(value);
setHasUnsavedChanges(true);
}
};
const handleSave = async () => {
if (theme) {
const updatedTheme = {
...theme,
code: code,
updatedAt: new Date()
};
await window.electron.updateCustomTheme(theme.id, updatedTheme);
setHasUnsavedChanges(false);
if (theme.isActive) {
window.electron.injectCSS(code);
}
}
};
return (
<div className="editor">
<div className="editor__header">
<h1>{theme?.name}</h1>
{hasUnsavedChanges && (
<div className="editor__header__status">
</div>
)}
</div>
{activeTab === 'code' && (
<Editor
theme="vs-dark"
defaultLanguage="css"
value={code}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
}}
/>
)}
{activeTab === 'info' && (
<div className="editor__info">
entao mano eu ate fiz isso aqui mas tava feio dms ai deu vergonha e removi kkkk
</div>
)}
<div className="editor__footer">
<div className="editor__footer-actions">
<div className="editor__footer-actions__tabs">
<Button onClick={() => handleTabChange('code')} theme='dark' className={activeTab === 'code' ? 'active' : ''}>
<CodeIcon />
{t('editor_tab_code')}
</Button>
<Button onClick={() => handleTabChange('info')} theme='dark' className={activeTab === 'info' ? 'active' : ''}>
<ProjectRoadmapIcon />
{t('editor_tab_info')}
</Button>
</div>
<Button onClick={handleSave}>
<CheckIcon />
{t('editor_tab_save')}
</Button>
</div>
</div>
</div>
);
};
export default EditorPage;

View File

@ -11,7 +11,7 @@ interface ThemeActionsProps {
}
export const ThemeActions = ({ onListUpdated }: ThemeActionsProps) => {
const { t } = useTranslation();
const { t } = useTranslation('settings');
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
const [deleteAllThemesModalVisible, setDeleteAllThemesModalVisible] =

View File

@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
import "./theme-card.scss";
import { useState } from "react";
import { DeleteThemeModal } from "../modals/delete-theme-modal";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
interface ThemeCardProps {
theme: Theme;
@ -13,11 +14,53 @@ interface ThemeCardProps {
}
export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
const { t } = useTranslation();
const { t } = useTranslation('settings');
const navigate = useNavigate();
const [deleteThemeModalVisible, setDeleteThemeModalVisible] = useState(false);
const handleSetTheme = async () => {
try {
const currentTheme = await window.electron.getCustomThemeById(theme.id);
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
await window.electron.updateCustomTheme(activeTheme.id, {
...activeTheme,
isActive: false
});
}
injectCustomCss(currentTheme.code);
await window.electron.updateCustomTheme(currentTheme.id, {
...currentTheme,
isActive: true
});
onListUpdated();
} catch (error) {
console.error(error);
}
};
const handleUnsetTheme = async () => {
try {
removeCustomCss();
await window.electron.updateCustomTheme(theme.id, {
...theme,
isActive: false
});
onListUpdated();
} catch (error) {
console.error(error);
}
};
return (
<>
<DeleteThemeModal
@ -25,6 +68,7 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
onClose={() => setDeleteThemeModalVisible(false)}
onThemeDeleted={onListUpdated}
themeId={theme.id}
themeName={theme.name}
/>
<div
@ -48,7 +92,7 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
</div>
</div>
{theme.author && theme.author && (
{theme.authorName && (
<p className="theme-card__author">
{t("by")}
@ -56,7 +100,7 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
className="theme-card__author__name"
onClick={() => navigate(`/profile/${theme.author}`)}
>
{theme.author}
{theme.authorName}
</span>
</p>
)}
@ -64,14 +108,22 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
<div className="theme-card__actions">
<div className="theme-card__actions__left">
{theme.isActive ? (
<Button theme="dark">{t("unset_theme ")}</Button>
<Button onClick={handleUnsetTheme} theme="dark">
{t("unset_theme")}
</Button>
) : (
<Button theme="outline">{t("set_theme")}</Button>
<Button onClick={handleSetTheme} theme="outline">
{t("set_theme")}
</Button>
)}
</div>
<div className="theme-card__actions__right">
<Button title={t("edit_theme")} theme="outline">
<Button
onClick={() => window.electron.openEditorWindow(theme.id)}
title={t("edit_theme")}
theme="outline"
>
<PencilIcon />
</Button>

View File

@ -4,6 +4,8 @@ import { Button } from "@renderer/components/button/button";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import "./modals.scss";
import { useUserDetails } from "@renderer/hooks";
import { Theme } from "@types";
interface AddThemeModalProps {
visible: boolean;
@ -17,6 +19,7 @@ export const AddThemeModal = ({
onThemeAdded,
}: AddThemeModalProps) => {
const { t } = useTranslation("settings");
const { userDetails } = useUserDetails();
const [name, setName] = useState("");
const [error, setError] = useState("");
@ -32,15 +35,20 @@ export const AddThemeModal = ({
return;
}
const theme = {
const theme: Theme = {
id: crypto.randomUUID(),
name,
isActive: false,
author: userDetails?.id || undefined,
authorName: userDetails?.username || undefined,
colors: {
accent: "#c0c1c7",
background: "#1c1c1c",
surface: "#151515",
},
code: "",
createdAt: new Date(),
updatedAt: new Date(),
};
await window.electron.addCustomTheme(theme);
@ -53,8 +61,8 @@ export const AddThemeModal = ({
return (
<Modal
visible={visible}
title={t("add_theme")}
description={t("add_theme_description")}
title={t("add_theme_modal_title")}
description={t("add_theme_modal_description")}
onClose={onClose}
>
<div className="add-theme-modal__container">

View File

@ -8,6 +8,7 @@ interface DeleteThemeModalProps {
onClose: () => void;
themeId: string;
onThemeDeleted: () => void;
themeName: string;
}
export const DeleteThemeModal = ({
@ -15,6 +16,7 @@ export const DeleteThemeModal = ({
onClose,
themeId,
onThemeDeleted,
themeName,
}: DeleteThemeModalProps) => {
const { t } = useTranslation("settings");
@ -27,7 +29,7 @@ export const DeleteThemeModal = ({
<Modal
visible={visible}
title={t("delete_theme")}
description={t("delete_theme_description")}
description={t("delete_theme_description", { theme: themeName })}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">

View File

@ -25,13 +25,15 @@ export const SettingsAppearance = () => {
{!themes.length ? (
<ThemePlaceholder onListUpdated={loadThemes} />
) : (
themes.map((theme) => (
<ThemeCard
key={theme.id}
theme={theme}
onListUpdated={loadThemes}
/>
))
[...themes]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.map((theme) => (
<ThemeCard
key={theme.id}
theme={theme}
onListUpdated={loadThemes}
/>
))
)}
</div>
</div>

View File

@ -15,7 +15,8 @@ export interface Theme {
optional2?: HexColorType;
};
description?: string;
author: number;
author: string | undefined;
authorName: string | undefined;
isActive: boolean;
code: string;
createdAt: Date;

View File

@ -1917,6 +1917,20 @@
lodash "^4.17.15"
tmp-promise "^3.0.2"
"@monaco-editor/loader@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558"
integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==
dependencies:
state-local "^1.0.6"
"@monaco-editor/react@^4.6.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119"
integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==
dependencies:
"@monaco-editor/loader" "^1.4.0"
"@napi-rs/nice-android-arm-eabi@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
@ -8719,6 +8733,11 @@ stat-mode@^1.0.0:
resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==
state-local@^1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"