feat: adding review styling

This commit is contained in:
Chubby Granny Chaser
2025-10-12 18:39:41 +01:00
parent 6146a1fbf1
commit 14204f1fbe
27 changed files with 1226 additions and 215 deletions

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

@@ -17,3 +17,4 @@ export * from "./system-path";
export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export function ConfirmationModal({
{cancelButtonLabel}
</Button>
<Button
theme="danger"
theme="primary"
disabled={buttonsIsDisabled}
onClick={onConfirm}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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