mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
feat: adding review styling
This commit is contained in:
@@ -76,7 +76,18 @@
|
||||
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
|
||||
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
|
||||
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero"
|
||||
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero",
|
||||
"install_decky_plugin": "Install Decky Plugin",
|
||||
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||
"install_decky_plugin_title": "Install Hydra Decky Plugin",
|
||||
"install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?",
|
||||
"update_decky_plugin_title": "Update Hydra Decky Plugin",
|
||||
"update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?",
|
||||
"decky_plugin_installed": "Decky plugin v{{version}} installed successfully",
|
||||
"decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}",
|
||||
"decky_plugin_installation_error": "Error installing Decky plugin: {{error}}",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
@@ -227,7 +238,7 @@
|
||||
"rating_neutral": "Neutral",
|
||||
"rating_positive": "Positive",
|
||||
"rating_very_positive": "Very Positive",
|
||||
"submit_review": "Submit Review",
|
||||
"submit_review": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"review_submitted_successfully": "Review submitted successfully!",
|
||||
"review_submission_failed": "Failed to submit review. Please try again.",
|
||||
@@ -486,6 +497,8 @@
|
||||
"delete_theme_description": "This will delete the theme {{theme}}",
|
||||
"cancel": "Cancel",
|
||||
"appearance": "Appearance",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.",
|
||||
"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",
|
||||
|
||||
@@ -27,21 +27,67 @@
|
||||
"friends": "Amigos",
|
||||
"need_help": "Precisa de ajuda?",
|
||||
"favorites": "Favoritos",
|
||||
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
|
||||
"add_custom_game_tooltip": "Adicionar jogo personalizado",
|
||||
"show_playable_only_tooltip": "Mostrar Apenas Jogáveis",
|
||||
"custom_game_modal": "Adicionar jogo personalizado",
|
||||
"custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável",
|
||||
"custom_game_modal_executable_path": "Caminho do Executável",
|
||||
"custom_game_modal_select_executable": "Selecionar arquivo executável",
|
||||
"custom_game_modal_title": "Título",
|
||||
"custom_game_modal_enter_title": "Insira o título",
|
||||
"edit_game_modal_title": "Título",
|
||||
"playable_button_title": "",
|
||||
"custom_game_modal_add": "Adicionar Jogo",
|
||||
"custom_game_modal_adding": "Adicionando...",
|
||||
"custom_game_modal_browse": "Buscar",
|
||||
"custom_game_modal_cancel": "Cancelar",
|
||||
"edit_game_modal_assets": "Imagens",
|
||||
"edit_game_modal_icon": "Ícone",
|
||||
"edit_game_modal_browse": "Buscar",
|
||||
"edit_game_modal_cancel": "Cancelar",
|
||||
"custom_game_modal_add": "Adicionar Jogo",
|
||||
"custom_game_modal_adding": "Adicionando...",
|
||||
"custom_game_modal_success": "Jogo personalizado adicionado com sucesso",
|
||||
"custom_game_modal_failed": "Falha ao adicionar jogo personalizado",
|
||||
"custom_game_modal_executable": "Executável",
|
||||
"edit_game_modal": "Personalizar detalhes",
|
||||
"edit_game_modal_description": "Personalize os recursos e detalhes do jogo",
|
||||
"edit_game_modal_enter_title": "Insira o título",
|
||||
"edit_game_modal_image": "Imagem",
|
||||
"edit_game_modal_select_image": "Selecionar imagem",
|
||||
"edit_game_modal_browse": "Buscar",
|
||||
"edit_game_modal_image_preview": "Visualização da imagem",
|
||||
"edit_game_modal_icon": "Ícone",
|
||||
"edit_game_modal_select_icon": "Selecionar ícone",
|
||||
"edit_game_modal_icon_preview": "Visualização do ícone",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal": "Personalizar detalhes"
|
||||
"edit_game_modal_select_logo": "Selecionar logo",
|
||||
"edit_game_modal_logo_preview": "Visualização do logo",
|
||||
"edit_game_modal_hero": "Hero da Biblioteca",
|
||||
"edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca",
|
||||
"edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca",
|
||||
"edit_game_modal_cancel": "Cancelar",
|
||||
"edit_game_modal_update": "Atualizar",
|
||||
"edit_game_modal_updating": "Atualizando...",
|
||||
"edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios",
|
||||
"edit_game_modal_success": "Recursos atualizados com sucesso",
|
||||
"edit_game_modal_failed": "Falha ao atualizar recursos",
|
||||
"edit_game_modal_image_filter": "Imagem",
|
||||
"edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px",
|
||||
"edit_game_modal_assets": "Imagens",
|
||||
"edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui",
|
||||
"edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui",
|
||||
"edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui",
|
||||
"edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone",
|
||||
"edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero",
|
||||
"install_decky_plugin": "Instalar Plugin Decky",
|
||||
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
|
||||
"install_decky_plugin_title": "Instalar Plugin Hydra Decky",
|
||||
"install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?",
|
||||
"update_decky_plugin_title": "Atualizar Plugin Hydra Decky",
|
||||
"update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?",
|
||||
"decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso",
|
||||
"decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}",
|
||||
"decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar jogos",
|
||||
@@ -256,7 +302,48 @@
|
||||
"update_playtime": "Modificar tempo de jogo",
|
||||
"update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}",
|
||||
"update_playtime_error": "Falha ao atualizar tempo de jogo",
|
||||
"update_playtime_title": "Atualizar tempo de jogo"
|
||||
"update_playtime_title": "Atualizar tempo de jogo",
|
||||
"update_playtime_success": "Tempo de jogo atualizado com sucesso",
|
||||
"show_more": "Mostrar mais",
|
||||
"show_less": "Mostrar menos",
|
||||
"reviews": "Avaliações",
|
||||
"leave_a_review": "Deixar uma Avaliação",
|
||||
"write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
|
||||
"sort_newest": "Mais Recentes",
|
||||
"sort_oldest": "Mais Antigas",
|
||||
"sort_highest_score": "Maior Nota",
|
||||
"sort_lowest_score": "Menor Nota",
|
||||
"sort_most_voted": "Mais Votadas",
|
||||
"no_reviews_yet": "Ainda não há avaliações",
|
||||
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
|
||||
"rating": "Avaliação",
|
||||
"rating_stats": "Avaliação",
|
||||
"rating_very_negative": "Muito Negativo",
|
||||
"rating_negative": "Negativo",
|
||||
"rating_neutral": "Neutro",
|
||||
"rating_positive": "Positivo",
|
||||
"rating_very_positive": "Muito Positivo",
|
||||
"submit_review": "Enviar",
|
||||
"submitting": "Enviando...",
|
||||
"review_submitted_successfully": "Avaliação enviada com sucesso!",
|
||||
"review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.",
|
||||
"review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.",
|
||||
"review_deleted_successfully": "Avaliação excluída com sucesso.",
|
||||
"review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.",
|
||||
"loading_reviews": "Carregando avaliações...",
|
||||
"loading_more_reviews": "Carregando mais avaliações...",
|
||||
"load_more_reviews": "Carregar Mais Avaliações",
|
||||
"you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
|
||||
"would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
|
||||
"yes": "Sim",
|
||||
"maybe_later": "Talvez Mais Tarde",
|
||||
"delete_review": "Excluir avaliação",
|
||||
"remove_review": "Remover Avaliação",
|
||||
"delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?",
|
||||
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
|
||||
"delete_review_modal_delete_button": "Excluir",
|
||||
"delete_review_modal_cancel_button": "Cancelar",
|
||||
"rating_count": "Avaliação"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -395,6 +482,8 @@
|
||||
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
|
||||
"cancel": "Cancelar",
|
||||
"appearance": "Aparência",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.",
|
||||
"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",
|
||||
@@ -457,7 +546,8 @@
|
||||
"game_card": {
|
||||
"available_one": "Disponível",
|
||||
"available_other": "Disponíveis",
|
||||
"no_downloads": "Sem downloads disponíveis"
|
||||
"no_downloads": "Sem downloads disponíveis",
|
||||
"calculating": "Calculando"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programas não instalados",
|
||||
@@ -569,7 +659,12 @@
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"achievements_earned": "Conquistas recebidas"
|
||||
"game_removed_from_pinned": "Jogo removido dos fixados",
|
||||
"achievements_earned": "Conquistas recebidas",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
|
||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada",
|
||||
|
||||
@@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
|
||||
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
|
||||
|
||||
export const MAIN_LOOP_INTERVAL = 2000;
|
||||
|
||||
export const DECKY_PLUGINS_LOCATION = path.join(
|
||||
SystemPath.getPath("home"),
|
||||
"homebrew",
|
||||
"plugins"
|
||||
);
|
||||
|
||||
export const HYDRA_DECKY_PLUGIN_LOCATION = path.join(
|
||||
DECKY_PLUGINS_LOCATION,
|
||||
"Hydra"
|
||||
);
|
||||
|
||||
@@ -58,6 +58,9 @@ import "./misc/install-common-redist";
|
||||
import "./misc/can-install-common-redist";
|
||||
import "./misc/save-temp-file";
|
||||
import "./misc/delete-temp-file";
|
||||
import "./misc/install-hydra-decky-plugin";
|
||||
import "./misc/get-hydra-decky-plugin-info";
|
||||
import "./misc/check-homebrew-folder-exists";
|
||||
import "./torrenting/cancel-game-download";
|
||||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
|
||||
13
src/main/events/misc/check-homebrew-folder-exists.ts
Normal file
13
src/main/events/misc/check-homebrew-folder-exists.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { DECKY_PLUGINS_LOCATION } from "@main/constants";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const checkHomebrewFolderExists = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<boolean> => {
|
||||
const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION);
|
||||
return fs.existsSync(homebrewPath);
|
||||
};
|
||||
|
||||
registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists);
|
||||
63
src/main/events/misc/get-hydra-decky-plugin-info.ts
Normal file
63
src/main/events/misc/get-hydra-decky-plugin-info.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { logger } from "@main/services";
|
||||
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const getHydraDeckyPluginInfo = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
path: string;
|
||||
}> => {
|
||||
try {
|
||||
// Check if plugin folder exists
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin not installed");
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if package.json exists
|
||||
const packageJsonPath = path.join(
|
||||
HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
"package.json"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log("Hydra Decky plugin package.json not found");
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
};
|
||||
}
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const version = packageJson.version;
|
||||
|
||||
logger.log(`Hydra Decky plugin installed, version: ${version}`);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to get plugin info:", error);
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo);
|
||||
|
||||
50
src/main/events/misc/install-hydra-decky-plugin.ts
Normal file
50
src/main/events/misc/install-hydra-decky-plugin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { logger, DeckyPlugin } from "@main/services";
|
||||
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
|
||||
|
||||
const installHydraDeckyPlugin = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
path: string;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
logger.log("Installing/updating Hydra Decky plugin...");
|
||||
|
||||
const result = await DeckyPlugin.checkPluginVersion();
|
||||
|
||||
if (result.exists && !result.outdated) {
|
||||
logger.log("Plugin installed successfully");
|
||||
return {
|
||||
success: true,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: result.currentVersion,
|
||||
expectedVersion: result.expectedVersion,
|
||||
};
|
||||
} else {
|
||||
logger.error("Failed to install plugin");
|
||||
return {
|
||||
success: false,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: result.currentVersion,
|
||||
expectedVersion: result.expectedVersion,
|
||||
error: "Plugin installation failed",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error("Failed to install plugin:", error);
|
||||
return {
|
||||
success: false,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: null,
|
||||
expectedVersion: "0.0.3",
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin);
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
startMainLoop,
|
||||
Ludusavi,
|
||||
Lock,
|
||||
DeckyPlugin,
|
||||
} from "@main/services";
|
||||
|
||||
export const loadState = async () => {
|
||||
@@ -49,6 +50,10 @@ export const loadState = async () => {
|
||||
Ludusavi.copyConfigFileToUserData();
|
||||
Ludusavi.copyBinaryToUserData();
|
||||
|
||||
if (process.platform === "linux") {
|
||||
DeckyPlugin.checkAndUpdateIfOutdated();
|
||||
}
|
||||
|
||||
await HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
// WSClient.connect();
|
||||
|
||||
313
src/main/services/decky-plugin.ts
Normal file
313
src/main/services/decky-plugin.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import axios from "axios";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
DECKY_PLUGINS_LOCATION,
|
||||
} from "@main/constants";
|
||||
import { logger } from "./logger";
|
||||
import { SevenZip } from "./7zip";
|
||||
import { SystemPath } from "./system-path";
|
||||
|
||||
export class DeckyPlugin {
|
||||
private static readonly EXPECTED_VERSION = "0.0.3";
|
||||
private static readonly DOWNLOAD_URL =
|
||||
"https://github.com/hydralauncher/decky-hydra-launcher/releases/download/0.0.3/Hydra.zip";
|
||||
|
||||
private static getPackageJsonPath(): string {
|
||||
return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json");
|
||||
}
|
||||
|
||||
private static async downloadPlugin(): Promise<string> {
|
||||
logger.log("Downloading Hydra Decky plugin...");
|
||||
|
||||
const tempDir = SystemPath.getPath("temp");
|
||||
const zipPath = path.join(tempDir, "Hydra.zip");
|
||||
|
||||
const response = await axios.get(this.DOWNLOAD_URL, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
await fs.promises.writeFile(zipPath, response.data);
|
||||
logger.log(`Plugin downloaded to: ${zipPath}`);
|
||||
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
private static async extractPlugin(zipPath: string): Promise<string> {
|
||||
logger.log("Extracting Hydra Decky plugin...");
|
||||
|
||||
const tempDir = SystemPath.getPath("temp");
|
||||
const extractPath = path.join(tempDir, "hydra-decky-plugin");
|
||||
|
||||
if (fs.existsSync(extractPath)) {
|
||||
await fs.promises.rm(extractPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(extractPath, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
},
|
||||
() => {
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
resolve(extractPath);
|
||||
},
|
||||
() => {
|
||||
reject(new Error("Failed to extract plugin"));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static needsSudo(): boolean {
|
||||
try {
|
||||
if (fs.existsSync(DECKY_PLUGINS_LOCATION)) {
|
||||
fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(DECKY_PLUGINS_LOCATION);
|
||||
if (fs.existsSync(parentDir)) {
|
||||
fs.accessSync(parentDir, fs.constants.W_OK);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error.code === "EACCES" || error.code === "EPERM")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async installPluginWithSudo(
|
||||
extractPath: string
|
||||
): Promise<void> {
|
||||
logger.log("Installing plugin with sudo...");
|
||||
|
||||
const username = os.userInfo().username;
|
||||
const sourcePath = path.join(extractPath, "Hydra");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`;
|
||||
|
||||
sudo.exec(
|
||||
command,
|
||||
{ name: app.getName() },
|
||||
(sudoError, _stdout, stderr) => {
|
||||
if (sudoError) {
|
||||
logger.error("Failed to install plugin with sudo:", sudoError);
|
||||
reject(sudoError);
|
||||
} else {
|
||||
logger.log("Plugin installed successfully with sudo");
|
||||
if (stderr) {
|
||||
logger.log("Sudo stderr:", stderr);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static async installPluginWithoutSudo(
|
||||
extractPath: string
|
||||
): Promise<void> {
|
||||
logger.log("Installing plugin without sudo...");
|
||||
|
||||
const sourcePath = path.join(extractPath, "Hydra");
|
||||
|
||||
if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) {
|
||||
await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
logger.log("Plugin installed successfully");
|
||||
}
|
||||
|
||||
private static async installPlugin(extractPath: string): Promise<void> {
|
||||
if (this.needsSudo()) {
|
||||
await this.installPluginWithSudo(extractPath);
|
||||
} else {
|
||||
await this.installPluginWithoutSudo(extractPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async updatePlugin(): Promise<void> {
|
||||
let zipPath: string | null = null;
|
||||
let extractPath: string | null = null;
|
||||
|
||||
try {
|
||||
zipPath = await this.downloadPlugin();
|
||||
extractPath = await this.extractPlugin(zipPath);
|
||||
await this.installPlugin(extractPath);
|
||||
|
||||
logger.log("Plugin update completed successfully");
|
||||
} catch (error) {
|
||||
logger.error("Failed to update plugin:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (zipPath) {
|
||||
try {
|
||||
await fs.promises.rm(zipPath, { force: true });
|
||||
logger.log("Cleaned up downloaded zip file");
|
||||
} catch (cleanupError) {
|
||||
logger.error("Failed to clean up zip file:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractPath) {
|
||||
try {
|
||||
await fs.promises.rm(extractPath, { recursive: true, force: true });
|
||||
logger.log("Cleaned up extraction directory");
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
"Failed to clean up extraction directory:",
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async checkAndUpdateIfOutdated(): Promise<void> {
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin not installed, skipping update check");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonPath = this.getPackageJsonPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log(
|
||||
"Hydra Decky plugin package.json not found, skipping update"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const currentVersion = packageJson.version;
|
||||
const isOutdated = currentVersion !== this.EXPECTED_VERSION;
|
||||
|
||||
if (isOutdated) {
|
||||
logger.log(
|
||||
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${this.EXPECTED_VERSION}. Updating...`
|
||||
);
|
||||
|
||||
await this.updatePlugin();
|
||||
logger.log("Hydra Decky plugin updated successfully");
|
||||
} else {
|
||||
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking/updating Hydra Decky plugin: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async checkPluginVersion(): Promise<{
|
||||
exists: boolean;
|
||||
outdated: boolean;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
}> {
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin folder not found, installing...");
|
||||
|
||||
try {
|
||||
await this.updatePlugin();
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: this.EXPECTED_VERSION,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to install plugin:", error);
|
||||
return {
|
||||
exists: false,
|
||||
outdated: true,
|
||||
currentVersion: null,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonPath = this.getPackageJsonPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log("Hydra Decky plugin package.json not found, installing...");
|
||||
|
||||
await this.updatePlugin();
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: this.EXPECTED_VERSION,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const currentVersion = packageJson.version;
|
||||
const isOutdated = currentVersion !== this.EXPECTED_VERSION;
|
||||
|
||||
if (isOutdated) {
|
||||
logger.log(
|
||||
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${this.EXPECTED_VERSION}`
|
||||
);
|
||||
|
||||
await this.updatePlugin();
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: this.EXPECTED_VERSION,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
} else {
|
||||
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: isOutdated,
|
||||
currentVersion,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error checking Hydra Decky plugin version: ${error}`);
|
||||
return {
|
||||
exists: false,
|
||||
outdated: true,
|
||||
currentVersion: null,
|
||||
expectedVersion: this.EXPECTED_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from "./system-path";
|
||||
export * from "./library-sync";
|
||||
export * from "./wine";
|
||||
export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
|
||||
@@ -386,6 +386,10 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getBadges: () => ipcRenderer.invoke("getBadges"),
|
||||
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
|
||||
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
|
||||
installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
|
||||
getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"),
|
||||
checkHomebrewFolderExists: () =>
|
||||
ipcRenderer.invoke("checkHomebrewFolderExists"),
|
||||
platform: process.platform,
|
||||
|
||||
/* Auto update */
|
||||
|
||||
BIN
src/renderer/src/assets/icons/decky.png
Normal file
BIN
src/renderer/src/assets/icons/decky.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
@@ -1,11 +0,0 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.confirm-modal {
|
||||
&__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "./confirm-modal.scss";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmTheme?: "primary" | "outline" | "danger";
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmTheme = "outline",
|
||||
confirmDisabled = false,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="confirm-modal__actions">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
theme={confirmTheme}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel || t("confirm")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{cancelLabel || t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
&__description {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ConfirmationModal({
|
||||
{cancelButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
theme="danger"
|
||||
theme="primary"
|
||||
disabled={buttonsIsDisabled}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
} from "@primer/octicons-react";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
|
||||
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItemData,
|
||||
ContextMenuProps,
|
||||
ConfirmationModal,
|
||||
} from "..";
|
||||
import { useGameActions } from "..";
|
||||
|
||||
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
|
||||
@@ -195,36 +199,40 @@ export function GameContextMenu({
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmationModal
|
||||
visible={showConfirmRemoveLibrary}
|
||||
title={t("remove_from_library_title")}
|
||||
description={t("remove_from_library_description", { game: game.title })}
|
||||
descriptionText={t("remove_from_library_description", {
|
||||
game: game.title,
|
||||
})}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
await handleRemoveFromLibrary();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("remove")}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmationModal
|
||||
visible={showConfirmRemoveFiles}
|
||||
title={t("remove_files")}
|
||||
description={t("delete_modal_description", { ns: "downloads" })}
|
||||
descriptionText={t("delete_modal_description", { ns: "downloads" })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
await handleRemoveFiles();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("remove")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -71,6 +71,23 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--decky {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(22, 177, 149, 0.2) 0%,
|
||||
rgba(62, 98, 192, 0.1) 100%
|
||||
);
|
||||
color: globals.$muted-color;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(22, 177, 149, 0.3) 0%,
|
||||
rgba(62, 98, 192, 0.15) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item-button {
|
||||
@@ -123,6 +140,11 @@
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__bottom-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__help-button {
|
||||
color: globals.$muted-color;
|
||||
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from "react-tooltip";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
import { TextField } from "@renderer/components";
|
||||
import { TextField, ConfirmationModal } from "@renderer/components";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
@@ -31,6 +31,7 @@ import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import deckyIcon from "@renderer/assets/icons/decky.png";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -47,6 +48,12 @@ export function Sidebar() {
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const [deckyPluginInfo, setDeckyPluginInfo] = useState<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
}>({ installed: false, version: null });
|
||||
const [homebrewFolderExists, setHomebrewFolderExists] = useState(false);
|
||||
const [showDeckyConfirmModal, setShowDeckyConfirmModal] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||
@@ -66,7 +73,7 @@ export function Sidebar() {
|
||||
|
||||
const { lastPacket, progress } = useDownload();
|
||||
|
||||
const { showWarningToast } = useToast();
|
||||
const { showWarningToast, showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
|
||||
const [showAddGameModal, setShowAddGameModal] = useState(false);
|
||||
@@ -83,10 +90,64 @@ export function Sidebar() {
|
||||
setShowAddGameModal(false);
|
||||
};
|
||||
|
||||
const loadDeckyPluginInfo = async () => {
|
||||
if (window.electron.platform !== "linux") return;
|
||||
|
||||
try {
|
||||
const [info, folderExists] = await Promise.all([
|
||||
window.electron.getHydraDeckyPluginInfo(),
|
||||
window.electron.checkHomebrewFolderExists(),
|
||||
]);
|
||||
|
||||
setDeckyPluginInfo({
|
||||
installed: info.installed,
|
||||
version: info.version,
|
||||
});
|
||||
setHomebrewFolderExists(folderExists);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Decky plugin info:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallHydraDeckyPlugin = () => {
|
||||
setShowDeckyConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeckyInstallation = async () => {
|
||||
setShowDeckyConfirmModal(false);
|
||||
|
||||
try {
|
||||
const result = await window.electron.installHydraDeckyPlugin();
|
||||
|
||||
if (result.success) {
|
||||
showSuccessToast(
|
||||
t("decky_plugin_installed", {
|
||||
version: result.currentVersion,
|
||||
})
|
||||
);
|
||||
await loadDeckyPluginInfo();
|
||||
} else {
|
||||
showErrorToast(
|
||||
t("decky_plugin_installation_failed", {
|
||||
error: result.error || "Unknown error",
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
t("decky_plugin_installation_error", { error: String(error) })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDeckyPluginInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
|
||||
dispatch(setFriendRequestCount(result.friendRequestCount));
|
||||
@@ -244,6 +305,28 @@ export function Sidebar() {
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{window.electron.platform === "linux" && homebrewFolderExists && (
|
||||
<li className="sidebar__menu-item sidebar__menu-item--decky">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={handleInstallHydraDeckyPlugin}
|
||||
>
|
||||
<img
|
||||
src={deckyIcon}
|
||||
alt="Decky"
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
<span>
|
||||
{deckyPluginInfo.installed
|
||||
? t("decky_plugin_installed_version", {
|
||||
version: deckyPluginInfo.version,
|
||||
})
|
||||
: t("install_decky_plugin")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -321,18 +404,20 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasActiveSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__help-button"
|
||||
data-open-support-chat
|
||||
>
|
||||
<div className="sidebar__help-button-icon">
|
||||
<CommentDiscussionIcon size={14} />
|
||||
</div>
|
||||
<span>{t("need_help")}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar__bottom-buttons">
|
||||
{hasActiveSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__help-button"
|
||||
data-open-support-chat
|
||||
>
|
||||
<div className="sidebar__help-button-icon">
|
||||
<CommentDiscussionIcon size={14} />
|
||||
</div>
|
||||
<span>{t("need_help")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -345,6 +430,24 @@ export function Sidebar() {
|
||||
onClose={handleCloseAddGameModal}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showDeckyConfirmModal}
|
||||
title={
|
||||
deckyPluginInfo.installed
|
||||
? t("update_decky_plugin_title")
|
||||
: t("install_decky_plugin_title")
|
||||
}
|
||||
descriptionText={
|
||||
deckyPluginInfo.installed
|
||||
? t("update_decky_plugin_message")
|
||||
: t("install_decky_plugin_message")
|
||||
}
|
||||
onClose={() => setShowDeckyConfirmModal(false)}
|
||||
onConfirm={handleConfirmDeckyInstallation}
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("confirm")}
|
||||
/>
|
||||
|
||||
<Tooltip id="add-custom-game-tooltip" />
|
||||
<Tooltip id="show-playable-only-tooltip" />
|
||||
</aside>
|
||||
|
||||
13
src/renderer/src/declaration.d.ts
vendored
13
src/renderer/src/declaration.d.ts
vendored
@@ -339,6 +339,19 @@ declare global {
|
||||
getBadges: () => Promise<Badge[]>;
|
||||
canInstallCommonRedist: () => Promise<boolean>;
|
||||
installCommonRedist: () => Promise<void>;
|
||||
installHydraDeckyPlugin: () => Promise<{
|
||||
success: boolean;
|
||||
path: string;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getHydraDeckyPluginInfo: () => Promise<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
path: string;
|
||||
}>;
|
||||
checkHomebrewFolderExists: () => Promise<boolean>;
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
@@ -104,3 +104,11 @@ export const generateRandomGradient = (): string => {
|
||||
// Return as data URL that works in img tags
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
@@ -2,20 +2,30 @@
|
||||
|
||||
.description-header {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: globals.$background-color;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1.5);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
gap: calc(globals.$spacing-unit * 0.5);
|
||||
flex-direction: column;
|
||||
|
||||
p {
|
||||
font-size: globals.$small-font-size;
|
||||
color: globals.$muted-color;
|
||||
font-weight: 400;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { GameReview } from "@types";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
@@ -27,6 +27,8 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { formatNumber } from "@renderer/helpers";
|
||||
import { Button } from "@renderer/components";
|
||||
import "./game-details.scss";
|
||||
|
||||
const getScoreColorClass = (score: number): string => {
|
||||
@@ -147,6 +149,9 @@ export function GameDetailsContent() {
|
||||
const [reviewCharCount, setReviewCharCount] = useState(0);
|
||||
const MAX_REVIEW_CHARS = 1000;
|
||||
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
|
||||
const previousVotesRef = useRef<
|
||||
Map<string, { upvotes: number; downvotes: number }>
|
||||
>(new Map());
|
||||
const [reviewsPage, setReviewsPage] = useState(0);
|
||||
const [hasMoreReviews, setHasMoreReviews] = useState(true);
|
||||
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
|
||||
@@ -333,6 +338,8 @@ export function GameDetailsContent() {
|
||||
loadReviews(true);
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
setHasUserReviewed(false);
|
||||
setShowReviewForm(true);
|
||||
showSuccessToast(t("review_deleted_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete review:", error);
|
||||
@@ -452,6 +459,18 @@ export function GameDetailsContent() {
|
||||
}
|
||||
}, [reviewsPage]);
|
||||
|
||||
// Initialize previousVotesRef for new reviews
|
||||
useEffect(() => {
|
||||
reviews.forEach((review) => {
|
||||
if (!previousVotesRef.current.has(review.id)) {
|
||||
previousVotesRef.current.set(review.id, {
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [reviews]);
|
||||
|
||||
const getImageWithCustomPriority = (
|
||||
customUrl: string | null | undefined,
|
||||
originalUrl: string | null | undefined,
|
||||
@@ -683,7 +702,7 @@ export function GameDetailsContent() {
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={24}
|
||||
size={18}
|
||||
fill={
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "currentColor"
|
||||
@@ -695,8 +714,8 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="game-details__review-submit-button"
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={
|
||||
!editor?.getHTML().trim() ||
|
||||
@@ -708,7 +727,7 @@ export function GameDetailsContent() {
|
||||
{submittingReview
|
||||
? t("submitting")
|
||||
: t("submit_review")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -772,11 +791,20 @@ export function GameDetailsContent() {
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
{review.user?.profileImageUrl && (
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
<button
|
||||
className="game-details__review-avatar-button"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
title={review.user.displayName || "User"}
|
||||
>
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
@@ -837,14 +865,6 @@ export function GameDetailsContent() {
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "upvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasUpvoted
|
||||
? {
|
||||
@@ -855,21 +875,45 @@ export function GameDetailsContent() {
|
||||
}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<span>{review.upvotes || 0}</span>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.upvotes || 0}
|
||||
custom={
|
||||
(review.upvotes || 0) >
|
||||
(previousVotesRef.current.get(review.id)
|
||||
?.upvotes || 0)
|
||||
}
|
||||
variants={{
|
||||
enter: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? 10 : -10,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { y: 0, opacity: 1 },
|
||||
exit: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? -10 : 10,
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
onAnimationComplete={() => {
|
||||
previousVotesRef.current.set(review.id, {
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{formatNumber(review.upvotes || 0)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "downvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasDownvoted
|
||||
? {
|
||||
@@ -880,7 +924,39 @@ export function GameDetailsContent() {
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<span>{review.downvotes || 0}</span>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.downvotes || 0}
|
||||
custom={
|
||||
(review.downvotes || 0) >
|
||||
(previousVotesRef.current.get(review.id)
|
||||
?.downvotes || 0)
|
||||
}
|
||||
variants={{
|
||||
enter: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? 10 : -10,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { y: 0, opacity: 1 },
|
||||
exit: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? -10 : 10,
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
onAnimationComplete={() => {
|
||||
previousVotesRef.current.set(review.id, {
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{formatNumber(review.downvotes || 0)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetails?.id === review.user?.id && (
|
||||
|
||||
@@ -128,7 +128,7 @@ $hero-height: 300px;
|
||||
&__star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__star {
|
||||
@@ -136,7 +136,7 @@ $hero-height: 300px;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -220,30 +220,6 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-submit-button {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: not-allowed;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-list {
|
||||
margin-top: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
@@ -288,7 +264,12 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__review-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
globals.$dark-background-color 0%,
|
||||
globals.$dark-background-color 30%,
|
||||
globals.$background-color 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
@@ -310,12 +291,29 @@ $hero-height: 300px;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
}
|
||||
|
||||
&__review-avatar-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__review-user-info {
|
||||
@@ -370,16 +368,7 @@ $hero-height: 300px;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&--upvote:hover {
|
||||
color: #4caf50;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
&--downvote:hover {
|
||||
color: #f44336;
|
||||
border-color: #f44336;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
@@ -398,6 +387,9 @@ $hero-height: 300px;
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
min-width: 1ch;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,9 +1007,9 @@ $hero-height: 300px;
|
||||
&__review-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #3a3a3a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: #1e1e1e;
|
||||
background-color: globals.$dark-background-color;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1026,8 +1018,8 @@ $hero-height: 300px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: globals.$background-color;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&__review-editor-toolbar {
|
||||
@@ -1037,7 +1029,7 @@ $hero-height: 300px;
|
||||
|
||||
&__editor-button {
|
||||
background: none;
|
||||
border: 1px solid #4a4a4a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
@@ -1046,13 +1038,13 @@ $hero-height: 300px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #5a5a5a;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #0078d4;
|
||||
border-color: #0078d4;
|
||||
background-color: globals.$brand-blue;
|
||||
border-color: globals.$brand-blue;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
71
src/renderer/src/pages/settings/settings-debrid.scss
Normal file
71
src/renderer/src/pages/settings/settings-debrid.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.settings-debrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&__description {
|
||||
margin: 0 0 calc(globals.$spacing-unit * 2) 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__collapse-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all ease 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__check-icon {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__beta-badge {
|
||||
background: linear-gradient(135deg, #c9aa71, #d4af37);
|
||||
color: #1a1a1a;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
228
src/renderer/src/pages/settings/settings-debrid.tsx
Normal file
228
src/renderer/src/pages/settings/settings-debrid.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useFeature, useAppSelector } from "@renderer/hooks";
|
||||
import { SettingsTorBox } from "./settings-torbox";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./settings-debrid.scss";
|
||||
|
||||
interface CollapseState {
|
||||
torbox: boolean;
|
||||
realDebrid: boolean;
|
||||
allDebrid: boolean;
|
||||
}
|
||||
|
||||
const sectionVariants = {
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chevronVariants = {
|
||||
collapsed: {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function SettingsDebrid() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const initialCollapseState = useMemo<CollapseState>(() => {
|
||||
return {
|
||||
torbox: !userPreferences?.torBoxApiToken,
|
||||
realDebrid: !userPreferences?.realDebridApiToken,
|
||||
allDebrid: !userPreferences?.allDebridApiKey,
|
||||
};
|
||||
}, [userPreferences]);
|
||||
|
||||
const [collapseState, setCollapseState] =
|
||||
useState<CollapseState>(initialCollapseState);
|
||||
|
||||
const toggleSection = useCallback((section: keyof CollapseState) => {
|
||||
setCollapseState((prevState) => ({
|
||||
...prevState,
|
||||
[section]: !prevState[section],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="settings-debrid">
|
||||
<p className="settings-debrid__description">{t("debrid_description")}</p>
|
||||
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("realDebrid")}
|
||||
aria-label={
|
||||
collapseState.realDebrid
|
||||
? "Expand Real-Debrid section"
|
||||
: "Collapse Real-Debrid section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.realDebrid ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">Real-Debrid</h3>
|
||||
{userPreferences?.realDebridApiToken && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.realDebrid && (
|
||||
<motion.div
|
||||
key="realdebrid-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsRealDebrid />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{isTorBoxEnabled && (
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("torbox")}
|
||||
aria-label={
|
||||
collapseState.torbox
|
||||
? "Expand TorBox section"
|
||||
: "Collapse TorBox section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.torbox ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">TorBox</h3>
|
||||
{userPreferences?.torBoxApiToken && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.torbox && (
|
||||
<motion.div
|
||||
key="torbox-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsTorBox />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("allDebrid")}
|
||||
aria-label={
|
||||
collapseState.allDebrid
|
||||
? "Expand All-Debrid section"
|
||||
: "Collapse All-Debrid section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">All-Debrid</h3>
|
||||
<span className="settings-debrid__beta-badge">BETA</span>
|
||||
{userPreferences?.allDebridApiKey && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.allDebrid && (
|
||||
<motion.div
|
||||
key="alldebrid-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsAllDebrid />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
@@ -10,21 +8,17 @@ import {
|
||||
SettingsContextProvider,
|
||||
} from "@renderer/context";
|
||||
import { SettingsAccount } from "./settings-account";
|
||||
import { useFeature, useUserDetails } from "@renderer/hooks";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import "./settings.scss";
|
||||
import { SettingsAppearance } from "./aparence/settings-appearance";
|
||||
import { SettingsTorBox } from "./settings-torbox";
|
||||
import { SettingsDebrid } from "./settings-debrid";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
|
||||
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categories = [
|
||||
{ tabLabel: t("general"), contentTitle: t("general") },
|
||||
@@ -34,16 +28,7 @@ export default function Settings() {
|
||||
tabLabel: t("appearance"),
|
||||
contentTitle: t("appearance"),
|
||||
},
|
||||
...(isTorBoxEnabled
|
||||
? [
|
||||
{
|
||||
tabLabel: "TorBox",
|
||||
contentTitle: "TorBox",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
|
||||
{ tabLabel: t("debrid"), contentTitle: t("debrid") },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
@@ -52,7 +37,7 @@ export default function Settings() {
|
||||
{ tabLabel: t("account"), contentTitle: t("account") },
|
||||
];
|
||||
return categories;
|
||||
}, [userDetails, t, isTorBoxEnabled]);
|
||||
}, [userDetails, t]);
|
||||
|
||||
return (
|
||||
<SettingsContextProvider>
|
||||
@@ -76,15 +61,7 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 4) {
|
||||
return <SettingsTorBox />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 5) {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 6) {
|
||||
return <SettingsAllDebrid />;
|
||||
return <SettingsDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsAccount />;
|
||||
|
||||
Reference in New Issue
Block a user