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