Merge pull request #1409 from hydralauncher/feature/custom-themes

Feature/custom themes
This commit is contained in:
Chubby Granny Chaser 2025-02-16 22:23:05 +00:00 committed by GitHub
commit e0dc87a55e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 1856 additions and 194 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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");

View file

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

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View file

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

View file

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

View file

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

View file

@ -3,3 +3,4 @@ export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";
export * from "./themes";

View file

@ -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}`,

View 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",
});

View file

@ -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,
},
{

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
export * from "./crypto";
export * from "./logger";
export * from "./steam";
export * from "./steam-250";

View file

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

View file

@ -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),
});

View file

@ -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]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

@ -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();
}
};

View file

@ -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);
};

View file

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

View file

@ -12,7 +12,7 @@ export function DeleteGameModal({
onClose,
visible,
deleteGame,
}: DeleteGameModalProps) {
}: Readonly<DeleteGameModalProps>) {
const { t } = useTranslation("downloads");
const handleDeleteGame = () => {

View file

@ -32,6 +32,7 @@ import {
} from "@primer/octicons-react";
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);

View file

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

View file

@ -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>
</>
);
};

View file

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

View file

@ -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>
</>
);
};

View file

@ -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);
}
}

View file

@ -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>
</>
);
};

View 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";

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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;
}

View file

@ -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);
}
}
}

View 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>
);
}

View file

@ -63,7 +63,7 @@ export function SettingsAccount() {
return () => {
unsubscribe();
};
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
}, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },

View file

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

View file

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

View file

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

View 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;
}
}
}

View 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>
);
}

View file

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

@ -0,0 +1,10 @@
export interface Theme {
id: string;
name: string;
author?: string;
authorName?: string;
isActive: boolean;
code: string;
createdAt: Date;
updatedAt: Date;
}

View file

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