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

@@ -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>