mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	Merge pull request #1409 from hydralauncher/feature/custom-themes
Feature/custom themes
This commit is contained in:
		
						commit
						e0dc87a55e
					
				
					 68 changed files with 1856 additions and 194 deletions
				
			
		| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
  "name": "hydralauncher",
 | 
					  "name": "hydralauncher",
 | 
				
			||||||
  "version": "3.1.5",
 | 
					  "version": "3.2.0",
 | 
				
			||||||
  "description": "Hydra",
 | 
					  "description": "Hydra",
 | 
				
			||||||
  "main": "./out/main/index.js",
 | 
					  "main": "./out/main/index.js",
 | 
				
			||||||
  "author": "Los Broxas",
 | 
					  "author": "Los Broxas",
 | 
				
			||||||
| 
						 | 
					@ -36,6 +36,7 @@
 | 
				
			||||||
    "@electron-toolkit/utils": "^3.0.0",
 | 
					    "@electron-toolkit/utils": "^3.0.0",
 | 
				
			||||||
    "@fontsource/noto-sans": "^5.1.0",
 | 
					    "@fontsource/noto-sans": "^5.1.0",
 | 
				
			||||||
    "@hookform/resolvers": "^3.9.1",
 | 
					    "@hookform/resolvers": "^3.9.1",
 | 
				
			||||||
 | 
					    "@monaco-editor/react": "^4.6.0",
 | 
				
			||||||
    "@primer/octicons-react": "^19.9.0",
 | 
					    "@primer/octicons-react": "^19.9.0",
 | 
				
			||||||
    "@radix-ui/react-dropdown-menu": "^2.1.2",
 | 
					    "@radix-ui/react-dropdown-menu": "^2.1.2",
 | 
				
			||||||
    "@reduxjs/toolkit": "^2.2.3",
 | 
					    "@reduxjs/toolkit": "^2.2.3",
 | 
				
			||||||
| 
						 | 
					@ -59,6 +60,7 @@
 | 
				
			||||||
    "i18next-browser-languagedetector": "^7.2.1",
 | 
					    "i18next-browser-languagedetector": "^7.2.1",
 | 
				
			||||||
    "jsdom": "^24.0.0",
 | 
					    "jsdom": "^24.0.0",
 | 
				
			||||||
    "jsonwebtoken": "^9.0.2",
 | 
					    "jsonwebtoken": "^9.0.2",
 | 
				
			||||||
 | 
					    "kill-port": "^2.0.1",
 | 
				
			||||||
    "knex": "^3.1.0",
 | 
					    "knex": "^3.1.0",
 | 
				
			||||||
    "lodash-es": "^4.17.21",
 | 
					    "lodash-es": "^4.17.21",
 | 
				
			||||||
    "parse-torrent": "^11.0.17",
 | 
					    "parse-torrent": "^11.0.17",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -189,9 +189,10 @@
 | 
				
			||||||
    "download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
 | 
					    "download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
 | 
				
			||||||
    "download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
 | 
					    "download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
 | 
				
			||||||
    "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
 | 
					    "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
 | 
				
			||||||
    "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available."
 | 
					    "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.",
 | 
				
			||||||
 | 
					    "game_removed_from_favorites": "Game removed from favorites",
 | 
				
			||||||
 | 
					    "game_added_to_favorites": "Game added to favorites"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					 | 
				
			||||||
  "activation": {
 | 
					  "activation": {
 | 
				
			||||||
    "title": "Activate Hydra",
 | 
					    "title": "Activate Hydra",
 | 
				
			||||||
    "installation_id": "Installation ID:",
 | 
					    "installation_id": "Installation ID:",
 | 
				
			||||||
| 
						 | 
					@ -303,10 +304,35 @@
 | 
				
			||||||
    "subscription_renew_cancelled": "Automatic renewal is disabled",
 | 
					    "subscription_renew_cancelled": "Automatic renewal is disabled",
 | 
				
			||||||
    "subscription_renews_on": "Your subscription renews on {{date}}",
 | 
					    "subscription_renews_on": "Your subscription renews on {{date}}",
 | 
				
			||||||
    "bill_sent_until": "Your next bill will be sent until this day",
 | 
					    "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.",
 | 
				
			||||||
 | 
					    "editor_tab_code": "Code",
 | 
				
			||||||
 | 
					    "editor_tab_info": "Info",
 | 
				
			||||||
 | 
					    "editor_tab_save": "Save",
 | 
				
			||||||
 | 
					    "web_store": "Web store",
 | 
				
			||||||
 | 
					    "clear_themes": "Clear",
 | 
				
			||||||
 | 
					    "create_theme": "Create",
 | 
				
			||||||
 | 
					    "create_theme_modal_title": "Create custom theme",
 | 
				
			||||||
 | 
					    "create_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",
 | 
				
			||||||
 | 
					    "appearance": "Appearance",
 | 
				
			||||||
    "enable_torbox": "Enable Torbox",
 | 
					    "enable_torbox": "Enable Torbox",
 | 
				
			||||||
    "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
 | 
					    "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
 | 
				
			||||||
    "torbox_account_linked": "TorBox account linked",
 | 
					    "torbox_account_linked": "TorBox account linked",
 | 
				
			||||||
    "real_debrid_account_linked": "Real-Debrid account linked"
 | 
					    "real_debrid_account_linked": "Real-Debrid account linked",
 | 
				
			||||||
 | 
					    "name_min_length": "Theme name must be at least 3 characters long",
 | 
				
			||||||
 | 
					    "import_theme": "Import theme",
 | 
				
			||||||
 | 
					    "import_theme_description": "You will import {{theme}} from the theme store",
 | 
				
			||||||
 | 
					    "error_importing_theme": "Error importing theme",
 | 
				
			||||||
 | 
					    "theme_imported": "Theme imported successfully"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "notifications": {
 | 
					  "notifications": {
 | 
				
			||||||
    "download_complete": "Download complete",
 | 
					    "download_complete": "Download complete",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -179,9 +179,10 @@
 | 
				
			||||||
    "download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
 | 
					    "download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
 | 
				
			||||||
    "download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
 | 
					    "download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
 | 
				
			||||||
    "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
 | 
					    "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
 | 
				
			||||||
    "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível."
 | 
					    "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
 | 
				
			||||||
 | 
					    "game_removed_from_favorites": "Jogo removido dos favoritos",
 | 
				
			||||||
 | 
					    "game_added_to_favorites": "Jogo adicionado aos favoritos"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					 | 
				
			||||||
  "activation": {
 | 
					  "activation": {
 | 
				
			||||||
    "title": "Ativação",
 | 
					    "title": "Ativação",
 | 
				
			||||||
    "installation_id": "ID da instalação:",
 | 
					    "installation_id": "ID da instalação:",
 | 
				
			||||||
| 
						 | 
					@ -293,10 +294,33 @@
 | 
				
			||||||
    "subscription_renew_cancelled": "A renovação automática está desativada",
 | 
					    "subscription_renew_cancelled": "A renovação automática está desativada",
 | 
				
			||||||
    "subscription_renews_on": "Sua assinatura renova dia {{date}}",
 | 
					    "subscription_renews_on": "Sua assinatura renova dia {{date}}",
 | 
				
			||||||
    "bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
 | 
					    "bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
 | 
				
			||||||
 | 
					    "no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
 | 
				
			||||||
 | 
					    "editor_tab_save": "Salvar",
 | 
				
			||||||
 | 
					    "web_store": "Loja de temas",
 | 
				
			||||||
 | 
					    "clear_themes": "Limpar",
 | 
				
			||||||
 | 
					    "create_theme": "Criar",
 | 
				
			||||||
 | 
					    "create_theme_modal_title": "Criar tema customizado",
 | 
				
			||||||
 | 
					    "create_theme_modal_description": "Criar novo tema para customizar a aparência do Hydra",
 | 
				
			||||||
 | 
					    "theme_name": "Nome",
 | 
				
			||||||
 | 
					    "insert_theme_name": "Insira o nome do tema",
 | 
				
			||||||
 | 
					    "set_theme": "Habilitar tema",
 | 
				
			||||||
 | 
					    "unset_theme": "Desabilitar tema",
 | 
				
			||||||
 | 
					    "delete_theme": "Deletar tema",
 | 
				
			||||||
 | 
					    "edit_theme": "Editar tema",
 | 
				
			||||||
 | 
					    "delete_all_themes": "Deletar todos os temas",
 | 
				
			||||||
 | 
					    "delete_all_themes_description": "Isso irá deletar todos os seus temas",
 | 
				
			||||||
 | 
					    "delete_theme_description": "Isso irá deletar o tema {{theme}}",
 | 
				
			||||||
 | 
					    "cancel": "Cancelar",
 | 
				
			||||||
 | 
					    "appearance": "Aparência",
 | 
				
			||||||
    "enable_torbox": "Habilitar Torbox",
 | 
					    "enable_torbox": "Habilitar Torbox",
 | 
				
			||||||
    "torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
 | 
					    "torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
 | 
				
			||||||
    "torbox_account_linked": "Conta do TorBox vinculada",
 | 
					    "torbox_account_linked": "Conta do TorBox vinculada",
 | 
				
			||||||
    "real_debrid_account_linked": "Conta Real-Debrid associada"
 | 
					    "real_debrid_account_linked": "Conta Real-Debrid associada",
 | 
				
			||||||
 | 
					    "name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
 | 
				
			||||||
 | 
					    "import_theme": "Importar tema",
 | 
				
			||||||
 | 
					    "import_theme_description": "Você irá importar {{theme}} da loja de temas",
 | 
				
			||||||
 | 
					    "error_importing_theme": "Erro ao importar tema",
 | 
				
			||||||
 | 
					    "theme_imported": "Tema importado com sucesso"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "notifications": {
 | 
					  "notifications": {
 | 
				
			||||||
    "download_complete": "Download concluído",
 | 
					    "download_complete": "Download concluído",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
 | 
				
			||||||
import { registerEvent } from "../register-event";
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
import { db, levelKeys } from "@main/level";
 | 
					import { db, levelKeys } from "@main/level";
 | 
				
			||||||
import type { Auth } from "@types";
 | 
					import type { Auth } from "@types";
 | 
				
			||||||
import { Crypto } from "@main/services";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
					const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
				
			||||||
  const auth = await db.get<string, Auth>(levelKeys.auth, {
 | 
					  const auth = await db.get<string, Auth>(levelKeys.auth, {
 | 
				
			||||||
| 
						 | 
					@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!auth) return null;
 | 
					  if (!auth) return null;
 | 
				
			||||||
  const payload = jwt.decode(
 | 
					  const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
 | 
				
			||||||
    Crypto.decrypt(auth.accessToken)
 | 
					 | 
				
			||||||
  ) as jwt.JwtPayload;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!payload) return null;
 | 
					  if (!payload) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -77,6 +77,16 @@ import "./cloud-save/upload-save-game";
 | 
				
			||||||
import "./cloud-save/delete-game-artifact";
 | 
					import "./cloud-save/delete-game-artifact";
 | 
				
			||||||
import "./cloud-save/select-game-backup-path";
 | 
					import "./cloud-save/select-game-backup-path";
 | 
				
			||||||
import "./notifications/publish-new-repacks-notification";
 | 
					import "./notifications/publish-new-repacks-notification";
 | 
				
			||||||
 | 
					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/close-editor-window";
 | 
				
			||||||
 | 
					import "./themes/toggle-custom-theme";
 | 
				
			||||||
import { isPortableVersion } from "@main/helpers";
 | 
					import { isPortableVersion } from "@main/helpers";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ipcMain.handle("ping", () => "pong");
 | 
					ipcMain.handle("ping", () => "pong");
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
import { shell } from "electron";
 | 
					import { shell } from "electron";
 | 
				
			||||||
import { registerEvent } from "../register-event";
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
import { Crypto, HydraApi } from "@main/services";
 | 
					import { HydraApi } from "@main/services";
 | 
				
			||||||
import { db, levelKeys } from "@main/level";
 | 
					import { db, levelKeys } from "@main/level";
 | 
				
			||||||
import type { Auth } from "@types";
 | 
					import type { Auth } from "@types";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const paymentToken = await HydraApi.post("/auth/payment", {
 | 
					  const paymentToken = await HydraApi.post("/auth/payment", {
 | 
				
			||||||
    refreshToken: Crypto.decrypt(auth.refreshToken),
 | 
					    refreshToken: auth.refreshToken,
 | 
				
			||||||
  }).then((response) => response.accessToken);
 | 
					  }).then((response) => response.accessToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const params = new URLSearchParams({
 | 
					  const params = new URLSearchParams({
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										12
									
								
								src/main/events/themes/add-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/main/events/themes/add-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,12 @@
 | 
				
			||||||
 | 
					import { Theme } from "@types";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const addCustomTheme = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  theme: Theme
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  await themesSublevel.put(theme.id, theme);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("addCustomTheme", addCustomTheme);
 | 
				
			||||||
							
								
								
									
										11
									
								
								src/main/events/themes/close-editor-window.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/themes/close-editor-window.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					import { WindowManager } from "@main/services";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const closeEditorWindow = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId?: string
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  WindowManager.closeEditorWindow(themeId);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("closeEditorWindow", closeEditorWindow);
 | 
				
			||||||
							
								
								
									
										8
									
								
								src/main/events/themes/delete-all-custom-themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/main/events/themes/delete-all-custom-themes.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
				
			||||||
 | 
					  await themesSublevel.clear();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);
 | 
				
			||||||
							
								
								
									
										11
									
								
								src/main/events/themes/delete-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/themes/delete-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const deleteCustomTheme = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId: string
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  await themesSublevel.del(themeId);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("deleteCustomTheme", deleteCustomTheme);
 | 
				
			||||||
							
								
								
									
										9
									
								
								src/main/events/themes/get-active-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/main/events/themes/get-active-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,9 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getActiveCustomTheme = async () => {
 | 
				
			||||||
 | 
					  const allThemes = await themesSublevel.values().all();
 | 
				
			||||||
 | 
					  return allThemes.find((theme) => theme.isActive);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("getActiveCustomTheme", getActiveCustomTheme);
 | 
				
			||||||
							
								
								
									
										8
									
								
								src/main/events/themes/get-all-custom-themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/main/events/themes/get-all-custom-themes.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
				
			||||||
 | 
					  return themesSublevel.values().all();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("getAllCustomThemes", getAllCustomThemes);
 | 
				
			||||||
							
								
								
									
										11
									
								
								src/main/events/themes/get-custom-theme-by-id.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/themes/get-custom-theme-by-id.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getCustomThemeById = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId: string
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  return themesSublevel.get(themeId);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("getCustomThemeById", getCustomThemeById);
 | 
				
			||||||
							
								
								
									
										11
									
								
								src/main/events/themes/open-editor-window.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/themes/open-editor-window.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					import { WindowManager } from "@main/services";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const openEditorWindow = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId: string
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  WindowManager.openEditorWindow(themeId);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("openEditorWindow", openEditorWindow);
 | 
				
			||||||
							
								
								
									
										22
									
								
								src/main/events/themes/toggle-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/main/events/themes/toggle-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,22 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const toggleCustomTheme = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId: string,
 | 
				
			||||||
 | 
					  isActive: boolean
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const theme = await themesSublevel.get(themeId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!theme) {
 | 
				
			||||||
 | 
					    throw new Error("Theme not found");
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await themesSublevel.put(themeId, {
 | 
				
			||||||
 | 
					    ...theme,
 | 
				
			||||||
 | 
					    isActive,
 | 
				
			||||||
 | 
					    updatedAt: new Date(),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("toggleCustomTheme", toggleCustomTheme);
 | 
				
			||||||
							
								
								
									
										27
									
								
								src/main/events/themes/update-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/main/events/themes/update-custom-theme.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,27 @@
 | 
				
			||||||
 | 
					import { themesSublevel } from "@main/level";
 | 
				
			||||||
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
 | 
					import { WindowManager } from "@main/services";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const updateCustomTheme = async (
 | 
				
			||||||
 | 
					  _event: Electron.IpcMainInvokeEvent,
 | 
				
			||||||
 | 
					  themeId: string,
 | 
				
			||||||
 | 
					  code: string
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
 | 
					  const theme = await themesSublevel.get(themeId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!theme) {
 | 
				
			||||||
 | 
					    throw new Error("Theme not found");
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await themesSublevel.put(themeId, {
 | 
				
			||||||
 | 
					    ...theme,
 | 
				
			||||||
 | 
					    code,
 | 
				
			||||||
 | 
					    updatedAt: new Date(),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (theme.isActive) {
 | 
				
			||||||
 | 
					    WindowManager.mainWindow?.webContents.send("css-injected", code);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					registerEvent("updateCustomTheme", updateCustomTheme);
 | 
				
			||||||
| 
						 | 
					@ -1,27 +1,10 @@
 | 
				
			||||||
import { registerEvent } from "../register-event";
 | 
					import { registerEvent } from "../register-event";
 | 
				
			||||||
import { db, levelKeys } from "@main/level";
 | 
					import { db, levelKeys } from "@main/level";
 | 
				
			||||||
import { Crypto } from "@main/services";
 | 
					 | 
				
			||||||
import type { UserPreferences } from "@types";
 | 
					import type { UserPreferences } from "@types";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getUserPreferences = async () =>
 | 
					const getUserPreferences = async () =>
 | 
				
			||||||
  db
 | 
					  db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
 | 
				
			||||||
    .get<string, UserPreferences | null>(levelKeys.userPreferences, {
 | 
					    valueEncoding: "json",
 | 
				
			||||||
      valueEncoding: "json",
 | 
					  });
 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    .then((userPreferences) => {
 | 
					 | 
				
			||||||
      if (userPreferences?.realDebridApiToken) {
 | 
					 | 
				
			||||||
        userPreferences.realDebridApiToken = Crypto.decrypt(
 | 
					 | 
				
			||||||
          userPreferences.realDebridApiToken
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (userPreferences?.torBoxApiToken) {
 | 
					 | 
				
			||||||
        userPreferences.torBoxApiToken = Crypto.decrypt(
 | 
					 | 
				
			||||||
          userPreferences.torBoxApiToken
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return userPreferences;
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
registerEvent("getUserPreferences", getUserPreferences);
 | 
					registerEvent("getUserPreferences", getUserPreferences);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,7 +3,6 @@ import { registerEvent } from "../register-event";
 | 
				
			||||||
import type { UserPreferences } from "@types";
 | 
					import type { UserPreferences } from "@types";
 | 
				
			||||||
import i18next from "i18next";
 | 
					import i18next from "i18next";
 | 
				
			||||||
import { db, levelKeys } from "@main/level";
 | 
					import { db, levelKeys } from "@main/level";
 | 
				
			||||||
import { Crypto } from "@main/services";
 | 
					 | 
				
			||||||
import { patchUserProfile } from "../profile/update-profile";
 | 
					import { patchUserProfile } from "../profile/update-profile";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const updateUserPreferences = async (
 | 
					const updateUserPreferences = async (
 | 
				
			||||||
| 
						 | 
					@ -24,16 +23,6 @@ const updateUserPreferences = async (
 | 
				
			||||||
    patchUserProfile({ language: preferences.language }).catch(() => {});
 | 
					    patchUserProfile({ language: preferences.language }).catch(() => {});
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (preferences.realDebridApiToken) {
 | 
					 | 
				
			||||||
    preferences.realDebridApiToken = Crypto.encrypt(
 | 
					 | 
				
			||||||
      preferences.realDebridApiToken
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (preferences.torBoxApiToken) {
 | 
					 | 
				
			||||||
    preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!preferences.downloadsPath) {
 | 
					  if (!preferences.downloadsPath) {
 | 
				
			||||||
    preferences.downloadsPath = null;
 | 
					    preferences.downloadsPath = null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,7 @@ import updater from "electron-updater";
 | 
				
			||||||
import i18n from "i18next";
 | 
					import i18n from "i18next";
 | 
				
			||||||
import path from "node:path";
 | 
					import path from "node:path";
 | 
				
			||||||
import url from "node:url";
 | 
					import url from "node:url";
 | 
				
			||||||
 | 
					import kill from "kill-port";
 | 
				
			||||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
 | 
					import { electronApp, optimizer } from "@electron-toolkit/utils";
 | 
				
			||||||
import { logger, WindowManager } from "@main/services";
 | 
					import { logger, WindowManager } from "@main/services";
 | 
				
			||||||
import resources from "@locales";
 | 
					import resources from "@locales";
 | 
				
			||||||
| 
						 | 
					@ -58,7 +59,7 @@ app.whenReady().then(async () => {
 | 
				
			||||||
    return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
 | 
					    return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await loadState();
 | 
					  await kill(PythonRPC.RPC_PORT).finally(() => loadState());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const language = await db.get<string, string>(levelKeys.language, {
 | 
					  const language = await db.get<string, string>(levelKeys.language, {
 | 
				
			||||||
    valueEncoding: "utf-8",
 | 
					    valueEncoding: "utf-8",
 | 
				
			||||||
| 
						 | 
					@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (url.host === "install-source") {
 | 
					    if (url.host === "install-source") {
 | 
				
			||||||
      WindowManager.redirect(`settings${url.search}`);
 | 
					      WindowManager.redirect(`settings${url.search}`);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (url.host === "profile") {
 | 
				
			||||||
 | 
					      const userId = url.searchParams.get("userId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (userId) {
 | 
				
			||||||
 | 
					        WindowManager.redirect(`profile/${userId}`);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (url.host === "install-theme") {
 | 
				
			||||||
 | 
					      const themeName = url.searchParams.get("theme");
 | 
				
			||||||
 | 
					      const authorId = url.searchParams.get("authorId");
 | 
				
			||||||
 | 
					      const authorName = url.searchParams.get("authorName");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (themeName && authorId && authorName) {
 | 
				
			||||||
 | 
					        WindowManager.redirect(
 | 
				
			||||||
 | 
					          `settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}`
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    logger.error("Error handling deep link", uri, error);
 | 
					    logger.error("Error handling deep link", uri, error);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,3 +3,4 @@ export * from "./games";
 | 
				
			||||||
export * from "./game-shop-cache";
 | 
					export * from "./game-shop-cache";
 | 
				
			||||||
export * from "./game-achievements";
 | 
					export * from "./game-achievements";
 | 
				
			||||||
export * from "./keys";
 | 
					export * from "./keys";
 | 
				
			||||||
 | 
					export * from "./themes";
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,6 +5,7 @@ export const levelKeys = {
 | 
				
			||||||
  game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
 | 
					  game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
 | 
				
			||||||
  user: "user",
 | 
					  user: "user",
 | 
				
			||||||
  auth: "auth",
 | 
					  auth: "auth",
 | 
				
			||||||
 | 
					  themes: "themes",
 | 
				
			||||||
  gameShopCache: "gameShopCache",
 | 
					  gameShopCache: "gameShopCache",
 | 
				
			||||||
  gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
 | 
					  gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
 | 
				
			||||||
    `${shop}:${objectId}:${language}`,
 | 
					    `${shop}:${objectId}:${language}`,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										7
									
								
								src/main/level/sublevels/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/main/level/sublevels/themes.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					import type { Theme } from "@types";
 | 
				
			||||||
 | 
					import { db } from "../level";
 | 
				
			||||||
 | 
					import { levelKeys } from "./keys";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const themesSublevel = db.sublevel<string, Theme>(levelKeys.themes, {
 | 
				
			||||||
 | 
					  valueEncoding: "json",
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -1,10 +1,4 @@
 | 
				
			||||||
import {
 | 
					import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
 | 
				
			||||||
  Crypto,
 | 
					 | 
				
			||||||
  DownloadManager,
 | 
					 | 
				
			||||||
  logger,
 | 
					 | 
				
			||||||
  Ludusavi,
 | 
					 | 
				
			||||||
  startMainLoop,
 | 
					 | 
				
			||||||
} from "./services";
 | 
					 | 
				
			||||||
import { RealDebridClient } from "./services/download/real-debrid";
 | 
					import { RealDebridClient } from "./services/download/real-debrid";
 | 
				
			||||||
import { HydraApi } from "./services/hydra-api";
 | 
					import { HydraApi } from "./services/hydra-api";
 | 
				
			||||||
import { uploadGamesBatch } from "./services/library-sync";
 | 
					import { uploadGamesBatch } from "./services/library-sync";
 | 
				
			||||||
| 
						 | 
					@ -38,13 +32,11 @@ export const loadState = async () => {
 | 
				
			||||||
  Aria2.spawn();
 | 
					  Aria2.spawn();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (userPreferences?.realDebridApiToken) {
 | 
					  if (userPreferences?.realDebridApiToken) {
 | 
				
			||||||
    RealDebridClient.authorize(
 | 
					    RealDebridClient.authorize(userPreferences.realDebridApiToken);
 | 
				
			||||||
      Crypto.decrypt(userPreferences.realDebridApiToken)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (userPreferences?.torBoxApiToken) {
 | 
					  if (userPreferences?.torBoxApiToken) {
 | 
				
			||||||
    TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
 | 
					    TorBoxClient.authorize(userPreferences.torBoxApiToken);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Ludusavi.addManifestToLudusaviConfig();
 | 
					  Ludusavi.addManifestToLudusaviConfig();
 | 
				
			||||||
| 
						 | 
					@ -121,9 +113,7 @@ const migrateFromSqlite = async () => {
 | 
				
			||||||
          levelKeys.userPreferences,
 | 
					          levelKeys.userPreferences,
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            ...rest,
 | 
					            ...rest,
 | 
				
			||||||
            realDebridApiToken: realDebridApiToken
 | 
					            realDebridApiToken,
 | 
				
			||||||
              ? Crypto.encrypt(realDebridApiToken)
 | 
					 | 
				
			||||||
              : null,
 | 
					 | 
				
			||||||
            preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
 | 
					            preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
 | 
				
			||||||
            runAtStartup: rest.runAtStartup === 1,
 | 
					            runAtStartup: rest.runAtStartup === 1,
 | 
				
			||||||
            startMinimized: rest.startMinimized === 1,
 | 
					            startMinimized: rest.startMinimized === 1,
 | 
				
			||||||
| 
						 | 
					@ -189,8 +179,8 @@ const migrateFromSqlite = async () => {
 | 
				
			||||||
        await db.put<string, Auth>(
 | 
					        await db.put<string, Auth>(
 | 
				
			||||||
          levelKeys.auth,
 | 
					          levelKeys.auth,
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            accessToken: Crypto.encrypt(users[0].accessToken),
 | 
					            accessToken: users[0].accessToken,
 | 
				
			||||||
            refreshToken: Crypto.encrypt(users[0].refreshToken),
 | 
					            refreshToken: users[0].refreshToken,
 | 
				
			||||||
            tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
 | 
					            tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,28 +0,0 @@
 | 
				
			||||||
import { safeStorage } from "electron";
 | 
					 | 
				
			||||||
import { logger } from "./logger";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class Crypto {
 | 
					 | 
				
			||||||
  public static encrypt(str: string) {
 | 
					 | 
				
			||||||
    if (safeStorage.isEncryptionAvailable()) {
 | 
					 | 
				
			||||||
      return safeStorage.encryptString(str).toString("base64");
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      logger.warn(
 | 
					 | 
				
			||||||
        "Encrypt method returned raw string because encryption is not available"
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return str;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public static decrypt(b64: string) {
 | 
					 | 
				
			||||||
    if (safeStorage.isEncryptionAvailable()) {
 | 
					 | 
				
			||||||
      return safeStorage.decryptString(Buffer.from(b64, "base64"));
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      logger.warn(
 | 
					 | 
				
			||||||
        "Decrypt method returned raw string because encryption is not available"
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return b64;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -230,14 +230,17 @@ export class DownloadManager {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static async cancelDownload(downloadKey = this.downloadingGameId) {
 | 
					  static async cancelDownload(downloadKey = this.downloadingGameId) {
 | 
				
			||||||
    await PythonRPC.rpc.post("/action", {
 | 
					    await PythonRPC.rpc
 | 
				
			||||||
      action: "cancel",
 | 
					      .post("/action", {
 | 
				
			||||||
      game_id: downloadKey,
 | 
					        action: "cancel",
 | 
				
			||||||
    });
 | 
					        game_id: downloadKey,
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
    WindowManager.mainWindow?.setProgressBar(-1);
 | 
					      .catch((err) => {
 | 
				
			||||||
 | 
					        logger.error("Failed to cancel game download", err);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (downloadKey === this.downloadingGameId) {
 | 
					    if (downloadKey === this.downloadingGameId) {
 | 
				
			||||||
 | 
					      WindowManager.mainWindow?.setProgressBar(-1);
 | 
				
			||||||
      WindowManager.mainWindow?.webContents.send("on-download-progress", null);
 | 
					      WindowManager.mainWindow?.webContents.send("on-download-progress", null);
 | 
				
			||||||
      this.downloadingGameId = null;
 | 
					      this.downloadingGameId = null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
 | 
				
			||||||
import { db } from "@main/level";
 | 
					import { db } from "@main/level";
 | 
				
			||||||
import { levelKeys } from "@main/level/sublevels";
 | 
					import { levelKeys } from "@main/level/sublevels";
 | 
				
			||||||
import type { Auth, User } from "@types";
 | 
					import type { Auth, User } from "@types";
 | 
				
			||||||
import { Crypto } from "./crypto";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface HydraApiOptions {
 | 
					interface HydraApiOptions {
 | 
				
			||||||
  needsAuth?: boolean;
 | 
					  needsAuth?: boolean;
 | 
				
			||||||
| 
						 | 
					@ -32,8 +31,9 @@ export class HydraApi {
 | 
				
			||||||
  private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
 | 
					  private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
 | 
				
			||||||
  private static readonly ADD_LOG_INTERCEPTOR = true;
 | 
					  private static readonly ADD_LOG_INTERCEPTOR = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private static readonly secondsToMilliseconds = (seconds: number) =>
 | 
					  private static secondsToMilliseconds(seconds: number) {
 | 
				
			||||||
    seconds * 1000;
 | 
					    return seconds * 1000;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private static userAuth: HydraApiUserAuth = {
 | 
					  private static userAuth: HydraApiUserAuth = {
 | 
				
			||||||
    authToken: "",
 | 
					    authToken: "",
 | 
				
			||||||
| 
						 | 
					@ -81,8 +81,8 @@ export class HydraApi {
 | 
				
			||||||
    db.put<string, Auth>(
 | 
					    db.put<string, Auth>(
 | 
				
			||||||
      levelKeys.auth,
 | 
					      levelKeys.auth,
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        accessToken: Crypto.encrypt(accessToken),
 | 
					        accessToken,
 | 
				
			||||||
        refreshToken: Crypto.encrypt(refreshToken),
 | 
					        refreshToken,
 | 
				
			||||||
        tokenExpirationTimestamp,
 | 
					        tokenExpirationTimestamp,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      { valueEncoding: "json" }
 | 
					      { valueEncoding: "json" }
 | 
				
			||||||
| 
						 | 
					@ -204,12 +204,8 @@ export class HydraApi {
 | 
				
			||||||
    const user = result.at(1) as User | undefined;
 | 
					    const user = result.at(1) as User | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.userAuth = {
 | 
					    this.userAuth = {
 | 
				
			||||||
      authToken: userAuth?.accessToken
 | 
					      authToken: userAuth?.accessToken ?? "",
 | 
				
			||||||
        ? Crypto.decrypt(userAuth.accessToken)
 | 
					      refreshToken: userAuth?.refreshToken ?? "",
 | 
				
			||||||
        : "",
 | 
					 | 
				
			||||||
      refreshToken: userAuth?.refreshToken
 | 
					 | 
				
			||||||
        ? Crypto.decrypt(userAuth.refreshToken)
 | 
					 | 
				
			||||||
        : "",
 | 
					 | 
				
			||||||
      expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
 | 
					      expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
 | 
				
			||||||
      subscription: user?.subscription
 | 
					      subscription: user?.subscription
 | 
				
			||||||
        ? { expiresAt: user.subscription?.expiresAt }
 | 
					        ? { expiresAt: user.subscription?.expiresAt }
 | 
				
			||||||
| 
						 | 
					@ -258,7 +254,7 @@ export class HydraApi {
 | 
				
			||||||
          levelKeys.auth,
 | 
					          levelKeys.auth,
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            ...auth,
 | 
					            ...auth,
 | 
				
			||||||
            accessToken: Crypto.encrypt(accessToken),
 | 
					            accessToken,
 | 
				
			||||||
            tokenExpirationTimestamp,
 | 
					            tokenExpirationTimestamp,
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          { valueEncoding: "json" }
 | 
					          { valueEncoding: "json" }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,3 @@
 | 
				
			||||||
export * from "./crypto";
 | 
					 | 
				
			||||||
export * from "./logger";
 | 
					export * from "./logger";
 | 
				
			||||||
export * from "./steam";
 | 
					export * from "./steam";
 | 
				
			||||||
export * from "./steam-250";
 | 
					export * from "./steam-250";
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,8 @@ import { isStaging } from "@main/constants";
 | 
				
			||||||
export class WindowManager {
 | 
					export class WindowManager {
 | 
				
			||||||
  public static mainWindow: Electron.BrowserWindow | null = null;
 | 
					  public static mainWindow: Electron.BrowserWindow | null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private static loadMainWindowURL(hash = "") {
 | 
					  private static loadMainWindowURL(hash = "") {
 | 
				
			||||||
    // HMR for renderer base on electron-vite cli.
 | 
					    // HMR for renderer base on electron-vite cli.
 | 
				
			||||||
    // Load the remote URL for development or the local html file for production.
 | 
					    // Load the remote URL for development or the local html file for production.
 | 
				
			||||||
| 
						 | 
					@ -201,6 +203,87 @@ export class WindowManager {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public static openEditorWindow(themeId: string) {
 | 
				
			||||||
 | 
					    if (this.mainWindow) {
 | 
				
			||||||
 | 
					      const existingWindow = this.editorWindows.get(themeId);
 | 
				
			||||||
 | 
					      if (existingWindow) {
 | 
				
			||||||
 | 
					        if (existingWindow.isMinimized()) {
 | 
				
			||||||
 | 
					          existingWindow.restore();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        existingWindow.focus();
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      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,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.editorWindows.set(themeId, editorWindow);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      editorWindow.removeMenu();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
 | 
				
			||||||
 | 
					        editorWindow.loadURL(
 | 
				
			||||||
 | 
					          `${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
 | 
				
			||||||
 | 
					          hash: `theme-editor?themeId=${themeId}`,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      editorWindow.once("ready-to-show", () => {
 | 
				
			||||||
 | 
					        editorWindow.show();
 | 
				
			||||||
 | 
					        this.mainWindow?.webContents.openDevTools();
 | 
				
			||||||
 | 
					        if (isStaging) {
 | 
				
			||||||
 | 
					          editorWindow.webContents.openDevTools();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      editorWindow.webContents.on("before-input-event", (event, input) => {
 | 
				
			||||||
 | 
					        if (input.key === "F12") {
 | 
				
			||||||
 | 
					          event.preventDefault();
 | 
				
			||||||
 | 
					          this.mainWindow?.webContents.toggleDevTools();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      editorWindow.on("close", () => {
 | 
				
			||||||
 | 
					        this.mainWindow?.webContents.closeDevTools();
 | 
				
			||||||
 | 
					        this.editorWindows.delete(themeId);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public static closeEditorWindow(themeId?: string) {
 | 
				
			||||||
 | 
					    if (themeId) {
 | 
				
			||||||
 | 
					      const editorWindow = this.editorWindows.get(themeId);
 | 
				
			||||||
 | 
					      if (editorWindow) {
 | 
				
			||||||
 | 
					        editorWindow.close();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.editorWindows.forEach((editorWindow) => {
 | 
				
			||||||
 | 
					        editorWindow.close();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public static redirect(hash: string) {
 | 
					  public static redirect(hash: string) {
 | 
				
			||||||
    if (!this.mainWindow) this.createMainWindow();
 | 
					    if (!this.mainWindow) this.createMainWindow();
 | 
				
			||||||
    this.loadMainWindowURL(hash);
 | 
					    this.loadMainWindowURL(hash);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,6 +14,7 @@ import type {
 | 
				
			||||||
  CatalogueSearchPayload,
 | 
					  CatalogueSearchPayload,
 | 
				
			||||||
  SeedingStatus,
 | 
					  SeedingStatus,
 | 
				
			||||||
  GameAchievement,
 | 
					  GameAchievement,
 | 
				
			||||||
 | 
					  Theme,
 | 
				
			||||||
} from "@types";
 | 
					} from "@types";
 | 
				
			||||||
import type { AuthPage, CatalogueCategory } from "@shared";
 | 
					import type { AuthPage, CatalogueCategory } from "@shared";
 | 
				
			||||||
import type { AxiosProgressEvent } from "axios";
 | 
					import type { AxiosProgressEvent } from "axios";
 | 
				
			||||||
| 
						 | 
					@ -347,4 +348,30 @@ contextBridge.exposeInMainWorld("electron", {
 | 
				
			||||||
  /* Notifications */
 | 
					  /* Notifications */
 | 
				
			||||||
  publishNewRepacksNotification: (newRepacksCount: number) =>
 | 
					  publishNewRepacksNotification: (newRepacksCount: number) =>
 | 
				
			||||||
    ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
 | 
					    ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Themes */
 | 
				
			||||||
 | 
					  addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
 | 
				
			||||||
 | 
					  getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
 | 
				
			||||||
 | 
					  deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
 | 
				
			||||||
 | 
					  deleteCustomTheme: (themeId: string) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("deleteCustomTheme", themeId),
 | 
				
			||||||
 | 
					  updateCustomTheme: (themeId: string, code: string) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("updateCustomTheme", themeId, code),
 | 
				
			||||||
 | 
					  getCustomThemeById: (themeId: string) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("getCustomThemeById", themeId),
 | 
				
			||||||
 | 
					  getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
 | 
				
			||||||
 | 
					  toggleCustomTheme: (themeId: string, isActive: boolean) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Editor */
 | 
				
			||||||
 | 
					  openEditorWindow: (themeId: string) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("openEditorWindow", themeId),
 | 
				
			||||||
 | 
					  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);
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  closeEditorWindow: (themeId?: string) =>
 | 
				
			||||||
 | 
					    ipcRenderer.invoke("closeEditorWindow", themeId),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,6 +28,7 @@ import { downloadSourcesTable } from "./dexie";
 | 
				
			||||||
import { useSubscription } from "./hooks/use-subscription";
 | 
					import { useSubscription } from "./hooks/use-subscription";
 | 
				
			||||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
 | 
					import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { injectCustomCss } from "./helpers";
 | 
				
			||||||
import "./app.scss";
 | 
					import "./app.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface AppProps {
 | 
					export interface AppProps {
 | 
				
			||||||
| 
						 | 
					@ -233,6 +234,17 @@ export function App() {
 | 
				
			||||||
    downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
 | 
					    downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
 | 
				
			||||||
  }, [updateRepacks]);
 | 
					  }, [updateRepacks]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const loadAndApplyTheme = async () => {
 | 
				
			||||||
 | 
					      const activeTheme = await window.electron.getActiveCustomTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (activeTheme?.code) {
 | 
				
			||||||
 | 
					        injectCustomCss(activeTheme.code);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    loadAndApplyTheme();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const playAudio = useCallback(() => {
 | 
					  const playAudio = useCallback(() => {
 | 
				
			||||||
    const audio = new Audio(achievementSound);
 | 
					    const audio = new Audio(achievementSound);
 | 
				
			||||||
    audio.volume = 0.2;
 | 
					    audio.volume = 0.2;
 | 
				
			||||||
| 
						 | 
					@ -249,6 +261,16 @@ export function App() {
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }, [playAudio]);
 | 
					  }, [playAudio]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const unsubscribe = window.electron.onCssInjected((cssString) => {
 | 
				
			||||||
 | 
					      if (cssString) {
 | 
				
			||||||
 | 
					        injectCustomCss(cssString);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => unsubscribe();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleToastClose = useCallback(() => {
 | 
					  const handleToastClose = useCallback(() => {
 | 
				
			||||||
    dispatch(closeToast());
 | 
					    dispatch(closeToast());
 | 
				
			||||||
  }, [dispatch]);
 | 
					  }, [dispatch]);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -167,6 +167,10 @@ export function Sidebar() {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const favoriteGames = useMemo(() => {
 | 
				
			||||||
 | 
					    return sortedLibrary.filter((game) => game.favorite);
 | 
				
			||||||
 | 
					  }, [sortedLibrary]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <aside
 | 
					    <aside
 | 
				
			||||||
      ref={sidebarRef}
 | 
					      ref={sidebarRef}
 | 
				
			||||||
| 
						 | 
					@ -206,13 +210,12 @@ export function Sidebar() {
 | 
				
			||||||
            </ul>
 | 
					            </ul>
 | 
				
			||||||
          </section>
 | 
					          </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <section className="sidebar__section">
 | 
					          {favoriteGames.length > 0 && (
 | 
				
			||||||
            <small className="sidebar__section-title">{t("favorites")}</small>
 | 
					            <section className="sidebar__section">
 | 
				
			||||||
 | 
					              <small className="sidebar__section-title">{t("favorites")}</small>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <ul className="sidebar__menu">
 | 
					              <ul className="sidebar__menu">
 | 
				
			||||||
              {sortedLibrary
 | 
					                {favoriteGames.map((game) => (
 | 
				
			||||||
                .filter((game) => game.favorite)
 | 
					 | 
				
			||||||
                .map((game) => (
 | 
					 | 
				
			||||||
                  <SidebarGameItem
 | 
					                  <SidebarGameItem
 | 
				
			||||||
                    key={game.id}
 | 
					                    key={game.id}
 | 
				
			||||||
                    game={game}
 | 
					                    game={game}
 | 
				
			||||||
| 
						 | 
					@ -220,8 +223,9 @@ export function Sidebar() {
 | 
				
			||||||
                    getGameTitle={getGameTitle}
 | 
					                    getGameTitle={getGameTitle}
 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                ))}
 | 
					                ))}
 | 
				
			||||||
            </ul>
 | 
					              </ul>
 | 
				
			||||||
          </section>
 | 
					            </section>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <section className="sidebar__section">
 | 
					          <section className="sidebar__section">
 | 
				
			||||||
            <small className="sidebar__section-title">{t("my_library")}</small>
 | 
					            <small className="sidebar__section-title">{t("my_library")}</small>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,8 +7,9 @@
 | 
				
			||||||
  background-color: globals.$dark-background-color;
 | 
					  background-color: globals.$dark-background-color;
 | 
				
			||||||
  border-radius: 4px;
 | 
					  border-radius: 4px;
 | 
				
			||||||
  border: solid 1px globals.$border-color;
 | 
					  border: solid 1px globals.$border-color;
 | 
				
			||||||
  right: 16px;
 | 
					  right: calc(globals.$spacing-unit * 2);
 | 
				
			||||||
  bottom: 26px + globals.$spacing-unit;
 | 
					  // 28px is the height of the bottom panel
 | 
				
			||||||
 | 
					  bottom: calc(28px + globals.$spacing-unit * 2);
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
import { Downloader } from "@shared";
 | 
					import { Downloader } from "@shared";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const VERSION_CODENAME = "Spectre";
 | 
					export const VERSION_CODENAME = "Polychrome";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const DOWNLOADER_NAME = {
 | 
					export const DOWNLOADER_NAME = {
 | 
				
			||||||
  [Downloader.RealDebrid]: "Real-Debrid",
 | 
					  [Downloader.RealDebrid]: "Real-Debrid",
 | 
				
			||||||
| 
						 | 
					@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
 | 
					export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const THEME_WEB_STORE_URL = "https://hydrathemes.shop";
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,20 +9,32 @@ export interface SettingsContext {
 | 
				
			||||||
  updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
 | 
					  updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
 | 
				
			||||||
  setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
 | 
					  setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
 | 
				
			||||||
  clearSourceUrl: () => void;
 | 
					  clearSourceUrl: () => void;
 | 
				
			||||||
 | 
					  clearTheme: () => void;
 | 
				
			||||||
  sourceUrl: string | null;
 | 
					  sourceUrl: string | null;
 | 
				
			||||||
  currentCategoryIndex: number;
 | 
					  currentCategoryIndex: number;
 | 
				
			||||||
  blockedUsers: UserBlocks["blocks"];
 | 
					  blockedUsers: UserBlocks["blocks"];
 | 
				
			||||||
  fetchBlockedUsers: () => Promise<void>;
 | 
					  fetchBlockedUsers: () => Promise<void>;
 | 
				
			||||||
 | 
					  appearance: {
 | 
				
			||||||
 | 
					    theme: string | null;
 | 
				
			||||||
 | 
					    authorId: string | null;
 | 
				
			||||||
 | 
					    authorName: string | null;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const settingsContext = createContext<SettingsContext>({
 | 
					export const settingsContext = createContext<SettingsContext>({
 | 
				
			||||||
  updateUserPreferences: async () => {},
 | 
					  updateUserPreferences: async () => {},
 | 
				
			||||||
  setCurrentCategoryIndex: () => {},
 | 
					  setCurrentCategoryIndex: () => {},
 | 
				
			||||||
  clearSourceUrl: () => {},
 | 
					  clearSourceUrl: () => {},
 | 
				
			||||||
 | 
					  clearTheme: () => {},
 | 
				
			||||||
  sourceUrl: null,
 | 
					  sourceUrl: null,
 | 
				
			||||||
  currentCategoryIndex: 0,
 | 
					  currentCategoryIndex: 0,
 | 
				
			||||||
  blockedUsers: [],
 | 
					  blockedUsers: [],
 | 
				
			||||||
  fetchBlockedUsers: async () => {},
 | 
					  fetchBlockedUsers: async () => {},
 | 
				
			||||||
 | 
					  appearance: {
 | 
				
			||||||
 | 
					    theme: null,
 | 
				
			||||||
 | 
					    authorId: null,
 | 
				
			||||||
 | 
					    authorName: null,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { Provider } = settingsContext;
 | 
					const { Provider } = settingsContext;
 | 
				
			||||||
| 
						 | 
					@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function SettingsContextProvider({
 | 
					export function SettingsContextProvider({
 | 
				
			||||||
  children,
 | 
					  children,
 | 
				
			||||||
}: SettingsContextProviderProps) {
 | 
					}: Readonly<SettingsContextProviderProps>) {
 | 
				
			||||||
  const dispatch = useAppDispatch();
 | 
					  const dispatch = useAppDispatch();
 | 
				
			||||||
  const [sourceUrl, setSourceUrl] = useState<string | null>(null);
 | 
					  const [sourceUrl, setSourceUrl] = useState<string | null>(null);
 | 
				
			||||||
 | 
					  const [appearance, setAppearance] = useState<{
 | 
				
			||||||
 | 
					    theme: string | null;
 | 
				
			||||||
 | 
					    authorId: string | null;
 | 
				
			||||||
 | 
					    authorName: string | null;
 | 
				
			||||||
 | 
					  }>({
 | 
				
			||||||
 | 
					    theme: null,
 | 
				
			||||||
 | 
					    authorId: null,
 | 
				
			||||||
 | 
					    authorName: null,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
  const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
 | 
					  const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
 | 
					  const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [searchParams] = useSearchParams();
 | 
					  const [searchParams] = useSearchParams();
 | 
				
			||||||
  const defaultSourceUrl = searchParams.get("urls");
 | 
					  const defaultSourceUrl = searchParams.get("urls");
 | 
				
			||||||
 | 
					  const defaultAppearanceTheme = searchParams.get("theme");
 | 
				
			||||||
 | 
					  const defaultAppearanceAuthorId = searchParams.get("authorId");
 | 
				
			||||||
 | 
					  const defaultAppearanceAuthorName = searchParams.get("authorName");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (sourceUrl) setCurrentCategoryIndex(2);
 | 
					    if (sourceUrl) setCurrentCategoryIndex(2);
 | 
				
			||||||
| 
						 | 
					@ -54,6 +77,36 @@ export function SettingsContextProvider({
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [defaultSourceUrl]);
 | 
					  }, [defaultSourceUrl]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (appearance.theme) setCurrentCategoryIndex(3);
 | 
				
			||||||
 | 
					  }, [appearance.theme]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (
 | 
				
			||||||
 | 
					      defaultAppearanceTheme &&
 | 
				
			||||||
 | 
					      defaultAppearanceAuthorId &&
 | 
				
			||||||
 | 
					      defaultAppearanceAuthorName
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					      setAppearance({
 | 
				
			||||||
 | 
					        theme: defaultAppearanceTheme,
 | 
				
			||||||
 | 
					        authorId: defaultAppearanceAuthorId,
 | 
				
			||||||
 | 
					        authorName: defaultAppearanceAuthorName,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [
 | 
				
			||||||
 | 
					    defaultAppearanceTheme,
 | 
				
			||||||
 | 
					    defaultAppearanceAuthorId,
 | 
				
			||||||
 | 
					    defaultAppearanceAuthorName,
 | 
				
			||||||
 | 
					  ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const clearTheme = useCallback(() => {
 | 
				
			||||||
 | 
					    setAppearance({
 | 
				
			||||||
 | 
					      theme: null,
 | 
				
			||||||
 | 
					      authorId: null,
 | 
				
			||||||
 | 
					      authorName: null,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const fetchBlockedUsers = useCallback(async () => {
 | 
					  const fetchBlockedUsers = useCallback(async () => {
 | 
				
			||||||
    const blockedUsers = await window.electron.getBlockedUsers(12, 0);
 | 
					    const blockedUsers = await window.electron.getBlockedUsers(12, 0);
 | 
				
			||||||
    setBlockedUsers(blockedUsers.blocks);
 | 
					    setBlockedUsers(blockedUsers.blocks);
 | 
				
			||||||
| 
						 | 
					@ -79,9 +132,11 @@ export function SettingsContextProvider({
 | 
				
			||||||
        setCurrentCategoryIndex,
 | 
					        setCurrentCategoryIndex,
 | 
				
			||||||
        clearSourceUrl,
 | 
					        clearSourceUrl,
 | 
				
			||||||
        fetchBlockedUsers,
 | 
					        fetchBlockedUsers,
 | 
				
			||||||
 | 
					        clearTheme,
 | 
				
			||||||
        currentCategoryIndex,
 | 
					        currentCategoryIndex,
 | 
				
			||||||
        sourceUrl,
 | 
					        sourceUrl,
 | 
				
			||||||
        blockedUsers,
 | 
					        blockedUsers,
 | 
				
			||||||
 | 
					        appearance,
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      {children}
 | 
					      {children}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										18
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
					@ -29,6 +29,7 @@ import type {
 | 
				
			||||||
  LibraryGame,
 | 
					  LibraryGame,
 | 
				
			||||||
  GameRunning,
 | 
					  GameRunning,
 | 
				
			||||||
  TorBoxUser,
 | 
					  TorBoxUser,
 | 
				
			||||||
 | 
					  Theme,
 | 
				
			||||||
} from "@types";
 | 
					} from "@types";
 | 
				
			||||||
import type { AxiosProgressEvent } from "axios";
 | 
					import type { AxiosProgressEvent } from "axios";
 | 
				
			||||||
import type disk from "diskusage";
 | 
					import type disk from "diskusage";
 | 
				
			||||||
| 
						 | 
					@ -279,6 +280,23 @@ declare global {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /* Notifications */
 | 
					    /* Notifications */
 | 
				
			||||||
    publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
 | 
					    publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Themes */
 | 
				
			||||||
 | 
					    addCustomTheme: (theme: Theme) => Promise<void>;
 | 
				
			||||||
 | 
					    getAllCustomThemes: () => Promise<Theme[]>;
 | 
				
			||||||
 | 
					    deleteAllCustomThemes: () => Promise<void>;
 | 
				
			||||||
 | 
					    deleteCustomTheme: (themeId: string) => Promise<void>;
 | 
				
			||||||
 | 
					    updateCustomTheme: (themeId: string, code: string) => Promise<void>;
 | 
				
			||||||
 | 
					    getCustomThemeById: (themeId: string) => Promise<Theme | null>;
 | 
				
			||||||
 | 
					    getActiveCustomTheme: () => Promise<Theme | null>;
 | 
				
			||||||
 | 
					    toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Editor */
 | 
				
			||||||
 | 
					    openEditorWindow: (themeId: string) => Promise<void>;
 | 
				
			||||||
 | 
					    onCssInjected: (
 | 
				
			||||||
 | 
					      cb: (cssString: string) => void
 | 
				
			||||||
 | 
					    ) => () => Electron.IpcRenderer;
 | 
				
			||||||
 | 
					    closeEditorWindow: (themeId?: string) => Promise<void>;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  interface Window {
 | 
					  interface Window {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ export const toastSlice = createSlice({
 | 
				
			||||||
      state.title = action.payload.title;
 | 
					      state.title = action.payload.title;
 | 
				
			||||||
      state.message = action.payload.message;
 | 
					      state.message = action.payload.message;
 | 
				
			||||||
      state.type = action.payload.type;
 | 
					      state.type = action.payload.type;
 | 
				
			||||||
      state.duration = action.payload.duration ?? 5000;
 | 
					      state.duration = action.payload.duration ?? 2000;
 | 
				
			||||||
      state.visible = true;
 | 
					      state.visible = true;
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    closeToast: (state) => {
 | 
					    closeToast: (state) => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
import type { GameShop } from "@types";
 | 
					import type { GameShop } from "@types";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import Color from "color";
 | 
					import Color from "color";
 | 
				
			||||||
 | 
					import { THEME_WEB_STORE_URL } from "./constants";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const formatDownloadProgress = (
 | 
					export const formatDownloadProgress = (
 | 
				
			||||||
  progress?: number,
 | 
					  progress?: number,
 | 
				
			||||||
| 
						 | 
					@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
 | 
					export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
 | 
				
			||||||
  new Color(color).darken(amount).alpha(alpha).toString();
 | 
					  new Color(color).darken(amount).alpha(alpha).toString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const injectCustomCss = (css: string) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const currentCustomCss = document.getElementById("custom-css");
 | 
				
			||||||
 | 
					    if (currentCustomCss) {
 | 
				
			||||||
 | 
					      currentCustomCss.remove();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (css.startsWith(THEME_WEB_STORE_URL)) {
 | 
				
			||||||
 | 
					      const link = document.createElement("link");
 | 
				
			||||||
 | 
					      link.id = "custom-css";
 | 
				
			||||||
 | 
					      link.rel = "stylesheet";
 | 
				
			||||||
 | 
					      link.href = css;
 | 
				
			||||||
 | 
					      document.head.appendChild(link);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const style = document.createElement("style");
 | 
				
			||||||
 | 
					      style.id = "custom-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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,18 +1,26 @@
 | 
				
			||||||
import { useEffect } from "react";
 | 
					import { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
enum Feature {
 | 
					enum Feature {
 | 
				
			||||||
  CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
 | 
					  CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
 | 
				
			||||||
 | 
					  Torbox = "TORBOX",
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useFeature() {
 | 
					export function useFeature() {
 | 
				
			||||||
 | 
					  const [features, setFeatures] = useState<string[] | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    window.electron.getFeatures().then((features) => {
 | 
					    window.electron.getFeatures().then((features) => {
 | 
				
			||||||
      localStorage.setItem("features", JSON.stringify(features || []));
 | 
					      localStorage.setItem("features", JSON.stringify(features || []));
 | 
				
			||||||
 | 
					      setFeatures(features || []);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const isFeatureEnabled = (feature: Feature) => {
 | 
					  const isFeatureEnabled = (feature: Feature) => {
 | 
				
			||||||
    const features = JSON.parse(localStorage.getItem("features") || "[]");
 | 
					    if (!features) {
 | 
				
			||||||
 | 
					      const features = JSON.parse(localStorage.getItem("features") ?? "[]");
 | 
				
			||||||
 | 
					      return features.includes(feature);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return features.includes(feature);
 | 
					    return features.includes(feature);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +33,9 @@ const Profile = React.lazy(() => import("./pages/profile/profile"));
 | 
				
			||||||
const Achievements = React.lazy(
 | 
					const Achievements = React.lazy(
 | 
				
			||||||
  () => import("./pages/achievements/achievements")
 | 
					  () => import("./pages/achievements/achievements")
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					const ThemeEditor = React.lazy(
 | 
				
			||||||
 | 
					  () => import("./pages/theme-editor/theme-editor")
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import * as Sentry from "@sentry/react";
 | 
					import * as Sentry from "@sentry/react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -105,6 +108,11 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
 | 
				
			||||||
              element={<SuspenseWrapper Component={Achievements} />}
 | 
					              element={<SuspenseWrapper Component={Achievements} />}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </Route>
 | 
					          </Route>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <Route
 | 
				
			||||||
 | 
					            path="/theme-editor"
 | 
				
			||||||
 | 
					            element={<SuspenseWrapper Component={ThemeEditor} />}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </Routes>
 | 
					        </Routes>
 | 
				
			||||||
      </HashRouter>
 | 
					      </HashRouter>
 | 
				
			||||||
    </Provider>
 | 
					    </Provider>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ export function DeleteGameModal({
 | 
				
			||||||
  onClose,
 | 
					  onClose,
 | 
				
			||||||
  visible,
 | 
					  visible,
 | 
				
			||||||
  deleteGame,
 | 
					  deleteGame,
 | 
				
			||||||
}: DeleteGameModalProps) {
 | 
					}: Readonly<DeleteGameModalProps>) {
 | 
				
			||||||
  const { t } = useTranslation("downloads");
 | 
					  const { t } = useTranslation("downloads");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleDeleteGame = () => {
 | 
					  const handleDeleteGame = () => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -32,6 +32,7 @@ import {
 | 
				
			||||||
} from "@primer/octicons-react";
 | 
					} from "@primer/octicons-react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
 | 
					import torBoxLogo from "@renderer/assets/icons/torbox.webp";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface DownloadGroupProps {
 | 
					export interface DownloadGroupProps {
 | 
				
			||||||
  library: LibraryGame[];
 | 
					  library: LibraryGame[];
 | 
				
			||||||
  title: string;
 | 
					  title: string;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,10 +7,11 @@ import {
 | 
				
			||||||
  PlusCircleIcon,
 | 
					  PlusCircleIcon,
 | 
				
			||||||
} from "@primer/octicons-react";
 | 
					} from "@primer/octicons-react";
 | 
				
			||||||
import { Button } from "@renderer/components";
 | 
					import { Button } from "@renderer/components";
 | 
				
			||||||
import { useDownload, useLibrary } from "@renderer/hooks";
 | 
					import { useDownload, useLibrary, useToast } from "@renderer/hooks";
 | 
				
			||||||
import { useContext, useState } from "react";
 | 
					import { useContext, useState } from "react";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
import { gameDetailsContext } from "@renderer/context";
 | 
					import { gameDetailsContext } from "@renderer/context";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import "./hero-panel-actions.scss";
 | 
					import "./hero-panel-actions.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function HeroPanelActions() {
 | 
					export function HeroPanelActions() {
 | 
				
			||||||
| 
						 | 
					@ -39,6 +40,8 @@ export function HeroPanelActions() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { updateLibrary } = useLibrary();
 | 
					  const { updateLibrary } = useLibrary();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { showSuccessToast } = useToast();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { t } = useTranslation("game_details");
 | 
					  const { t } = useTranslation("game_details");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const addGameToLibrary = async () => {
 | 
					  const addGameToLibrary = async () => {
 | 
				
			||||||
| 
						 | 
					@ -54,25 +57,24 @@ export function HeroPanelActions() {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const addGameToFavorites = async () => {
 | 
					  const toggleGameFavorite = async () => {
 | 
				
			||||||
    setToggleLibraryGameDisabled(true);
 | 
					    setToggleLibraryGameDisabled(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      if (!objectId) throw new Error("objectId is required");
 | 
					      if (game?.favorite && objectId) {
 | 
				
			||||||
      await window.electron.addGameToFavorites(shop, objectId);
 | 
					        await window.electron
 | 
				
			||||||
      updateLibrary();
 | 
					          .removeGameFromFavorites(shop, objectId)
 | 
				
			||||||
      updateGame();
 | 
					          .then(() => {
 | 
				
			||||||
    } finally {
 | 
					            showSuccessToast(t("game_removed_from_favorites"));
 | 
				
			||||||
      setToggleLibraryGameDisabled(false);
 | 
					          });
 | 
				
			||||||
    }
 | 
					      } else {
 | 
				
			||||||
  };
 | 
					        if (!objectId) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const removeGameFromFavorites = async () => {
 | 
					        await window.electron.addGameToFavorites(shop, objectId).then(() => {
 | 
				
			||||||
    setToggleLibraryGameDisabled(true);
 | 
					          showSuccessToast(t("game_added_to_favorites"));
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      if (!objectId) throw new Error("objectId is required");
 | 
					 | 
				
			||||||
      await window.electron.removeGameFromFavorites(shop, objectId);
 | 
					 | 
				
			||||||
      updateLibrary();
 | 
					      updateLibrary();
 | 
				
			||||||
      updateGame();
 | 
					      updateGame();
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
| 
						 | 
					@ -188,7 +190,7 @@ export function HeroPanelActions() {
 | 
				
			||||||
        {gameActionButton()}
 | 
					        {gameActionButton()}
 | 
				
			||||||
        <div className="hero-panel-actions__separator" />
 | 
					        <div className="hero-panel-actions__separator" />
 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          onClick={game.favorite ? removeGameFromFavorites : addGameToFavorites}
 | 
					          onClick={toggleGameFavorite}
 | 
				
			||||||
          theme="outline"
 | 
					          theme="outline"
 | 
				
			||||||
          disabled={deleting}
 | 
					          disabled={deleting}
 | 
				
			||||||
          className="hero-panel-actions__action"
 | 
					          className="hero-panel-actions__action"
 | 
				
			||||||
| 
						 | 
					@ -196,7 +198,6 @@ export function HeroPanelActions() {
 | 
				
			||||||
          {game.favorite ? <HeartFillIcon /> : <HeartIcon />}
 | 
					          {game.favorite ? <HeartFillIcon /> : <HeartIcon />}
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          onClick={() => setShowGameOptionsModal(true)}
 | 
					          onClick={() => setShowGameOptionsModal(true)}
 | 
				
			||||||
          theme="outline"
 | 
					          theme="outline"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -44,10 +44,9 @@ export function DownloadSettingsModal({
 | 
				
			||||||
    (state) => state.userPreferences.value
 | 
					    (state) => state.userPreferences.value
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const getDiskFreeSpace = (path: string) => {
 | 
					  const getDiskFreeSpace = async (path: string) => {
 | 
				
			||||||
    window.electron.getDiskFreeSpace(path).then((result) => {
 | 
					    const result = await window.electron.getDiskFreeSpace(path);
 | 
				
			||||||
      setDiskFreeSpace(result.free);
 | 
					    setDiskFreeSpace(result.free);
 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const checkFolderWritePermission = useCallback(
 | 
					  const checkFolderWritePermission = useCallback(
 | 
				
			||||||
| 
						 | 
					@ -100,6 +99,7 @@ export function DownloadSettingsModal({
 | 
				
			||||||
    userPreferences?.downloadsPath,
 | 
					    userPreferences?.downloadsPath,
 | 
				
			||||||
    downloaders,
 | 
					    downloaders,
 | 
				
			||||||
    userPreferences?.realDebridApiToken,
 | 
					    userPreferences?.realDebridApiToken,
 | 
				
			||||||
 | 
					    userPreferences?.torBoxApiToken,
 | 
				
			||||||
  ]);
 | 
					  ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleChooseDownloadsPath = async () => {
 | 
					  const handleChooseDownloadsPath = async () => {
 | 
				
			||||||
| 
						 | 
					@ -155,27 +155,30 @@ export function DownloadSettingsModal({
 | 
				
			||||||
          <span>{t("downloader")}</span>
 | 
					          <span>{t("downloader")}</span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div className="download-settings-modal__downloaders">
 | 
					          <div className="download-settings-modal__downloaders">
 | 
				
			||||||
            {downloaders.map((downloader) => (
 | 
					            {downloaders.map((downloader) => {
 | 
				
			||||||
              <Button
 | 
					              const shouldDisableButton =
 | 
				
			||||||
                key={downloader}
 | 
					                (downloader === Downloader.RealDebrid &&
 | 
				
			||||||
                className="download-settings-modal__downloader-option"
 | 
					                  !userPreferences?.realDebridApiToken) ||
 | 
				
			||||||
                theme={
 | 
					                (downloader === Downloader.TorBox &&
 | 
				
			||||||
                  selectedDownloader === downloader ? "primary" : "outline"
 | 
					                  !userPreferences?.torBoxApiToken);
 | 
				
			||||||
                }
 | 
					
 | 
				
			||||||
                disabled={
 | 
					              return (
 | 
				
			||||||
                  (downloader === Downloader.RealDebrid &&
 | 
					                <Button
 | 
				
			||||||
                    !userPreferences?.realDebridApiToken) ||
 | 
					                  key={downloader}
 | 
				
			||||||
                  (downloader === Downloader.TorBox &&
 | 
					                  className="download-settings-modal__downloader-option"
 | 
				
			||||||
                    !userPreferences?.torBoxApiToken)
 | 
					                  theme={
 | 
				
			||||||
                }
 | 
					                    selectedDownloader === downloader ? "primary" : "outline"
 | 
				
			||||||
                onClick={() => setSelectedDownloader(downloader)}
 | 
					                  }
 | 
				
			||||||
              >
 | 
					                  disabled={shouldDisableButton}
 | 
				
			||||||
                {selectedDownloader === downloader && (
 | 
					                  onClick={() => setSelectedDownloader(downloader)}
 | 
				
			||||||
                  <CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
 | 
					                >
 | 
				
			||||||
                )}
 | 
					                  {selectedDownloader === downloader && (
 | 
				
			||||||
                {DOWNLOADER_NAME[downloader]}
 | 
					                    <CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
 | 
				
			||||||
              </Button>
 | 
					                  )}
 | 
				
			||||||
            ))}
 | 
					                  {DOWNLOADER_NAME[downloader]}
 | 
				
			||||||
 | 
					                </Button>
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            })}
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -74,7 +74,10 @@ export function ReportProfile() {
 | 
				
			||||||
        title={t("report_profile")}
 | 
					        title={t("report_profile")}
 | 
				
			||||||
        clickOutsideToClose={false}
 | 
					        clickOutsideToClose={false}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <form className="report-profile__form">
 | 
					        <form
 | 
				
			||||||
 | 
					          onSubmit={handleSubmit(onSubmit)}
 | 
				
			||||||
 | 
					          className="report-profile__form"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <Controller
 | 
					          <Controller
 | 
				
			||||||
            control={control}
 | 
					            control={control}
 | 
				
			||||||
            name="reason"
 | 
					            name="reason"
 | 
				
			||||||
| 
						 | 
					@ -101,12 +104,7 @@ export function ReportProfile() {
 | 
				
			||||||
            error={errors.description?.message}
 | 
					            error={errors.description?.message}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <Button
 | 
					          <Button className="report-profile__submit">{t("report")}</Button>
 | 
				
			||||||
            className="report-profile__submit"
 | 
					 | 
				
			||||||
            onClick={handleSubmit(onSubmit)}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            {t("report")}
 | 
					 | 
				
			||||||
          </Button>
 | 
					 | 
				
			||||||
        </form>
 | 
					        </form>
 | 
				
			||||||
      </Modal>
 | 
					      </Modal>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
 | 
				
			||||||
  visible,
 | 
					  visible,
 | 
				
			||||||
  onClose,
 | 
					  onClose,
 | 
				
			||||||
  onAddDownloadSource,
 | 
					  onAddDownloadSource,
 | 
				
			||||||
}: AddDownloadSourceModalProps) {
 | 
					}: Readonly<AddDownloadSourceModalProps>) {
 | 
				
			||||||
  const [url, setUrl] = useState("");
 | 
					  const [url, setUrl] = useState("");
 | 
				
			||||||
  const [isLoading, setIsLoading] = useState(false);
 | 
					  const [isLoading, setIsLoading] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,25 @@
 | 
				
			||||||
 | 
					@use "../../../../scss/globals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.settings-appearance {
 | 
				
			||||||
 | 
					  &__actions {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &-left {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &-right {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__button {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    gap: 8px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,75 @@
 | 
				
			||||||
 | 
					import { GlobeIcon, TrashIcon, PlusIcon } from "@primer/octicons-react";
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { AddThemeModal, DeleteAllThemesModal } from "../index";
 | 
				
			||||||
 | 
					import "./theme-actions.scss";
 | 
				
			||||||
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					import { THEME_WEB_STORE_URL } from "@renderer/constants";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ThemeActionsProps {
 | 
				
			||||||
 | 
					  onListUpdated: () => void;
 | 
				
			||||||
 | 
					  themesCount: number;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ThemeActions = ({
 | 
				
			||||||
 | 
					  onListUpdated,
 | 
				
			||||||
 | 
					  themesCount,
 | 
				
			||||||
 | 
					}: ThemeActionsProps) => {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
 | 
				
			||||||
 | 
					  const [deleteAllThemesModalVisible, setDeleteAllThemesModalVisible] =
 | 
				
			||||||
 | 
					    useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <AddThemeModal
 | 
				
			||||||
 | 
					        visible={addThemeModalVisible}
 | 
				
			||||||
 | 
					        onClose={() => setAddThemeModalVisible(false)}
 | 
				
			||||||
 | 
					        onThemeAdded={onListUpdated}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <DeleteAllThemesModal
 | 
				
			||||||
 | 
					        visible={deleteAllThemesModalVisible}
 | 
				
			||||||
 | 
					        onClose={() => setDeleteAllThemesModalVisible(false)}
 | 
				
			||||||
 | 
					        onThemesDeleted={onListUpdated}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="settings-appearance__actions">
 | 
				
			||||||
 | 
					        <div className="settings-appearance__actions-left">
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            theme="primary"
 | 
				
			||||||
 | 
					            className="settings-appearance__button"
 | 
				
			||||||
 | 
					            onClick={() => {
 | 
				
			||||||
 | 
					              window.open(THEME_WEB_STORE_URL, "_blank");
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <GlobeIcon />
 | 
				
			||||||
 | 
					            {t("web_store")}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            theme="danger"
 | 
				
			||||||
 | 
					            className="settings-appearance__button"
 | 
				
			||||||
 | 
					            onClick={() => setDeleteAllThemesModalVisible(true)}
 | 
				
			||||||
 | 
					            disabled={themesCount < 1}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <TrashIcon />
 | 
				
			||||||
 | 
					            {t("clear_themes")}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div className="settings-appearance__actions-right">
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            theme="outline"
 | 
				
			||||||
 | 
					            className="settings-appearance__button"
 | 
				
			||||||
 | 
					            onClick={() => setAddThemeModalVisible(true)}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <PlusIcon />
 | 
				
			||||||
 | 
					            {t("create_theme")}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,97 @@
 | 
				
			||||||
 | 
					@use "../../../../scss/globals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.theme-card {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  min-height: 160px;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  background-color: rgba(globals.$border-color, 0.01);
 | 
				
			||||||
 | 
					  border: 1px solid globals.$border-color;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  gap: 4px;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s ease;
 | 
				
			||||||
 | 
					  padding: 16px;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--active {
 | 
				
			||||||
 | 
					    background-color: rgba(globals.$border-color, 0.04);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__header {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    gap: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__title {
 | 
				
			||||||
 | 
					      font-size: 18px;
 | 
				
			||||||
 | 
					      font-weight: 600;
 | 
				
			||||||
 | 
					      color: globals.$muted-color;
 | 
				
			||||||
 | 
					      text-transform: capitalize;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__colors {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-direction: row;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &__color {
 | 
				
			||||||
 | 
					        width: 16px;
 | 
				
			||||||
 | 
					        height: 16px;
 | 
				
			||||||
 | 
					        border-radius: 4px;
 | 
				
			||||||
 | 
					        border: 1px solid globals.$border-color;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__author {
 | 
				
			||||||
 | 
					    font-size: 12px;
 | 
				
			||||||
 | 
					    color: globals.$body-color;
 | 
				
			||||||
 | 
					    font-weight: 400;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__name {
 | 
				
			||||||
 | 
					      font-weight: 600;
 | 
				
			||||||
 | 
					      color: rgba(globals.$muted-color, 0.8);
 | 
				
			||||||
 | 
					      margin-left: 4px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:hover {
 | 
				
			||||||
 | 
					        color: globals.$muted-color;
 | 
				
			||||||
 | 
					        cursor: pointer;
 | 
				
			||||||
 | 
					        text-decoration: underline;
 | 
				
			||||||
 | 
					        text-underline-offset: 2px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__actions {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    position: absolute;
 | 
				
			||||||
 | 
					    bottom: 16px;
 | 
				
			||||||
 | 
					    left: 16px;
 | 
				
			||||||
 | 
					    right: 16px;
 | 
				
			||||||
 | 
					    gap: 8px;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__left {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-direction: row;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__right {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-direction: row;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &--external {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      Button {
 | 
				
			||||||
 | 
					        padding: 8px 11px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,130 @@
 | 
				
			||||||
 | 
					import { PencilIcon, TrashIcon } from "@primer/octicons-react";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import type { Theme } from "@types";
 | 
				
			||||||
 | 
					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";
 | 
				
			||||||
 | 
					import { THEME_WEB_STORE_URL } from "@renderer/constants";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ThemeCardProps {
 | 
				
			||||||
 | 
					  theme: Theme;
 | 
				
			||||||
 | 
					  onListUpdated: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
 | 
				
			||||||
 | 
					  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.toggleCustomTheme(activeTheme.id, false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (currentTheme.code) {
 | 
				
			||||||
 | 
					        injectCustomCss(currentTheme.code);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await window.electron.toggleCustomTheme(currentTheme.id, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      onListUpdated();
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleUnsetTheme = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      removeCustomCss();
 | 
				
			||||||
 | 
					      await window.electron.toggleCustomTheme(theme.id, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      onListUpdated();
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <DeleteThemeModal
 | 
				
			||||||
 | 
					        visible={deleteThemeModalVisible}
 | 
				
			||||||
 | 
					        onClose={() => setDeleteThemeModalVisible(false)}
 | 
				
			||||||
 | 
					        onThemeDeleted={onListUpdated}
 | 
				
			||||||
 | 
					        themeId={theme.id}
 | 
				
			||||||
 | 
					        themeName={theme.name}
 | 
				
			||||||
 | 
					        isActive={theme.isActive}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        className={`theme-card ${theme.isActive ? "theme-card--active" : ""}`}
 | 
				
			||||||
 | 
					        key={theme.name}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div className="theme-card__header">
 | 
				
			||||||
 | 
					          <div className="theme-card__header__title">{theme.name}</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {theme.authorName && (
 | 
				
			||||||
 | 
					          <p className="theme-card__author">
 | 
				
			||||||
 | 
					            {t("by")}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              className="theme-card__author__name"
 | 
				
			||||||
 | 
					              onClick={() => navigate(`/profile/${theme.author}`)}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {theme.authorName}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div className="theme-card__actions">
 | 
				
			||||||
 | 
					          <div className="theme-card__actions__left">
 | 
				
			||||||
 | 
					            {theme.isActive ? (
 | 
				
			||||||
 | 
					              <Button onClick={handleUnsetTheme} theme="dark">
 | 
				
			||||||
 | 
					                {t("unset_theme")}
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            ) : (
 | 
				
			||||||
 | 
					              <Button onClick={handleSetTheme} theme="outline">
 | 
				
			||||||
 | 
					                {t("set_theme")}
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div className="theme-card__actions__right">
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              className={
 | 
				
			||||||
 | 
					                theme.code.startsWith(THEME_WEB_STORE_URL)
 | 
				
			||||||
 | 
					                  ? "theme-card__actions__right--external"
 | 
				
			||||||
 | 
					                  : ""
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              onClick={() => window.electron.openEditorWindow(theme.id)}
 | 
				
			||||||
 | 
					              title={t("edit_theme")}
 | 
				
			||||||
 | 
					              theme="outline"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <PencilIcon />
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              onClick={() => setDeleteThemeModalVisible(true)}
 | 
				
			||||||
 | 
					              title={t("delete_theme")}
 | 
				
			||||||
 | 
					              theme="outline"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <TrashIcon />
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,39 @@
 | 
				
			||||||
 | 
					@use "../../../../scss/globals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.theme-placeholder {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  min-height: 160px;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 40px 24px;
 | 
				
			||||||
 | 
					  background-color: rgba(globals.$border-color, 0.01);
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  border: 1px dashed globals.$border-color;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  gap: 12px;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s ease;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &:hover {
 | 
				
			||||||
 | 
					    background-color: rgba(globals.$border-color, 0.03);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__icon {
 | 
				
			||||||
 | 
					    svg {
 | 
				
			||||||
 | 
					      width: 32px;
 | 
				
			||||||
 | 
					      height: 32px;
 | 
				
			||||||
 | 
					      color: globals.$body-color;
 | 
				
			||||||
 | 
					      opacity: 0.7;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__text {
 | 
				
			||||||
 | 
					    text-align: center;
 | 
				
			||||||
 | 
					    max-width: 400px;
 | 
				
			||||||
 | 
					    font-size: 14.5px;
 | 
				
			||||||
 | 
					    line-height: 1.6;
 | 
				
			||||||
 | 
					    font-weight: 400;
 | 
				
			||||||
 | 
					    color: rgba(globals.$body-color, 0.85);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,36 @@
 | 
				
			||||||
 | 
					import { AlertIcon } from "@primer/octicons-react";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import "./theme-placeholder.scss";
 | 
				
			||||||
 | 
					import { AddThemeModal } from "../modals/add-theme-modal";
 | 
				
			||||||
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ThemePlaceholderProps {
 | 
				
			||||||
 | 
					  onListUpdated: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ThemePlaceholder = ({ onListUpdated }: ThemePlaceholderProps) => {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <AddThemeModal
 | 
				
			||||||
 | 
					        visible={addThemeModalVisible}
 | 
				
			||||||
 | 
					        onClose={() => setAddThemeModalVisible(false)}
 | 
				
			||||||
 | 
					        onThemeAdded={onListUpdated}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        className="theme-placeholder"
 | 
				
			||||||
 | 
					        onClick={() => setAddThemeModalVisible(true)}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div className="theme-placeholder__icon">
 | 
				
			||||||
 | 
					          <AlertIcon />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <p className="theme-placeholder__text">{t("no_themes")}</p>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										7
									
								
								src/renderer/src/pages/settings/aparence/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/renderer/src/pages/settings/aparence/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					export { SettingsAppearance } from "./settings-appearance";
 | 
				
			||||||
 | 
					export { AddThemeModal } from "./modals/add-theme-modal";
 | 
				
			||||||
 | 
					export { DeleteAllThemesModal } from "./modals/delete-all-themes-modal";
 | 
				
			||||||
 | 
					export { DeleteThemeModal } from "./modals/delete-theme-modal";
 | 
				
			||||||
 | 
					export { ThemeCard } from "./components/theme-card";
 | 
				
			||||||
 | 
					export { ThemePlaceholder } from "./components/theme-placeholder";
 | 
				
			||||||
 | 
					export { ThemeActions } from "./components/theme-actions";
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,127 @@
 | 
				
			||||||
 | 
					import { Modal } from "@renderer/components/modal/modal";
 | 
				
			||||||
 | 
					import { TextField } from "@renderer/components/text-field/text-field";
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import { useUserDetails } from "@renderer/hooks";
 | 
				
			||||||
 | 
					import { Theme } from "@types";
 | 
				
			||||||
 | 
					import { useForm } from "react-hook-form";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as yup from "yup";
 | 
				
			||||||
 | 
					import { yupResolver } from "@hookform/resolvers/yup";
 | 
				
			||||||
 | 
					import { useCallback } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import "./modals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface AddThemeModalProps {
 | 
				
			||||||
 | 
					  visible: boolean;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onThemeAdded: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface FormValues {
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const DEFAULT_THEME_CODE = `
 | 
				
			||||||
 | 
					  /*
 | 
				
			||||||
 | 
					    Here you can edit CSS for your theme and apply it on Hydra.
 | 
				
			||||||
 | 
					    There are a few classes already in place, you can use them to style the launcher.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
 | 
				
			||||||
 | 
					    or how to publish your theme in the theme store, you can check the docs:
 | 
				
			||||||
 | 
					    https://docs.hydralauncher.gg/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Happy hacking!
 | 
				
			||||||
 | 
					  */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Header */
 | 
				
			||||||
 | 
					  .header {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Sidebar */
 | 
				
			||||||
 | 
					  .sidebar {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Main content */
 | 
				
			||||||
 | 
					  .container__content {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Bottom panel */
 | 
				
			||||||
 | 
					  .bottom-panel {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Toast */
 | 
				
			||||||
 | 
					  .toast {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Button */
 | 
				
			||||||
 | 
					  .button {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function AddThemeModal({
 | 
				
			||||||
 | 
					  visible,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  onThemeAdded,
 | 
				
			||||||
 | 
					}: Readonly<AddThemeModalProps>) {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					  const { userDetails } = useUserDetails();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const schema = yup.object({
 | 
				
			||||||
 | 
					    name: yup
 | 
				
			||||||
 | 
					      .string()
 | 
				
			||||||
 | 
					      .required(t("required_field"))
 | 
				
			||||||
 | 
					      .min(3, t("name_min_length")),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const {
 | 
				
			||||||
 | 
					    register,
 | 
				
			||||||
 | 
					    handleSubmit,
 | 
				
			||||||
 | 
					    reset,
 | 
				
			||||||
 | 
					    formState: { isSubmitting, errors },
 | 
				
			||||||
 | 
					  } = useForm<FormValues>({
 | 
				
			||||||
 | 
					    resolver: yupResolver(schema),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onSubmit = useCallback(
 | 
				
			||||||
 | 
					    async (values: FormValues) => {
 | 
				
			||||||
 | 
					      const theme: Theme = {
 | 
				
			||||||
 | 
					        id: crypto.randomUUID(),
 | 
				
			||||||
 | 
					        name: values.name,
 | 
				
			||||||
 | 
					        isActive: false,
 | 
				
			||||||
 | 
					        author: userDetails?.id,
 | 
				
			||||||
 | 
					        authorName: userDetails?.username,
 | 
				
			||||||
 | 
					        code: DEFAULT_THEME_CODE,
 | 
				
			||||||
 | 
					        createdAt: new Date(),
 | 
				
			||||||
 | 
					        updatedAt: new Date(),
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await window.electron.addCustomTheme(theme);
 | 
				
			||||||
 | 
					      onThemeAdded();
 | 
				
			||||||
 | 
					      onClose();
 | 
				
			||||||
 | 
					      reset();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [onClose, onThemeAdded, userDetails?.id, userDetails?.username, reset]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Modal
 | 
				
			||||||
 | 
					      visible={visible}
 | 
				
			||||||
 | 
					      title={t("create_theme_modal_title")}
 | 
				
			||||||
 | 
					      description={t("create_theme_modal_description")}
 | 
				
			||||||
 | 
					      onClose={onClose}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <form
 | 
				
			||||||
 | 
					        onSubmit={handleSubmit(onSubmit)}
 | 
				
			||||||
 | 
					        className="add-theme-modal__container"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <TextField
 | 
				
			||||||
 | 
					          {...register("name")}
 | 
				
			||||||
 | 
					          label={t("theme_name")}
 | 
				
			||||||
 | 
					          placeholder={t("insert_theme_name")}
 | 
				
			||||||
 | 
					          hint={errors.name?.message}
 | 
				
			||||||
 | 
					          error={errors.name?.message}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button type="submit" theme="primary" disabled={isSubmitting}>
 | 
				
			||||||
 | 
					          {t("create_theme")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </form>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,51 @@
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import { Modal } from "@renderer/components/modal/modal";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import "./modals.scss";
 | 
				
			||||||
 | 
					import { removeCustomCss } from "@renderer/helpers";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface DeleteAllThemesModalProps {
 | 
				
			||||||
 | 
					  visible: boolean;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onThemesDeleted: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DeleteAllThemesModal = ({
 | 
				
			||||||
 | 
					  visible,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  onThemesDeleted,
 | 
				
			||||||
 | 
					}: DeleteAllThemesModalProps) => {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDeleteAllThemes = async () => {
 | 
				
			||||||
 | 
					    const activeTheme = await window.electron.getActiveCustomTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (activeTheme) {
 | 
				
			||||||
 | 
					      removeCustomCss();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await window.electron.deleteAllCustomThemes();
 | 
				
			||||||
 | 
					    await window.electron.closeEditorWindow();
 | 
				
			||||||
 | 
					    onClose();
 | 
				
			||||||
 | 
					    onThemesDeleted();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Modal
 | 
				
			||||||
 | 
					      visible={visible}
 | 
				
			||||||
 | 
					      title={t("delete_all_themes")}
 | 
				
			||||||
 | 
					      description={t("delete_all_themes_description")}
 | 
				
			||||||
 | 
					      onClose={onClose}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div className="delete-all-themes-modal__container">
 | 
				
			||||||
 | 
					        <Button theme="outline" onClick={handleDeleteAllThemes}>
 | 
				
			||||||
 | 
					          {t("delete_all_themes")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button theme="primary" onClick={onClose}>
 | 
				
			||||||
 | 
					          {t("cancel")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,54 @@
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import { Modal } from "@renderer/components/modal/modal";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import "./modals.scss";
 | 
				
			||||||
 | 
					import { removeCustomCss } from "@renderer/helpers";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface DeleteThemeModalProps {
 | 
				
			||||||
 | 
					  visible: boolean;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  themeId: string;
 | 
				
			||||||
 | 
					  isActive: boolean;
 | 
				
			||||||
 | 
					  onThemeDeleted: () => void;
 | 
				
			||||||
 | 
					  themeName: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DeleteThemeModal = ({
 | 
				
			||||||
 | 
					  visible,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  themeId,
 | 
				
			||||||
 | 
					  isActive,
 | 
				
			||||||
 | 
					  onThemeDeleted,
 | 
				
			||||||
 | 
					  themeName,
 | 
				
			||||||
 | 
					}: DeleteThemeModalProps) => {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDeleteTheme = async () => {
 | 
				
			||||||
 | 
					    if (isActive) {
 | 
				
			||||||
 | 
					      removeCustomCss();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await window.electron.deleteCustomTheme(themeId);
 | 
				
			||||||
 | 
					    await window.electron.closeEditorWindow(themeId);
 | 
				
			||||||
 | 
					    onThemeDeleted();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Modal
 | 
				
			||||||
 | 
					      visible={visible}
 | 
				
			||||||
 | 
					      title={t("delete_theme")}
 | 
				
			||||||
 | 
					      description={t("delete_theme_description", { theme: themeName })}
 | 
				
			||||||
 | 
					      onClose={onClose}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div className="delete-all-themes-modal__container">
 | 
				
			||||||
 | 
					        <Button theme="outline" onClick={handleDeleteTheme}>
 | 
				
			||||||
 | 
					          {t("delete_theme")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button theme="primary" onClick={onClose}>
 | 
				
			||||||
 | 
					          {t("cancel")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,90 @@
 | 
				
			||||||
 | 
					import { Button } from "@renderer/components/button/button";
 | 
				
			||||||
 | 
					import { Modal } from "@renderer/components/modal/modal";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import "./modals.scss";
 | 
				
			||||||
 | 
					import { Theme } from "@types";
 | 
				
			||||||
 | 
					import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
 | 
				
			||||||
 | 
					import { useToast } from "@renderer/hooks";
 | 
				
			||||||
 | 
					import { THEME_WEB_STORE_URL } from "@renderer/constants";
 | 
				
			||||||
 | 
					import { logger } from "@renderer/logger";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ImportThemeModalProps {
 | 
				
			||||||
 | 
					  visible: boolean;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onThemeImported: () => void;
 | 
				
			||||||
 | 
					  themeName: string;
 | 
				
			||||||
 | 
					  authorId: string;
 | 
				
			||||||
 | 
					  authorName: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ImportThemeModal = ({
 | 
				
			||||||
 | 
					  visible,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  onThemeImported,
 | 
				
			||||||
 | 
					  themeName,
 | 
				
			||||||
 | 
					  authorId,
 | 
				
			||||||
 | 
					  authorName,
 | 
				
			||||||
 | 
					}: ImportThemeModalProps) => {
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					  const { showSuccessToast, showErrorToast } = useToast();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleImportTheme = async () => {
 | 
				
			||||||
 | 
					    const theme: Theme = {
 | 
				
			||||||
 | 
					      id: crypto.randomUUID(),
 | 
				
			||||||
 | 
					      name: themeName,
 | 
				
			||||||
 | 
					      isActive: false,
 | 
				
			||||||
 | 
					      author: authorId,
 | 
				
			||||||
 | 
					      authorName: authorName,
 | 
				
			||||||
 | 
					      code: `${THEME_WEB_STORE_URL}/themes/${themeName.toLowerCase()}/theme.css`,
 | 
				
			||||||
 | 
					      createdAt: new Date(),
 | 
				
			||||||
 | 
					      updatedAt: new Date(),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await window.electron.addCustomTheme(theme);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const currentTheme = await window.electron.getCustomThemeById(theme.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!currentTheme) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const activeTheme = await window.electron.getActiveCustomTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (activeTheme) {
 | 
				
			||||||
 | 
					        removeCustomCss();
 | 
				
			||||||
 | 
					        await window.electron.toggleCustomTheme(activeTheme.id, false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (currentTheme.code) {
 | 
				
			||||||
 | 
					        injectCustomCss(currentTheme.code);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await window.electron.toggleCustomTheme(currentTheme.id, true);
 | 
				
			||||||
 | 
					      onThemeImported();
 | 
				
			||||||
 | 
					      showSuccessToast(t("theme_imported"));
 | 
				
			||||||
 | 
					      onClose();
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      logger.error(error);
 | 
				
			||||||
 | 
					      showErrorToast(t("error_importing_theme"));
 | 
				
			||||||
 | 
					      onClose();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Modal
 | 
				
			||||||
 | 
					      visible={visible}
 | 
				
			||||||
 | 
					      title={t("import_theme")}
 | 
				
			||||||
 | 
					      description={t("import_theme_description", { theme: themeName })}
 | 
				
			||||||
 | 
					      onClose={onClose}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div className="delete-all-themes-modal__container">
 | 
				
			||||||
 | 
					        <Button theme="outline" onClick={handleImportTheme}>
 | 
				
			||||||
 | 
					          {t("import_theme")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <Button theme="primary" onClick={onClose}>
 | 
				
			||||||
 | 
					          {t("cancel")}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </Modal>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										15
									
								
								src/renderer/src/pages/settings/aparence/modals/modals.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/renderer/src/pages/settings/aparence/modals/modals.scss
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					.add-theme-modal {
 | 
				
			||||||
 | 
					  &__container {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    gap: 16px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.delete-all-themes-modal__container {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  justify-content: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,154 @@
 | 
				
			||||||
 | 
					@use "../../../scss/globals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.settings-appearance {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  gap: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__actions {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &-left {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      gap: 8px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__themes {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    gap: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__theme {
 | 
				
			||||||
 | 
					      width: 100%;
 | 
				
			||||||
 | 
					      min-height: 160px;
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-direction: column;
 | 
				
			||||||
 | 
					      background-color: rgba(globals.$border-color, 0.01);
 | 
				
			||||||
 | 
					      border: 1px solid globals.$border-color;
 | 
				
			||||||
 | 
					      border-radius: 12px;
 | 
				
			||||||
 | 
					      gap: 4px;
 | 
				
			||||||
 | 
					      transition: background-color 0.2s ease;
 | 
				
			||||||
 | 
					      padding: 16px;
 | 
				
			||||||
 | 
					      position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &--active {
 | 
				
			||||||
 | 
					        background-color: rgba(globals.$border-color, 0.04);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &__header {
 | 
				
			||||||
 | 
					        display: flex;
 | 
				
			||||||
 | 
					        justify-content: space-between;
 | 
				
			||||||
 | 
					        align-items: center;
 | 
				
			||||||
 | 
					        flex-direction: row;
 | 
				
			||||||
 | 
					        gap: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__title {
 | 
				
			||||||
 | 
					          font-size: 18px;
 | 
				
			||||||
 | 
					          font-weight: 600;
 | 
				
			||||||
 | 
					          color: globals.$muted-color;
 | 
				
			||||||
 | 
					          text-transform: capitalize;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__colors {
 | 
				
			||||||
 | 
					          display: flex;
 | 
				
			||||||
 | 
					          flex-direction: row;
 | 
				
			||||||
 | 
					          gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          &__color {
 | 
				
			||||||
 | 
					            width: 16px;
 | 
				
			||||||
 | 
					            height: 16px;
 | 
				
			||||||
 | 
					            border-radius: 4px;
 | 
				
			||||||
 | 
					            border: 1px solid globals.$border-color;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &__author {
 | 
				
			||||||
 | 
					        font-size: 12px;
 | 
				
			||||||
 | 
					        color: globals.$body-color;
 | 
				
			||||||
 | 
					        font-weight: 400;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__name {
 | 
				
			||||||
 | 
					          font-weight: 600;
 | 
				
			||||||
 | 
					          color: rgba(globals.$muted-color, 0.8);
 | 
				
			||||||
 | 
					          margin-left: 4px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          &:hover {
 | 
				
			||||||
 | 
					            color: globals.$muted-color;
 | 
				
			||||||
 | 
					            cursor: pointer;
 | 
				
			||||||
 | 
					            text-decoration: underline;
 | 
				
			||||||
 | 
					            text-underline-offset: 2px;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &__actions {
 | 
				
			||||||
 | 
					        display: flex;
 | 
				
			||||||
 | 
					        flex-direction: row;
 | 
				
			||||||
 | 
					        position: absolute;
 | 
				
			||||||
 | 
					        bottom: 16px;
 | 
				
			||||||
 | 
					        left: 16px;
 | 
				
			||||||
 | 
					        right: 16px;
 | 
				
			||||||
 | 
					        gap: 8px;
 | 
				
			||||||
 | 
					        justify-content: space-between;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__left {
 | 
				
			||||||
 | 
					          display: flex;
 | 
				
			||||||
 | 
					          flex-direction: row;
 | 
				
			||||||
 | 
					          gap: 8px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &__right {
 | 
				
			||||||
 | 
					          display: flex;
 | 
				
			||||||
 | 
					          flex-direction: row;
 | 
				
			||||||
 | 
					          gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          Button {
 | 
				
			||||||
 | 
					            padding: 8px 11px;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__no-themes {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    min-height: 160px;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    padding: 40px 24px;
 | 
				
			||||||
 | 
					    background-color: rgba(globals.$border-color, 0.01);
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					    border: 1px dashed globals.$border-color;
 | 
				
			||||||
 | 
					    border-radius: 12px;
 | 
				
			||||||
 | 
					    gap: 12px;
 | 
				
			||||||
 | 
					    transition: background-color 0.2s ease;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:hover {
 | 
				
			||||||
 | 
					      background-color: rgba(globals.$border-color, 0.03);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__icon {
 | 
				
			||||||
 | 
					      svg {
 | 
				
			||||||
 | 
					        width: 32px;
 | 
				
			||||||
 | 
					        height: 32px;
 | 
				
			||||||
 | 
					        color: globals.$body-color;
 | 
				
			||||||
 | 
					        opacity: 0.7;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__text {
 | 
				
			||||||
 | 
					      text-align: center;
 | 
				
			||||||
 | 
					      max-width: 400px;
 | 
				
			||||||
 | 
					      font-size: 14.5px;
 | 
				
			||||||
 | 
					      line-height: 1.6;
 | 
				
			||||||
 | 
					      font-weight: 400;
 | 
				
			||||||
 | 
					      color: rgba(globals.$body-color, 0.85);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										102
									
								
								src/renderer/src/pages/settings/aparence/settings-appearance.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/renderer/src/pages/settings/aparence/settings-appearance.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,102 @@
 | 
				
			||||||
 | 
					import { useCallback, useContext, useEffect, useState } from "react";
 | 
				
			||||||
 | 
					import "./settings-appearance.scss";
 | 
				
			||||||
 | 
					import { ThemeActions, ThemeCard, ThemePlaceholder } from "./index";
 | 
				
			||||||
 | 
					import type { Theme } from "@types";
 | 
				
			||||||
 | 
					import { ImportThemeModal } from "./modals/import-theme-modal";
 | 
				
			||||||
 | 
					import { settingsContext } from "@renderer/context";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface SettingsAppearanceProps {
 | 
				
			||||||
 | 
					  appearance: {
 | 
				
			||||||
 | 
					    theme: string | null;
 | 
				
			||||||
 | 
					    authorId: string | null;
 | 
				
			||||||
 | 
					    authorName: string | null;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function SettingsAppearance({
 | 
				
			||||||
 | 
					  appearance,
 | 
				
			||||||
 | 
					}: Readonly<SettingsAppearanceProps>) {
 | 
				
			||||||
 | 
					  const [themes, setThemes] = useState<Theme[]>([]);
 | 
				
			||||||
 | 
					  const [isImportThemeModalVisible, setIsImportThemeModalVisible] =
 | 
				
			||||||
 | 
					    useState(false);
 | 
				
			||||||
 | 
					  const [importTheme, setImportTheme] = useState<{
 | 
				
			||||||
 | 
					    theme: string;
 | 
				
			||||||
 | 
					    authorId: string;
 | 
				
			||||||
 | 
					    authorName: string;
 | 
				
			||||||
 | 
					  } | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { clearTheme } = useContext(settingsContext);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const loadThemes = useCallback(async () => {
 | 
				
			||||||
 | 
					    const themesList = await window.electron.getAllCustomThemes();
 | 
				
			||||||
 | 
					    setThemes(themesList);
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    loadThemes();
 | 
				
			||||||
 | 
					  }, [loadThemes]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const unsubscribe = window.electron.onCssInjected(() => {
 | 
				
			||||||
 | 
					      loadThemes();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => unsubscribe();
 | 
				
			||||||
 | 
					  }, [loadThemes]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (appearance.theme && appearance.authorId && appearance.authorName) {
 | 
				
			||||||
 | 
					      setIsImportThemeModalVisible(true);
 | 
				
			||||||
 | 
					      setImportTheme({
 | 
				
			||||||
 | 
					        theme: appearance.theme,
 | 
				
			||||||
 | 
					        authorId: appearance.authorId,
 | 
				
			||||||
 | 
					        authorName: appearance.authorName,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [appearance.theme, appearance.authorId, appearance.authorName]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onThemeImported = useCallback(() => {
 | 
				
			||||||
 | 
					    setIsImportThemeModalVisible(false);
 | 
				
			||||||
 | 
					    loadThemes();
 | 
				
			||||||
 | 
					  }, [clearTheme, loadThemes]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="settings-appearance">
 | 
				
			||||||
 | 
					      <ThemeActions onListUpdated={loadThemes} themesCount={themes.length} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="settings-appearance__themes">
 | 
				
			||||||
 | 
					        {!themes.length ? (
 | 
				
			||||||
 | 
					          <ThemePlaceholder 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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {importTheme && (
 | 
				
			||||||
 | 
					        <ImportThemeModal
 | 
				
			||||||
 | 
					          visible={isImportThemeModalVisible}
 | 
				
			||||||
 | 
					          onClose={() => {
 | 
				
			||||||
 | 
					            setIsImportThemeModalVisible(false);
 | 
				
			||||||
 | 
					            clearTheme();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onThemeImported={onThemeImported}
 | 
				
			||||||
 | 
					          themeName={importTheme.theme}
 | 
				
			||||||
 | 
					          authorId={importTheme.authorId}
 | 
				
			||||||
 | 
					          authorName={importTheme.authorName}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -63,7 +63,7 @@ export function SettingsAccount() {
 | 
				
			||||||
    return () => {
 | 
					    return () => {
 | 
				
			||||||
      unsubscribe();
 | 
					      unsubscribe();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }, [fetchUserDetails, updateUserDetails, showSuccessToast]);
 | 
					  }, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const visibilityOptions = [
 | 
					  const visibilityOptions = [
 | 
				
			||||||
    { value: "PUBLIC", label: t("public") },
 | 
					    { value: "PUBLIC", label: t("public") },
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -86,12 +86,12 @@ export function SettingsRealDebrid() {
 | 
				
			||||||
      <CheckboxField
 | 
					      <CheckboxField
 | 
				
			||||||
        label={t("enable_real_debrid")}
 | 
					        label={t("enable_real_debrid")}
 | 
				
			||||||
        checked={form.useRealDebrid}
 | 
					        checked={form.useRealDebrid}
 | 
				
			||||||
        onChange={() =>
 | 
					        onChange={() => {
 | 
				
			||||||
          setForm((prev) => ({
 | 
					          setForm((prev) => ({
 | 
				
			||||||
            ...prev,
 | 
					            ...prev,
 | 
				
			||||||
            useRealDebrid: !form.useRealDebrid,
 | 
					            useRealDebrid: !form.useRealDebrid,
 | 
				
			||||||
          }))
 | 
					          }));
 | 
				
			||||||
        }
 | 
					        }}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {form.useRealDebrid && (
 | 
					      {form.useRealDebrid && (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,9 +10,10 @@ import {
 | 
				
			||||||
  SettingsContextProvider,
 | 
					  SettingsContextProvider,
 | 
				
			||||||
} from "@renderer/context";
 | 
					} from "@renderer/context";
 | 
				
			||||||
import { SettingsAccount } from "./settings-account";
 | 
					import { SettingsAccount } from "./settings-account";
 | 
				
			||||||
import { useUserDetails } from "@renderer/hooks";
 | 
					import { useFeature, useUserDetails } from "@renderer/hooks";
 | 
				
			||||||
import { useMemo } from "react";
 | 
					import { useMemo } from "react";
 | 
				
			||||||
import "./settings.scss";
 | 
					import "./settings.scss";
 | 
				
			||||||
 | 
					import { SettingsAppearance } from "./aparence/settings-appearance";
 | 
				
			||||||
import { SettingsTorbox } from "./settings-torbox";
 | 
					import { SettingsTorbox } from "./settings-torbox";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Settings() {
 | 
					export default function Settings() {
 | 
				
			||||||
| 
						 | 
					@ -20,20 +21,36 @@ export default function Settings() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { userDetails } = useUserDetails();
 | 
					  const { userDetails } = useUserDetails();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { isFeatureEnabled, Feature } = useFeature();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const isTorboxEnabled = isFeatureEnabled(Feature.Torbox);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const categories = useMemo(() => {
 | 
					  const categories = useMemo(() => {
 | 
				
			||||||
    const categories = [
 | 
					    const categories = [
 | 
				
			||||||
      { tabLabel: t("general"), contentTitle: t("general") },
 | 
					      { tabLabel: t("general"), contentTitle: t("general") },
 | 
				
			||||||
      { tabLabel: t("behavior"), contentTitle: t("behavior") },
 | 
					      { tabLabel: t("behavior"), contentTitle: t("behavior") },
 | 
				
			||||||
      { tabLabel: t("download_sources"), contentTitle: t("download_sources") },
 | 
					      { tabLabel: t("download_sources"), contentTitle: t("download_sources") },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        tabLabel: (
 | 
					        tabLabel: t("appearance"),
 | 
				
			||||||
          <>
 | 
					        contentTitle: t("appearance"),
 | 
				
			||||||
            <img src={torBoxLogo} alt="TorBox" style={{ width: 13 }} />
 | 
					 | 
				
			||||||
            Torbox
 | 
					 | 
				
			||||||
          </>
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        contentTitle: "TorBox",
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      ...(isTorboxEnabled
 | 
				
			||||||
 | 
					        ? [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              tabLabel: (
 | 
				
			||||||
 | 
					                <>
 | 
				
			||||||
 | 
					                  <img
 | 
				
			||||||
 | 
					                    src={torBoxLogo}
 | 
				
			||||||
 | 
					                    alt="TorBox"
 | 
				
			||||||
 | 
					                    style={{ width: 13, height: 13 }}
 | 
				
			||||||
 | 
					                  />{" "}
 | 
				
			||||||
 | 
					                  Torbox
 | 
				
			||||||
 | 
					                </>
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              contentTitle: "TorBox",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ]
 | 
				
			||||||
 | 
					        : []),
 | 
				
			||||||
      { tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
 | 
					      { tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -43,12 +60,12 @@ export default function Settings() {
 | 
				
			||||||
        { tabLabel: t("account"), contentTitle: t("account") },
 | 
					        { tabLabel: t("account"), contentTitle: t("account") },
 | 
				
			||||||
      ];
 | 
					      ];
 | 
				
			||||||
    return categories;
 | 
					    return categories;
 | 
				
			||||||
  }, [userDetails, t]);
 | 
					  }, [userDetails, t, isTorboxEnabled]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <SettingsContextProvider>
 | 
					    <SettingsContextProvider>
 | 
				
			||||||
      <SettingsContextConsumer>
 | 
					      <SettingsContextConsumer>
 | 
				
			||||||
        {({ currentCategoryIndex, setCurrentCategoryIndex }) => {
 | 
					        {({ currentCategoryIndex, setCurrentCategoryIndex, appearance }) => {
 | 
				
			||||||
          const renderCategory = () => {
 | 
					          const renderCategory = () => {
 | 
				
			||||||
            if (currentCategoryIndex === 0) {
 | 
					            if (currentCategoryIndex === 0) {
 | 
				
			||||||
              return <SettingsGeneral />;
 | 
					              return <SettingsGeneral />;
 | 
				
			||||||
| 
						 | 
					@ -63,10 +80,14 @@ export default function Settings() {
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (currentCategoryIndex === 3) {
 | 
					            if (currentCategoryIndex === 3) {
 | 
				
			||||||
              return <SettingsTorbox />;
 | 
					              return <SettingsAppearance appearance={appearance} />;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (currentCategoryIndex === 4) {
 | 
					            if (currentCategoryIndex === 4) {
 | 
				
			||||||
 | 
					              return <SettingsTorbox />;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (currentCategoryIndex === 5) {
 | 
				
			||||||
              return <SettingsRealDebrid />;
 | 
					              return <SettingsRealDebrid />;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -79,7 +100,7 @@ export default function Settings() {
 | 
				
			||||||
                <section className="settings__categories">
 | 
					                <section className="settings__categories">
 | 
				
			||||||
                  {categories.map((category, index) => (
 | 
					                  {categories.map((category, index) => (
 | 
				
			||||||
                    <Button
 | 
					                    <Button
 | 
				
			||||||
                      key={index}
 | 
					                      key={category.contentTitle}
 | 
				
			||||||
                      theme={
 | 
					                      theme={
 | 
				
			||||||
                        currentCategoryIndex === index ? "primary" : "outline"
 | 
					                        currentCategoryIndex === index ? "primary" : "outline"
 | 
				
			||||||
                      }
 | 
					                      }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -106,12 +106,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
 | 
				
			||||||
      <div className="user-friend-item__container">
 | 
					      <div className="user-friend-item__container">
 | 
				
			||||||
        <div className="user-friend-item__button">
 | 
					        <div className="user-friend-item__button">
 | 
				
			||||||
          <Avatar size={35} src={profileImageUrl} alt={displayName} />
 | 
					          <Avatar size={35} src={profileImageUrl} alt={displayName} />
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <div className="user-friend-item__button__content">
 | 
					          <div className="user-friend-item__button__content">
 | 
				
			||||||
            <p className="user-friend-item__display-name">{displayName}</p>
 | 
					            <p className="user-friend-item__display-name">{displayName}</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="user-friend-item__button__actions">
 | 
					        <div className="user-friend-item__button__actions">
 | 
				
			||||||
          {getRequestActions()}
 | 
					          {getRequestActions()}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
| 
						 | 
					@ -133,7 +131,6 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
 | 
				
			||||||
          {getRequestDescription()}
 | 
					          {getRequestDescription()}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="user-friend-item__button__actions">
 | 
					      <div className="user-friend-item__button__actions">
 | 
				
			||||||
        {getRequestActions()}
 | 
					        {getRequestActions()}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										77
									
								
								src/renderer/src/pages/theme-editor/theme-editor.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/renderer/src/pages/theme-editor/theme-editor.scss
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,77 @@
 | 
				
			||||||
 | 
					@use "../../scss/globals.scss";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.theme-editor {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__header {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    padding: calc(globals.$spacing-unit + 1px);
 | 
				
			||||||
 | 
					    background-color: globals.$dark-background-color;
 | 
				
			||||||
 | 
					    font-size: 8px;
 | 
				
			||||||
 | 
					    z-index: 50;
 | 
				
			||||||
 | 
					    -webkit-app-region: drag;
 | 
				
			||||||
 | 
					    gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--darwin {
 | 
				
			||||||
 | 
					      padding-top: calc(globals.$spacing-unit * 6);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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: flex-end;
 | 
				
			||||||
 | 
					      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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										98
									
								
								src/renderer/src/pages/theme-editor/theme-editor.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/renderer/src/pages/theme-editor/theme-editor.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,98 @@
 | 
				
			||||||
 | 
					import { useCallback, useEffect, useState } from "react";
 | 
				
			||||||
 | 
					import "./theme-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 } from "@primer/octicons-react";
 | 
				
			||||||
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					import cn from "classnames";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function ThemeEditor() {
 | 
				
			||||||
 | 
					  const [searchParams] = useSearchParams();
 | 
				
			||||||
 | 
					  const [theme, setTheme] = useState<Theme | null>(null);
 | 
				
			||||||
 | 
					  const [code, setCode] = useState("");
 | 
				
			||||||
 | 
					  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const themeId = searchParams.get("themeId");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { t } = useTranslation("settings");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (themeId) {
 | 
				
			||||||
 | 
					      window.electron.getCustomThemeById(themeId).then((loadedTheme) => {
 | 
				
			||||||
 | 
					        if (loadedTheme) {
 | 
				
			||||||
 | 
					          setTheme(loadedTheme);
 | 
				
			||||||
 | 
					          setCode(loadedTheme.code);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [themeId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSave = useCallback(async () => {
 | 
				
			||||||
 | 
					    if (theme) {
 | 
				
			||||||
 | 
					      await window.electron.updateCustomTheme(theme.id, code);
 | 
				
			||||||
 | 
					      setHasUnsavedChanges(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [code, theme]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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, handleSave, theme]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleEditorChange = (value: string | undefined) => {
 | 
				
			||||||
 | 
					    if (value !== undefined) {
 | 
				
			||||||
 | 
					      setCode(value);
 | 
				
			||||||
 | 
					      setHasUnsavedChanges(true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="theme-editor">
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        className={cn("theme-editor__header", {
 | 
				
			||||||
 | 
					          "theme-editor__header--darwin": window.electron.platform === "darwin",
 | 
				
			||||||
 | 
					        })}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <h1>{theme?.name}</h1>
 | 
				
			||||||
 | 
					        {hasUnsavedChanges && (
 | 
				
			||||||
 | 
					          <div className="theme-editor__header__status"></div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <Editor
 | 
				
			||||||
 | 
					        theme="vs-dark"
 | 
				
			||||||
 | 
					        defaultLanguage="css"
 | 
				
			||||||
 | 
					        value={code}
 | 
				
			||||||
 | 
					        onChange={handleEditorChange}
 | 
				
			||||||
 | 
					        options={{
 | 
				
			||||||
 | 
					          minimap: { enabled: false },
 | 
				
			||||||
 | 
					          fontSize: 14,
 | 
				
			||||||
 | 
					          lineNumbers: "on",
 | 
				
			||||||
 | 
					          wordWrap: "on",
 | 
				
			||||||
 | 
					          automaticLayout: true,
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="theme-editor__footer">
 | 
				
			||||||
 | 
					        <div className="theme-editor__footer-actions">
 | 
				
			||||||
 | 
					          <Button onClick={handleSave}>
 | 
				
			||||||
 | 
					            <CheckIcon />
 | 
				
			||||||
 | 
					            {t("editor_tab_save")}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -296,3 +296,4 @@ export * from "./download.types";
 | 
				
			||||||
export * from "./ludusavi.types";
 | 
					export * from "./ludusavi.types";
 | 
				
			||||||
export * from "./how-long-to-beat.types";
 | 
					export * from "./how-long-to-beat.types";
 | 
				
			||||||
export * from "./level.types";
 | 
					export * from "./level.types";
 | 
				
			||||||
 | 
					export * from "./theme.types";
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										10
									
								
								src/types/theme.types.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/types/theme.types.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					export interface Theme {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  author?: string;
 | 
				
			||||||
 | 
					  authorName?: string;
 | 
				
			||||||
 | 
					  isActive: boolean;
 | 
				
			||||||
 | 
					  code: string;
 | 
				
			||||||
 | 
					  createdAt: Date;
 | 
				
			||||||
 | 
					  updatedAt: Date;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										37
									
								
								yarn.lock
									
										
									
									
									
								
							
							
						
						
									
										37
									
								
								yarn.lock
									
										
									
									
									
								
							| 
						 | 
					@ -1790,6 +1790,20 @@
 | 
				
			||||||
    lodash "^4.17.15"
 | 
					    lodash "^4.17.15"
 | 
				
			||||||
    tmp-promise "^3.0.2"
 | 
					    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":
 | 
					"@napi-rs/nice-android-arm-eabi@1.0.1":
 | 
				
			||||||
  version "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"
 | 
					  resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
 | 
				
			||||||
| 
						 | 
					@ -5851,6 +5865,11 @@ get-symbol-description@^1.1.0:
 | 
				
			||||||
    es-errors "^1.3.0"
 | 
					    es-errors "^1.3.0"
 | 
				
			||||||
    get-intrinsic "^1.2.6"
 | 
					    get-intrinsic "^1.2.6"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					get-them-args@1.3.2:
 | 
				
			||||||
 | 
					  version "1.3.2"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/get-them-args/-/get-them-args-1.3.2.tgz#74a20ba8a4abece5ae199ad03f2bcc68fdfc9ba5"
 | 
				
			||||||
 | 
					  integrity sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
getopts@2.3.0:
 | 
					getopts@2.3.0:
 | 
				
			||||||
  version "2.3.0"
 | 
					  version "2.3.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
 | 
					  resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
 | 
				
			||||||
| 
						 | 
					@ -6876,6 +6895,14 @@ keyv@^4.0.0, keyv@^4.5.3:
 | 
				
			||||||
  dependencies:
 | 
					  dependencies:
 | 
				
			||||||
    json-buffer "3.0.1"
 | 
					    json-buffer "3.0.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					kill-port@^2.0.1:
 | 
				
			||||||
 | 
					  version "2.0.1"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/kill-port/-/kill-port-2.0.1.tgz#e5e18e2706b13d54320938be42cb7d40609b15cf"
 | 
				
			||||||
 | 
					  integrity sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==
 | 
				
			||||||
 | 
					  dependencies:
 | 
				
			||||||
 | 
					    get-them-args "1.3.2"
 | 
				
			||||||
 | 
					    shell-exec "1.0.2"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
knex@^3.1.0:
 | 
					knex@^3.1.0:
 | 
				
			||||||
  version "3.1.0"
 | 
					  version "3.1.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/knex/-/knex-3.1.0.tgz#b6ddd5b5ad26a6315234a5b09ec38dc4a370bd8c"
 | 
					  resolved "https://registry.yarnpkg.com/knex/-/knex-3.1.0.tgz#b6ddd5b5ad26a6315234a5b09ec38dc4a370bd8c"
 | 
				
			||||||
| 
						 | 
					@ -8599,6 +8626,11 @@ shebang-regex@^3.0.0:
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
 | 
					  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
 | 
				
			||||||
  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 | 
					  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					shell-exec@1.0.2:
 | 
				
			||||||
 | 
					  version "1.0.2"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/shell-exec/-/shell-exec-1.0.2.tgz#2e9361b0fde1d73f476c4b6671fa17785f696756"
 | 
				
			||||||
 | 
					  integrity sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
side-channel-list@^1.0.0:
 | 
					side-channel-list@^1.0.0:
 | 
				
			||||||
  version "1.0.0"
 | 
					  version "1.0.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
 | 
					  resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
 | 
				
			||||||
| 
						 | 
					@ -8776,6 +8808,11 @@ stat-mode@^1.0.0:
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
 | 
					  resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
 | 
				
			||||||
  integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==
 | 
					  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":
 | 
					"string-width-cjs@npm:string-width@^4.2.0":
 | 
				
			||||||
  version "4.2.3"
 | 
					  version "4.2.3"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
 | 
					  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue