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",
 | 
			
		||||
  "version": "3.1.5",
 | 
			
		||||
  "version": "3.2.0",
 | 
			
		||||
  "description": "Hydra",
 | 
			
		||||
  "main": "./out/main/index.js",
 | 
			
		||||
  "author": "Los Broxas",
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +36,7 @@
 | 
			
		|||
    "@electron-toolkit/utils": "^3.0.0",
 | 
			
		||||
    "@fontsource/noto-sans": "^5.1.0",
 | 
			
		||||
    "@hookform/resolvers": "^3.9.1",
 | 
			
		||||
    "@monaco-editor/react": "^4.6.0",
 | 
			
		||||
    "@primer/octicons-react": "^19.9.0",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.2",
 | 
			
		||||
    "@reduxjs/toolkit": "^2.2.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +60,7 @@
 | 
			
		|||
    "i18next-browser-languagedetector": "^7.2.1",
 | 
			
		||||
    "jsdom": "^24.0.0",
 | 
			
		||||
    "jsonwebtoken": "^9.0.2",
 | 
			
		||||
    "kill-port": "^2.0.1",
 | 
			
		||||
    "knex": "^3.1.0",
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
    "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_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_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": {
 | 
			
		||||
    "title": "Activate Hydra",
 | 
			
		||||
    "installation_id": "Installation ID:",
 | 
			
		||||
| 
						 | 
				
			
			@ -303,10 +304,35 @@
 | 
			
		|||
    "subscription_renew_cancelled": "Automatic renewal is disabled",
 | 
			
		||||
    "subscription_renews_on": "Your subscription renews on {{date}}",
 | 
			
		||||
    "bill_sent_until": "Your next bill will be sent until this day",
 | 
			
		||||
    "no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece.",
 | 
			
		||||
    "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",
 | 
			
		||||
    "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
 | 
			
		||||
    "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": {
 | 
			
		||||
    "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_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_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": {
 | 
			
		||||
    "title": "Ativação",
 | 
			
		||||
    "installation_id": "ID da instalação:",
 | 
			
		||||
| 
						 | 
				
			
			@ -293,10 +294,33 @@
 | 
			
		|||
    "subscription_renew_cancelled": "A renovação automática está desativada",
 | 
			
		||||
    "subscription_renews_on": "Sua assinatura renova dia {{date}}",
 | 
			
		||||
    "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",
 | 
			
		||||
    "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",
 | 
			
		||||
    "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": {
 | 
			
		||||
    "download_complete": "Download concluído",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { db, levelKeys } from "@main/level";
 | 
			
		||||
import type { Auth } from "@types";
 | 
			
		||||
import { Crypto } from "@main/services";
 | 
			
		||||
 | 
			
		||||
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
			
		||||
  const auth = await db.get<string, Auth>(levelKeys.auth, {
 | 
			
		||||
| 
						 | 
				
			
			@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  if (!auth) return null;
 | 
			
		||||
  const payload = jwt.decode(
 | 
			
		||||
    Crypto.decrypt(auth.accessToken)
 | 
			
		||||
  ) as jwt.JwtPayload;
 | 
			
		||||
  const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
 | 
			
		||||
 | 
			
		||||
  if (!payload) return null;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,6 +77,16 @@ import "./cloud-save/upload-save-game";
 | 
			
		|||
import "./cloud-save/delete-game-artifact";
 | 
			
		||||
import "./cloud-save/select-game-backup-path";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
ipcMain.handle("ping", () => "pong");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import { shell } from "electron";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { Crypto, HydraApi } from "@main/services";
 | 
			
		||||
import { HydraApi } from "@main/services";
 | 
			
		||||
import { db, levelKeys } from "@main/level";
 | 
			
		||||
import type { Auth } from "@types";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  const paymentToken = await HydraApi.post("/auth/payment", {
 | 
			
		||||
    refreshToken: Crypto.decrypt(auth.refreshToken),
 | 
			
		||||
    refreshToken: auth.refreshToken,
 | 
			
		||||
  }).then((response) => response.accessToken);
 | 
			
		||||
 | 
			
		||||
  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 { db, levelKeys } from "@main/level";
 | 
			
		||||
import { Crypto } from "@main/services";
 | 
			
		||||
import type { UserPreferences } from "@types";
 | 
			
		||||
 | 
			
		||||
const getUserPreferences = async () =>
 | 
			
		||||
  db
 | 
			
		||||
    .get<string, UserPreferences | null>(levelKeys.userPreferences, {
 | 
			
		||||
  db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
 | 
			
		||||
    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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,6 @@ import { registerEvent } from "../register-event";
 | 
			
		|||
import type { UserPreferences } from "@types";
 | 
			
		||||
import i18next from "i18next";
 | 
			
		||||
import { db, levelKeys } from "@main/level";
 | 
			
		||||
import { Crypto } from "@main/services";
 | 
			
		||||
import { patchUserProfile } from "../profile/update-profile";
 | 
			
		||||
 | 
			
		||||
const updateUserPreferences = async (
 | 
			
		||||
| 
						 | 
				
			
			@ -24,16 +23,6 @@ const updateUserPreferences = async (
 | 
			
		|||
    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) {
 | 
			
		||||
    preferences.downloadsPath = null;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ import updater from "electron-updater";
 | 
			
		|||
import i18n from "i18next";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import url from "node:url";
 | 
			
		||||
import kill from "kill-port";
 | 
			
		||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
 | 
			
		||||
import { logger, WindowManager } from "@main/services";
 | 
			
		||||
import resources from "@locales";
 | 
			
		||||
| 
						 | 
				
			
			@ -58,7 +59,7 @@ app.whenReady().then(async () => {
 | 
			
		|||
    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, {
 | 
			
		||||
    valueEncoding: "utf-8",
 | 
			
		||||
| 
						 | 
				
			
			@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
 | 
			
		|||
 | 
			
		||||
    if (url.host === "install-source") {
 | 
			
		||||
      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) {
 | 
			
		||||
    logger.error("Error handling deep link", uri, error);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,3 +3,4 @@ export * from "./games";
 | 
			
		|||
export * from "./game-shop-cache";
 | 
			
		||||
export * from "./game-achievements";
 | 
			
		||||
export * from "./keys";
 | 
			
		||||
export * from "./themes";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ export const levelKeys = {
 | 
			
		|||
  game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
 | 
			
		||||
  user: "user",
 | 
			
		||||
  auth: "auth",
 | 
			
		||||
  themes: "themes",
 | 
			
		||||
  gameShopCache: "gameShopCache",
 | 
			
		||||
  gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
 | 
			
		||||
    `${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 {
 | 
			
		||||
  Crypto,
 | 
			
		||||
  DownloadManager,
 | 
			
		||||
  logger,
 | 
			
		||||
  Ludusavi,
 | 
			
		||||
  startMainLoop,
 | 
			
		||||
} from "./services";
 | 
			
		||||
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
 | 
			
		||||
import { RealDebridClient } from "./services/download/real-debrid";
 | 
			
		||||
import { HydraApi } from "./services/hydra-api";
 | 
			
		||||
import { uploadGamesBatch } from "./services/library-sync";
 | 
			
		||||
| 
						 | 
				
			
			@ -38,13 +32,11 @@ export const loadState = async () => {
 | 
			
		|||
  Aria2.spawn();
 | 
			
		||||
 | 
			
		||||
  if (userPreferences?.realDebridApiToken) {
 | 
			
		||||
    RealDebridClient.authorize(
 | 
			
		||||
      Crypto.decrypt(userPreferences.realDebridApiToken)
 | 
			
		||||
    );
 | 
			
		||||
    RealDebridClient.authorize(userPreferences.realDebridApiToken);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (userPreferences?.torBoxApiToken) {
 | 
			
		||||
    TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
 | 
			
		||||
    TorBoxClient.authorize(userPreferences.torBoxApiToken);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Ludusavi.addManifestToLudusaviConfig();
 | 
			
		||||
| 
						 | 
				
			
			@ -121,9 +113,7 @@ const migrateFromSqlite = async () => {
 | 
			
		|||
          levelKeys.userPreferences,
 | 
			
		||||
          {
 | 
			
		||||
            ...rest,
 | 
			
		||||
            realDebridApiToken: realDebridApiToken
 | 
			
		||||
              ? Crypto.encrypt(realDebridApiToken)
 | 
			
		||||
              : null,
 | 
			
		||||
            realDebridApiToken,
 | 
			
		||||
            preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
 | 
			
		||||
            runAtStartup: rest.runAtStartup === 1,
 | 
			
		||||
            startMinimized: rest.startMinimized === 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -189,8 +179,8 @@ const migrateFromSqlite = async () => {
 | 
			
		|||
        await db.put<string, Auth>(
 | 
			
		||||
          levelKeys.auth,
 | 
			
		||||
          {
 | 
			
		||||
            accessToken: Crypto.encrypt(users[0].accessToken),
 | 
			
		||||
            refreshToken: Crypto.encrypt(users[0].refreshToken),
 | 
			
		||||
            accessToken: users[0].accessToken,
 | 
			
		||||
            refreshToken: users[0].refreshToken,
 | 
			
		||||
            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) {
 | 
			
		||||
    await PythonRPC.rpc.post("/action", {
 | 
			
		||||
    await PythonRPC.rpc
 | 
			
		||||
      .post("/action", {
 | 
			
		||||
        action: "cancel",
 | 
			
		||||
        game_id: downloadKey,
 | 
			
		||||
      })
 | 
			
		||||
      .catch((err) => {
 | 
			
		||||
        logger.error("Failed to cancel game download", err);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    WindowManager.mainWindow?.setProgressBar(-1);
 | 
			
		||||
 | 
			
		||||
    if (downloadKey === this.downloadingGameId) {
 | 
			
		||||
      WindowManager.mainWindow?.setProgressBar(-1);
 | 
			
		||||
      WindowManager.mainWindow?.webContents.send("on-download-progress", null);
 | 
			
		||||
      this.downloadingGameId = null;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
 | 
			
		|||
import { db } from "@main/level";
 | 
			
		||||
import { levelKeys } from "@main/level/sublevels";
 | 
			
		||||
import type { Auth, User } from "@types";
 | 
			
		||||
import { Crypto } from "./crypto";
 | 
			
		||||
 | 
			
		||||
interface HydraApiOptions {
 | 
			
		||||
  needsAuth?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -32,8 +31,9 @@ export class HydraApi {
 | 
			
		|||
  private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
 | 
			
		||||
  private static readonly ADD_LOG_INTERCEPTOR = true;
 | 
			
		||||
 | 
			
		||||
  private static readonly secondsToMilliseconds = (seconds: number) =>
 | 
			
		||||
    seconds * 1000;
 | 
			
		||||
  private static secondsToMilliseconds(seconds: number) {
 | 
			
		||||
    return seconds * 1000;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static userAuth: HydraApiUserAuth = {
 | 
			
		||||
    authToken: "",
 | 
			
		||||
| 
						 | 
				
			
			@ -81,8 +81,8 @@ export class HydraApi {
 | 
			
		|||
    db.put<string, Auth>(
 | 
			
		||||
      levelKeys.auth,
 | 
			
		||||
      {
 | 
			
		||||
        accessToken: Crypto.encrypt(accessToken),
 | 
			
		||||
        refreshToken: Crypto.encrypt(refreshToken),
 | 
			
		||||
        accessToken,
 | 
			
		||||
        refreshToken,
 | 
			
		||||
        tokenExpirationTimestamp,
 | 
			
		||||
      },
 | 
			
		||||
      { valueEncoding: "json" }
 | 
			
		||||
| 
						 | 
				
			
			@ -204,12 +204,8 @@ export class HydraApi {
 | 
			
		|||
    const user = result.at(1) as User | undefined;
 | 
			
		||||
 | 
			
		||||
    this.userAuth = {
 | 
			
		||||
      authToken: userAuth?.accessToken
 | 
			
		||||
        ? Crypto.decrypt(userAuth.accessToken)
 | 
			
		||||
        : "",
 | 
			
		||||
      refreshToken: userAuth?.refreshToken
 | 
			
		||||
        ? Crypto.decrypt(userAuth.refreshToken)
 | 
			
		||||
        : "",
 | 
			
		||||
      authToken: userAuth?.accessToken ?? "",
 | 
			
		||||
      refreshToken: userAuth?.refreshToken ?? "",
 | 
			
		||||
      expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
 | 
			
		||||
      subscription: user?.subscription
 | 
			
		||||
        ? { expiresAt: user.subscription?.expiresAt }
 | 
			
		||||
| 
						 | 
				
			
			@ -258,7 +254,7 @@ export class HydraApi {
 | 
			
		|||
          levelKeys.auth,
 | 
			
		||||
          {
 | 
			
		||||
            ...auth,
 | 
			
		||||
            accessToken: Crypto.encrypt(accessToken),
 | 
			
		||||
            accessToken,
 | 
			
		||||
            tokenExpirationTimestamp,
 | 
			
		||||
          },
 | 
			
		||||
          { valueEncoding: "json" }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
export * from "./crypto";
 | 
			
		||||
export * from "./logger";
 | 
			
		||||
export * from "./steam";
 | 
			
		||||
export * from "./steam-250";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,6 +24,8 @@ import { isStaging } from "@main/constants";
 | 
			
		|||
export class WindowManager {
 | 
			
		||||
  public static mainWindow: Electron.BrowserWindow | null = null;
 | 
			
		||||
 | 
			
		||||
  private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
 | 
			
		||||
 | 
			
		||||
  private static loadMainWindowURL(hash = "") {
 | 
			
		||||
    // HMR for renderer base on electron-vite cli.
 | 
			
		||||
    // 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) {
 | 
			
		||||
    if (!this.mainWindow) this.createMainWindow();
 | 
			
		||||
    this.loadMainWindowURL(hash);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ import type {
 | 
			
		|||
  CatalogueSearchPayload,
 | 
			
		||||
  SeedingStatus,
 | 
			
		||||
  GameAchievement,
 | 
			
		||||
  Theme,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import type { AuthPage, CatalogueCategory } from "@shared";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
| 
						 | 
				
			
			@ -347,4 +348,30 @@ contextBridge.exposeInMainWorld("electron", {
 | 
			
		|||
  /* Notifications */
 | 
			
		||||
  publishNewRepacksNotification: (newRepacksCount: number) =>
 | 
			
		||||
    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 { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
 | 
			
		||||
 | 
			
		||||
import { injectCustomCss } from "./helpers";
 | 
			
		||||
import "./app.scss";
 | 
			
		||||
 | 
			
		||||
export interface AppProps {
 | 
			
		||||
| 
						 | 
				
			
			@ -233,6 +234,17 @@ export function App() {
 | 
			
		|||
    downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
 | 
			
		||||
  }, [updateRepacks]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const loadAndApplyTheme = async () => {
 | 
			
		||||
      const activeTheme = await window.electron.getActiveCustomTheme();
 | 
			
		||||
 | 
			
		||||
      if (activeTheme?.code) {
 | 
			
		||||
        injectCustomCss(activeTheme.code);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    loadAndApplyTheme();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const playAudio = useCallback(() => {
 | 
			
		||||
    const audio = new Audio(achievementSound);
 | 
			
		||||
    audio.volume = 0.2;
 | 
			
		||||
| 
						 | 
				
			
			@ -249,6 +261,16 @@ export function App() {
 | 
			
		|||
    };
 | 
			
		||||
  }, [playAudio]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const unsubscribe = window.electron.onCssInjected((cssString) => {
 | 
			
		||||
      if (cssString) {
 | 
			
		||||
        injectCustomCss(cssString);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return () => unsubscribe();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleToastClose = useCallback(() => {
 | 
			
		||||
    dispatch(closeToast());
 | 
			
		||||
  }, [dispatch]);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -167,6 +167,10 @@ export function Sidebar() {
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const favoriteGames = useMemo(() => {
 | 
			
		||||
    return sortedLibrary.filter((game) => game.favorite);
 | 
			
		||||
  }, [sortedLibrary]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <aside
 | 
			
		||||
      ref={sidebarRef}
 | 
			
		||||
| 
						 | 
				
			
			@ -206,13 +210,12 @@ export function Sidebar() {
 | 
			
		|||
            </ul>
 | 
			
		||||
          </section>
 | 
			
		||||
 | 
			
		||||
          {favoriteGames.length > 0 && (
 | 
			
		||||
            <section className="sidebar__section">
 | 
			
		||||
              <small className="sidebar__section-title">{t("favorites")}</small>
 | 
			
		||||
 | 
			
		||||
              <ul className="sidebar__menu">
 | 
			
		||||
              {sortedLibrary
 | 
			
		||||
                .filter((game) => game.favorite)
 | 
			
		||||
                .map((game) => (
 | 
			
		||||
                {favoriteGames.map((game) => (
 | 
			
		||||
                  <SidebarGameItem
 | 
			
		||||
                    key={game.id}
 | 
			
		||||
                    game={game}
 | 
			
		||||
| 
						 | 
				
			
			@ -222,6 +225,7 @@ export function Sidebar() {
 | 
			
		|||
                ))}
 | 
			
		||||
              </ul>
 | 
			
		||||
            </section>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <section className="sidebar__section">
 | 
			
		||||
            <small className="sidebar__section-title">{t("my_library")}</small>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,8 +7,9 @@
 | 
			
		|||
  background-color: globals.$dark-background-color;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  border: solid 1px globals.$border-color;
 | 
			
		||||
  right: 16px;
 | 
			
		||||
  bottom: 26px + globals.$spacing-unit;
 | 
			
		||||
  right: calc(globals.$spacing-unit * 2);
 | 
			
		||||
  // 28px is the height of the bottom panel
 | 
			
		||||
  bottom: calc(28px + globals.$spacing-unit * 2);
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import { Downloader } from "@shared";
 | 
			
		||||
 | 
			
		||||
export const VERSION_CODENAME = "Spectre";
 | 
			
		||||
export const VERSION_CODENAME = "Polychrome";
 | 
			
		||||
 | 
			
		||||
export const DOWNLOADER_NAME = {
 | 
			
		||||
  [Downloader.RealDebrid]: "Real-Debrid",
 | 
			
		||||
| 
						 | 
				
			
			@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
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>;
 | 
			
		||||
  setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
 | 
			
		||||
  clearSourceUrl: () => void;
 | 
			
		||||
  clearTheme: () => void;
 | 
			
		||||
  sourceUrl: string | null;
 | 
			
		||||
  currentCategoryIndex: number;
 | 
			
		||||
  blockedUsers: UserBlocks["blocks"];
 | 
			
		||||
  fetchBlockedUsers: () => Promise<void>;
 | 
			
		||||
  appearance: {
 | 
			
		||||
    theme: string | null;
 | 
			
		||||
    authorId: string | null;
 | 
			
		||||
    authorName: string | null;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const settingsContext = createContext<SettingsContext>({
 | 
			
		||||
  updateUserPreferences: async () => {},
 | 
			
		||||
  setCurrentCategoryIndex: () => {},
 | 
			
		||||
  clearSourceUrl: () => {},
 | 
			
		||||
  clearTheme: () => {},
 | 
			
		||||
  sourceUrl: null,
 | 
			
		||||
  currentCategoryIndex: 0,
 | 
			
		||||
  blockedUsers: [],
 | 
			
		||||
  fetchBlockedUsers: async () => {},
 | 
			
		||||
  appearance: {
 | 
			
		||||
    theme: null,
 | 
			
		||||
    authorId: null,
 | 
			
		||||
    authorName: null,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const { Provider } = settingsContext;
 | 
			
		||||
| 
						 | 
				
			
			@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
 | 
			
		|||
 | 
			
		||||
export function SettingsContextProvider({
 | 
			
		||||
  children,
 | 
			
		||||
}: SettingsContextProviderProps) {
 | 
			
		||||
}: Readonly<SettingsContextProviderProps>) {
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
  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 [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
 | 
			
		||||
 | 
			
		||||
  const [searchParams] = useSearchParams();
 | 
			
		||||
  const defaultSourceUrl = searchParams.get("urls");
 | 
			
		||||
  const defaultAppearanceTheme = searchParams.get("theme");
 | 
			
		||||
  const defaultAppearanceAuthorId = searchParams.get("authorId");
 | 
			
		||||
  const defaultAppearanceAuthorName = searchParams.get("authorName");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (sourceUrl) setCurrentCategoryIndex(2);
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +77,36 @@ export function SettingsContextProvider({
 | 
			
		|||
    }
 | 
			
		||||
  }, [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 blockedUsers = await window.electron.getBlockedUsers(12, 0);
 | 
			
		||||
    setBlockedUsers(blockedUsers.blocks);
 | 
			
		||||
| 
						 | 
				
			
			@ -79,9 +132,11 @@ export function SettingsContextProvider({
 | 
			
		|||
        setCurrentCategoryIndex,
 | 
			
		||||
        clearSourceUrl,
 | 
			
		||||
        fetchBlockedUsers,
 | 
			
		||||
        clearTheme,
 | 
			
		||||
        currentCategoryIndex,
 | 
			
		||||
        sourceUrl,
 | 
			
		||||
        blockedUsers,
 | 
			
		||||
        appearance,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										18
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -29,6 +29,7 @@ import type {
 | 
			
		|||
  LibraryGame,
 | 
			
		||||
  GameRunning,
 | 
			
		||||
  TorBoxUser,
 | 
			
		||||
  Theme,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
import type disk from "diskusage";
 | 
			
		||||
| 
						 | 
				
			
			@ -279,6 +280,23 @@ declare global {
 | 
			
		|||
 | 
			
		||||
    /* Notifications */
 | 
			
		||||
    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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ export const toastSlice = createSlice({
 | 
			
		|||
      state.title = action.payload.title;
 | 
			
		||||
      state.message = action.payload.message;
 | 
			
		||||
      state.type = action.payload.type;
 | 
			
		||||
      state.duration = action.payload.duration ?? 5000;
 | 
			
		||||
      state.duration = action.payload.duration ?? 2000;
 | 
			
		||||
      state.visible = true;
 | 
			
		||||
    },
 | 
			
		||||
    closeToast: (state) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import type { GameShop } from "@types";
 | 
			
		||||
 | 
			
		||||
import Color from "color";
 | 
			
		||||
import { THEME_WEB_STORE_URL } from "./constants";
 | 
			
		||||
 | 
			
		||||
export const formatDownloadProgress = (
 | 
			
		||||
  progress?: number,
 | 
			
		||||
| 
						 | 
				
			
			@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
 | 
			
		|||
 | 
			
		||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
 | 
			
		||||
  new Color(color).darken(amount).alpha(alpha).toString();
 | 
			
		||||
 | 
			
		||||
export const injectCustomCss = (css: string) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const currentCustomCss = document.getElementById("custom-css");
 | 
			
		||||
    if (currentCustomCss) {
 | 
			
		||||
      currentCustomCss.remove();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 {
 | 
			
		||||
  CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
 | 
			
		||||
  Torbox = "TORBOX",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useFeature() {
 | 
			
		||||
  const [features, setFeatures] = useState<string[] | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.electron.getFeatures().then((features) => {
 | 
			
		||||
      localStorage.setItem("features", JSON.stringify(features || []));
 | 
			
		||||
      setFeatures(features || []);
 | 
			
		||||
    });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,6 +33,9 @@ const Profile = React.lazy(() => import("./pages/profile/profile"));
 | 
			
		|||
const Achievements = React.lazy(
 | 
			
		||||
  () => import("./pages/achievements/achievements")
 | 
			
		||||
);
 | 
			
		||||
const ThemeEditor = React.lazy(
 | 
			
		||||
  () => import("./pages/theme-editor/theme-editor")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
import * as Sentry from "@sentry/react";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -105,6 +108,11 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
 | 
			
		|||
              element={<SuspenseWrapper Component={Achievements} />}
 | 
			
		||||
            />
 | 
			
		||||
          </Route>
 | 
			
		||||
 | 
			
		||||
          <Route
 | 
			
		||||
            path="/theme-editor"
 | 
			
		||||
            element={<SuspenseWrapper Component={ThemeEditor} />}
 | 
			
		||||
          />
 | 
			
		||||
        </Routes>
 | 
			
		||||
      </HashRouter>
 | 
			
		||||
    </Provider>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ export function DeleteGameModal({
 | 
			
		|||
  onClose,
 | 
			
		||||
  visible,
 | 
			
		||||
  deleteGame,
 | 
			
		||||
}: DeleteGameModalProps) {
 | 
			
		||||
}: Readonly<DeleteGameModalProps>) {
 | 
			
		||||
  const { t } = useTranslation("downloads");
 | 
			
		||||
 | 
			
		||||
  const handleDeleteGame = () => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,6 +32,7 @@ import {
 | 
			
		|||
} from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
 | 
			
		||||
 | 
			
		||||
export interface DownloadGroupProps {
 | 
			
		||||
  library: LibraryGame[];
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,10 +7,11 @@ import {
 | 
			
		|||
  PlusCircleIcon,
 | 
			
		||||
} from "@primer/octicons-react";
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
import { useDownload, useLibrary } from "@renderer/hooks";
 | 
			
		||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
 | 
			
		||||
import { useContext, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { gameDetailsContext } from "@renderer/context";
 | 
			
		||||
 | 
			
		||||
import "./hero-panel-actions.scss";
 | 
			
		||||
 | 
			
		||||
export function HeroPanelActions() {
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +40,8 @@ export function HeroPanelActions() {
 | 
			
		|||
 | 
			
		||||
  const { updateLibrary } = useLibrary();
 | 
			
		||||
 | 
			
		||||
  const { showSuccessToast } = useToast();
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const addGameToLibrary = async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -54,25 +57,24 @@ export function HeroPanelActions() {
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const addGameToFavorites = async () => {
 | 
			
		||||
  const toggleGameFavorite = async () => {
 | 
			
		||||
    setToggleLibraryGameDisabled(true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (!objectId) throw new Error("objectId is required");
 | 
			
		||||
      await window.electron.addGameToFavorites(shop, objectId);
 | 
			
		||||
      updateLibrary();
 | 
			
		||||
      updateGame();
 | 
			
		||||
    } finally {
 | 
			
		||||
      setToggleLibraryGameDisabled(false);
 | 
			
		||||
      if (game?.favorite && objectId) {
 | 
			
		||||
        await window.electron
 | 
			
		||||
          .removeGameFromFavorites(shop, objectId)
 | 
			
		||||
          .then(() => {
 | 
			
		||||
            showSuccessToast(t("game_removed_from_favorites"));
 | 
			
		||||
          });
 | 
			
		||||
      } else {
 | 
			
		||||
        if (!objectId) return;
 | 
			
		||||
 | 
			
		||||
        await window.electron.addGameToFavorites(shop, objectId).then(() => {
 | 
			
		||||
          showSuccessToast(t("game_added_to_favorites"));
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const removeGameFromFavorites = async () => {
 | 
			
		||||
    setToggleLibraryGameDisabled(true);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (!objectId) throw new Error("objectId is required");
 | 
			
		||||
      await window.electron.removeGameFromFavorites(shop, objectId);
 | 
			
		||||
      updateLibrary();
 | 
			
		||||
      updateGame();
 | 
			
		||||
    } finally {
 | 
			
		||||
| 
						 | 
				
			
			@ -188,7 +190,7 @@ export function HeroPanelActions() {
 | 
			
		|||
        {gameActionButton()}
 | 
			
		||||
        <div className="hero-panel-actions__separator" />
 | 
			
		||||
        <Button
 | 
			
		||||
          onClick={game.favorite ? removeGameFromFavorites : addGameToFavorites}
 | 
			
		||||
          onClick={toggleGameFavorite}
 | 
			
		||||
          theme="outline"
 | 
			
		||||
          disabled={deleting}
 | 
			
		||||
          className="hero-panel-actions__action"
 | 
			
		||||
| 
						 | 
				
			
			@ -196,7 +198,6 @@ export function HeroPanelActions() {
 | 
			
		|||
          {game.favorite ? <HeartFillIcon /> : <HeartIcon />}
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          onClick={() => setShowGameOptionsModal(true)}
 | 
			
		||||
          theme="outline"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,10 +44,9 @@ export function DownloadSettingsModal({
 | 
			
		|||
    (state) => state.userPreferences.value
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const getDiskFreeSpace = (path: string) => {
 | 
			
		||||
    window.electron.getDiskFreeSpace(path).then((result) => {
 | 
			
		||||
  const getDiskFreeSpace = async (path: string) => {
 | 
			
		||||
    const result = await window.electron.getDiskFreeSpace(path);
 | 
			
		||||
    setDiskFreeSpace(result.free);
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const checkFolderWritePermission = useCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -100,6 +99,7 @@ export function DownloadSettingsModal({
 | 
			
		|||
    userPreferences?.downloadsPath,
 | 
			
		||||
    downloaders,
 | 
			
		||||
    userPreferences?.realDebridApiToken,
 | 
			
		||||
    userPreferences?.torBoxApiToken,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  const handleChooseDownloadsPath = async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -155,19 +155,21 @@ export function DownloadSettingsModal({
 | 
			
		|||
          <span>{t("downloader")}</span>
 | 
			
		||||
 | 
			
		||||
          <div className="download-settings-modal__downloaders">
 | 
			
		||||
            {downloaders.map((downloader) => (
 | 
			
		||||
            {downloaders.map((downloader) => {
 | 
			
		||||
              const shouldDisableButton =
 | 
			
		||||
                (downloader === Downloader.RealDebrid &&
 | 
			
		||||
                  !userPreferences?.realDebridApiToken) ||
 | 
			
		||||
                (downloader === Downloader.TorBox &&
 | 
			
		||||
                  !userPreferences?.torBoxApiToken);
 | 
			
		||||
 | 
			
		||||
              return (
 | 
			
		||||
                <Button
 | 
			
		||||
                  key={downloader}
 | 
			
		||||
                  className="download-settings-modal__downloader-option"
 | 
			
		||||
                  theme={
 | 
			
		||||
                    selectedDownloader === downloader ? "primary" : "outline"
 | 
			
		||||
                  }
 | 
			
		||||
                disabled={
 | 
			
		||||
                  (downloader === Downloader.RealDebrid &&
 | 
			
		||||
                    !userPreferences?.realDebridApiToken) ||
 | 
			
		||||
                  (downloader === Downloader.TorBox &&
 | 
			
		||||
                    !userPreferences?.torBoxApiToken)
 | 
			
		||||
                }
 | 
			
		||||
                  disabled={shouldDisableButton}
 | 
			
		||||
                  onClick={() => setSelectedDownloader(downloader)}
 | 
			
		||||
                >
 | 
			
		||||
                  {selectedDownloader === downloader && (
 | 
			
		||||
| 
						 | 
				
			
			@ -175,7 +177,8 @@ export function DownloadSettingsModal({
 | 
			
		|||
                  )}
 | 
			
		||||
                  {DOWNLOADER_NAME[downloader]}
 | 
			
		||||
                </Button>
 | 
			
		||||
            ))}
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,7 +74,10 @@ export function ReportProfile() {
 | 
			
		|||
        title={t("report_profile")}
 | 
			
		||||
        clickOutsideToClose={false}
 | 
			
		||||
      >
 | 
			
		||||
        <form className="report-profile__form">
 | 
			
		||||
        <form
 | 
			
		||||
          onSubmit={handleSubmit(onSubmit)}
 | 
			
		||||
          className="report-profile__form"
 | 
			
		||||
        >
 | 
			
		||||
          <Controller
 | 
			
		||||
            control={control}
 | 
			
		||||
            name="reason"
 | 
			
		||||
| 
						 | 
				
			
			@ -101,12 +104,7 @@ export function ReportProfile() {
 | 
			
		|||
            error={errors.description?.message}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <Button
 | 
			
		||||
            className="report-profile__submit"
 | 
			
		||||
            onClick={handleSubmit(onSubmit)}
 | 
			
		||||
          >
 | 
			
		||||
            {t("report")}
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button className="report-profile__submit">{t("report")}</Button>
 | 
			
		||||
        </form>
 | 
			
		||||
      </Modal>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
 | 
			
		|||
  visible,
 | 
			
		||||
  onClose,
 | 
			
		||||
  onAddDownloadSource,
 | 
			
		||||
}: AddDownloadSourceModalProps) {
 | 
			
		||||
}: Readonly<AddDownloadSourceModalProps>) {
 | 
			
		||||
  const [url, setUrl] = useState("");
 | 
			
		||||
  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 () => {
 | 
			
		||||
      unsubscribe();
 | 
			
		||||
    };
 | 
			
		||||
  }, [fetchUserDetails, updateUserDetails, showSuccessToast]);
 | 
			
		||||
  }, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
 | 
			
		||||
 | 
			
		||||
  const visibilityOptions = [
 | 
			
		||||
    { value: "PUBLIC", label: t("public") },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -86,12 +86,12 @@ export function SettingsRealDebrid() {
 | 
			
		|||
      <CheckboxField
 | 
			
		||||
        label={t("enable_real_debrid")}
 | 
			
		||||
        checked={form.useRealDebrid}
 | 
			
		||||
        onChange={() =>
 | 
			
		||||
        onChange={() => {
 | 
			
		||||
          setForm((prev) => ({
 | 
			
		||||
            ...prev,
 | 
			
		||||
            useRealDebrid: !form.useRealDebrid,
 | 
			
		||||
          }))
 | 
			
		||||
        }
 | 
			
		||||
          }));
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {form.useRealDebrid && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,9 +10,10 @@ import {
 | 
			
		|||
  SettingsContextProvider,
 | 
			
		||||
} from "@renderer/context";
 | 
			
		||||
import { SettingsAccount } from "./settings-account";
 | 
			
		||||
import { useUserDetails } from "@renderer/hooks";
 | 
			
		||||
import { useFeature, useUserDetails } from "@renderer/hooks";
 | 
			
		||||
import { useMemo } from "react";
 | 
			
		||||
import "./settings.scss";
 | 
			
		||||
import { SettingsAppearance } from "./aparence/settings-appearance";
 | 
			
		||||
import { SettingsTorbox } from "./settings-torbox";
 | 
			
		||||
 | 
			
		||||
export default function Settings() {
 | 
			
		||||
| 
						 | 
				
			
			@ -20,20 +21,36 @@ export default function Settings() {
 | 
			
		|||
 | 
			
		||||
  const { userDetails } = useUserDetails();
 | 
			
		||||
 | 
			
		||||
  const { isFeatureEnabled, Feature } = useFeature();
 | 
			
		||||
 | 
			
		||||
  const isTorboxEnabled = isFeatureEnabled(Feature.Torbox);
 | 
			
		||||
 | 
			
		||||
  const categories = useMemo(() => {
 | 
			
		||||
    const categories = [
 | 
			
		||||
      { tabLabel: t("general"), contentTitle: t("general") },
 | 
			
		||||
      { tabLabel: t("behavior"), contentTitle: t("behavior") },
 | 
			
		||||
      { tabLabel: t("download_sources"), contentTitle: t("download_sources") },
 | 
			
		||||
      {
 | 
			
		||||
        tabLabel: t("appearance"),
 | 
			
		||||
        contentTitle: t("appearance"),
 | 
			
		||||
      },
 | 
			
		||||
      ...(isTorboxEnabled
 | 
			
		||||
        ? [
 | 
			
		||||
            {
 | 
			
		||||
              tabLabel: (
 | 
			
		||||
                <>
 | 
			
		||||
            <img src={torBoxLogo} alt="TorBox" style={{ width: 13 }} />
 | 
			
		||||
                  <img
 | 
			
		||||
                    src={torBoxLogo}
 | 
			
		||||
                    alt="TorBox"
 | 
			
		||||
                    style={{ width: 13, height: 13 }}
 | 
			
		||||
                  />{" "}
 | 
			
		||||
                  Torbox
 | 
			
		||||
                </>
 | 
			
		||||
              ),
 | 
			
		||||
              contentTitle: "TorBox",
 | 
			
		||||
            },
 | 
			
		||||
          ]
 | 
			
		||||
        : []),
 | 
			
		||||
      { tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -43,12 +60,12 @@ export default function Settings() {
 | 
			
		|||
        { tabLabel: t("account"), contentTitle: t("account") },
 | 
			
		||||
      ];
 | 
			
		||||
    return categories;
 | 
			
		||||
  }, [userDetails, t]);
 | 
			
		||||
  }, [userDetails, t, isTorboxEnabled]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingsContextProvider>
 | 
			
		||||
      <SettingsContextConsumer>
 | 
			
		||||
        {({ currentCategoryIndex, setCurrentCategoryIndex }) => {
 | 
			
		||||
        {({ currentCategoryIndex, setCurrentCategoryIndex, appearance }) => {
 | 
			
		||||
          const renderCategory = () => {
 | 
			
		||||
            if (currentCategoryIndex === 0) {
 | 
			
		||||
              return <SettingsGeneral />;
 | 
			
		||||
| 
						 | 
				
			
			@ -63,10 +80,14 @@ export default function Settings() {
 | 
			
		|||
            }
 | 
			
		||||
 | 
			
		||||
            if (currentCategoryIndex === 3) {
 | 
			
		||||
              return <SettingsTorbox />;
 | 
			
		||||
              return <SettingsAppearance appearance={appearance} />;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (currentCategoryIndex === 4) {
 | 
			
		||||
              return <SettingsTorbox />;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (currentCategoryIndex === 5) {
 | 
			
		||||
              return <SettingsRealDebrid />;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +100,7 @@ export default function Settings() {
 | 
			
		|||
                <section className="settings__categories">
 | 
			
		||||
                  {categories.map((category, index) => (
 | 
			
		||||
                    <Button
 | 
			
		||||
                      key={index}
 | 
			
		||||
                      key={category.contentTitle}
 | 
			
		||||
                      theme={
 | 
			
		||||
                        currentCategoryIndex === index ? "primary" : "outline"
 | 
			
		||||
                      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -106,12 +106,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
 | 
			
		|||
      <div className="user-friend-item__container">
 | 
			
		||||
        <div className="user-friend-item__button">
 | 
			
		||||
          <Avatar size={35} src={profileImageUrl} alt={displayName} />
 | 
			
		||||
 | 
			
		||||
          <div className="user-friend-item__button__content">
 | 
			
		||||
            <p className="user-friend-item__display-name">{displayName}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="user-friend-item__button__actions">
 | 
			
		||||
          {getRequestActions()}
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -133,7 +131,6 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
 | 
			
		|||
          {getRequestDescription()}
 | 
			
		||||
        </div>
 | 
			
		||||
      </button>
 | 
			
		||||
 | 
			
		||||
      <div className="user-friend-item__button__actions">
 | 
			
		||||
        {getRequestActions()}
 | 
			
		||||
      </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 "./how-long-to-beat.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"
 | 
			
		||||
    tmp-promise "^3.0.2"
 | 
			
		||||
 | 
			
		||||
"@monaco-editor/loader@^1.4.0":
 | 
			
		||||
  version "1.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558"
 | 
			
		||||
  integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    state-local "^1.0.6"
 | 
			
		||||
 | 
			
		||||
"@monaco-editor/react@^4.6.0":
 | 
			
		||||
  version "4.6.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119"
 | 
			
		||||
  integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@monaco-editor/loader" "^1.4.0"
 | 
			
		||||
 | 
			
		||||
"@napi-rs/nice-android-arm-eabi@1.0.1":
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
 | 
			
		||||
| 
						 | 
				
			
			@ -5851,6 +5865,11 @@ get-symbol-description@^1.1.0:
 | 
			
		|||
    es-errors "^1.3.0"
 | 
			
		||||
    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:
 | 
			
		||||
  version "2.3.0"
 | 
			
		||||
  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:
 | 
			
		||||
    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:
 | 
			
		||||
  version "3.1.0"
 | 
			
		||||
  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"
 | 
			
		||||
  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:
 | 
			
		||||
  version "1.0.0"
 | 
			
		||||
  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"
 | 
			
		||||
  integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==
 | 
			
		||||
 | 
			
		||||
state-local@^1.0.6:
 | 
			
		||||
  version "1.0.7"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
 | 
			
		||||
  integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
 | 
			
		||||
 | 
			
		||||
"string-width-cjs@npm:string-width@^4.2.0":
 | 
			
		||||
  version "4.2.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue