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