diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 00000000..0b0c009c
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,29 @@
+# Hydra Project Rules
+
+## Logging
+
+- **Always use `logger` instead of `console` for logging** in both main and renderer processes
+- In main process: `import { logger } from "@main/services";`
+- In renderer process: `import { logger } from "@renderer/logger";`
+- Replace all instances of:
+ - `console.log()` → `logger.log()`
+ - `console.error()` → `logger.error()`
+ - `console.warn()` → `logger.warn()`
+ - `console.info()` → `logger.info()`
+ - `console.debug()` → `logger.debug()`
+- Do not use `console` for any logging purposes
+
+## Internationalization (i18n)
+
+- All user-facing strings must be translated using i18next
+- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");`
+- Add new translation keys to `src/locales/en/translation.json`
+- Never hardcode English strings in the UI code
+- Placeholder text in form fields must also be translated
+
+## Code Style
+
+- Use ESLint and Prettier for code formatting
+- Follow TypeScript strict mode conventions
+- Use async/await instead of promises when possible
+- Prefer named exports over default exports for utilities and services
diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml
index 6aefac43..f7361883 100644
--- a/.github/workflows/build-renderer.yml
+++ b/.github/workflows/build-renderer.yml
@@ -6,7 +6,7 @@ concurrency:
on:
push:
- branches: main
+ branches: [main]
jobs:
build:
@@ -19,7 +19,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.18.0
+ node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6bc508ec..92fcebc3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,11 +1,12 @@
name: Build
+on:
+ pull_request:
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
-on: pull_request
-
jobs:
build:
strategy:
@@ -22,7 +23,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.18.3
+ node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -38,11 +39,15 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
+ - name: Copy OpenSSL DLLs
+ if: matrix.os == 'windows-2022'
+ run: |
+ cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
+ cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
+
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
- sudo apt-get update
- sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
@@ -98,5 +103,4 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- dist/*.pacman
dist/*.AppImage
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index ac359364..89e8b59f 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.18.3
+ node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 72e6e0f3..75ff209a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,7 +23,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.18.3
+ node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -39,11 +39,15 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
+ - name: Copy OpenSSL DLLs
+ if: matrix.os == 'windows-2022'
+ run: |
+ cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
+ cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
+
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
- sudo apt-get update
- sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
@@ -90,7 +94,6 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- dist/*.pacman
- name: Upload build
env:
@@ -119,6 +122,5 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- dist/*.pacman
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml
new file mode 100644
index 00000000..2a3583bc
--- /dev/null
+++ b/.github/workflows/update-aur.yml
@@ -0,0 +1,155 @@
+name: Update AUR Package
+
+on:
+ workflow_dispatch:
+ release:
+ types: [published]
+
+jobs:
+ update-aur:
+ runs-on: ubuntu-latest
+ container:
+ image: archlinux:latest
+
+ steps:
+ - name: Install dependencies
+ run: |
+ pacman -Syu --noconfirm
+ pacman -S --noconfirm nodejs npm git base-devel openssh jq pacman-contrib
+
+ - name: Create builder user
+ run: |
+ # Create builder user with home directory
+ useradd -m -s /bin/bash builder
+
+ # Add builder to wheel group for sudo access
+ usermod -aG wheel builder
+
+ # Configure sudo for builder user (no password required)
+ echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
+
+ - name: Setup SSH for AUR
+ run: |
+ mkdir -p ~/.ssh
+ echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ chmod 700 ~/.ssh
+
+ # Add AUR host key to known_hosts
+ ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
+
+ # Configure SSH to use the key
+ cat > ~/.ssh/config << EOF
+ Host aur.archlinux.org
+ IdentityFile ~/.ssh/id_rsa
+ IdentitiesOnly yes
+ User aur
+ UserKnownHostsFile ~/.ssh/known_hosts
+ StrictHostKeyChecking no
+ EOF
+
+ # Start SSH agent and add key
+ eval "$(ssh-agent -s)"
+ ssh-add ~/.ssh/id_rsa
+
+ export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
+
+ git clone ssh://aur@aur.archlinux.org/hydra-launcher-bin.git
+
+ # Give builder user ownership of the repository
+ chown -R builder:builder hydra-launcher-bin
+
+ - name: Get version to update
+ id: get-version
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ VERSION="${{ github.event.release.tag_name }}"
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "source=release" >> $GITHUB_OUTPUT
+ else
+ echo "Getting latest release version"
+ VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//')
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "source=latest" >> $GITHUB_OUTPUT
+ fi
+
+ echo "Version to update: $VERSION"
+
+ - name: Check if update is needed
+ id: check-update
+ run: |
+ CURRENT_VERSION=$(grep '^pkgver=' hydra-launcher-bin/PKGBUILD | cut -d'=' -f2)
+ NEW_VERSION="${{ steps.get-version.outputs.version }}"
+
+ echo "Current AUR version: $CURRENT_VERSION"
+ echo "New version: $NEW_VERSION"
+
+ if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
+ echo "update_needed=false" >> $GITHUB_OUTPUT
+ echo "No update needed - versions are the same"
+ else
+ echo "update_needed=true" >> $GITHUB_OUTPUT
+ echo "Update needed"
+ fi
+
+ - name: Update PKGBUILD and .SRCINFO
+ if: steps.check-update.outputs.update_needed == 'true'
+ run: |
+ # Update pkgver in PKGBUILD
+ cd hydra-launcher-bin
+ NEW_VERSION="${{ steps.get-version.outputs.version }}"
+
+ echo "Updating PKGBUILD pkgver to $NEW_VERSION"
+
+ # Read PKGBUILD and update pkgver line
+ sed -i "s/^pkgver=.*/pkgver=$NEW_VERSION/" ./PKGBUILD
+
+ # Reset pkgrel to 1 when version changes
+ sed -i "s/^pkgrel=.*/pkgrel=1/" ./PKGBUILD
+
+ echo "✅ Successfully updated pkgver to $NEW_VERSION in ./PKGBUILD"
+
+ # Update package checksums and generate .SRCINFO as builder user
+ sudo -u builder updpkgsums
+ sudo -u builder makepkg --printsrcinfo > .SRCINFO
+
+ - name: Commit and push changes
+ if: steps.check-update.outputs.update_needed == 'true'
+ run: |
+ cd hydra-launcher-bin
+ git config --global --add safe.directory .
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+
+ git add PKGBUILD .SRCINFO
+
+ echo "## Git Diff Preview"
+ echo "Changes that would be made:"
+ git diff PKGBUILD .SRCINFO || echo "No changes to show"
+ echo ""
+ echo "Staged changes:"
+ git add PKGBUILD .SRCINFO
+ git diff --staged || echo "No staged changes"
+
+ if git diff --staged --quiet; then
+ echo "No changes to commit"
+ else
+ COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
+
+ git commit -m "$COMMIT_MSG"
+ git push origin master
+ echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}"
+ fi
+
+ - name: Create summary
+ if: always()
+ run: |
+ echo "## AUR Update Summary" >> $GITHUB_STEP_SUMMARY
+ echo "- **Version**: ${{ steps.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Source**: ${{ steps.get-version.outputs.source }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Update needed**: ${{ steps.check-update.outputs.update_needed }}" >> $GITHUB_STEP_SUMMARY
+ if [ "${{ steps.check-update.outputs.update_needed }}" = "true" ]; then
+ echo "- **Status**: ✅ AUR package updated successfully" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- **Status**: ⏭️ No update needed" >> $GITHUB_STEP_SUMMARY
+ fi
diff --git a/electron-builder.yml b/electron-builder.yml
index 50fe8139..ec162530 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -56,7 +56,6 @@ linux:
- AppImage
- snap
- deb
- - pacman
- rpm
maintainer: electronjs.org
category: Game
diff --git a/package.json b/package.json
index 342b078a..9ed25fa9 100644
--- a/package.json
+++ b/package.json
@@ -90,8 +90,7 @@
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
- "yup": "^1.5.0",
- "zod": "^3.24.1"
+ "yup": "^1.5.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",
@@ -116,9 +115,9 @@
"@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1",
- "electron": "^33.4.11",
+ "electron": "^37.7.1",
"electron-builder": "^26.0.12",
- "electron-vite": "^3.0.0",
+ "electron-vite": "^4.0.1",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4",
@@ -130,7 +129,7 @@
"sass-embedded": "^1.80.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
- "vite": "5.4.20",
+ "vite": "5.4.21",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
diff --git a/scripts/upload-build.cjs b/scripts/upload-build.cjs
index fe475163..15e3a5b4 100644
--- a/scripts/upload-build.cjs
+++ b/scripts/upload-build.cjs
@@ -20,7 +20,7 @@ const s3 = new S3Client({
const dist = path.resolve(__dirname, "..", "dist");
-const extensionsToUpload = [".deb", ".exe", ".pacman", ".AppImage"];
+const extensionsToUpload = [".deb", ".exe", ".AppImage"];
fs.readdir(dist, async (err, files) => {
if (err) throw err;
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 46bdb28c..668f1547 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -428,6 +428,9 @@
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
+ "adding": "Adding…",
+ "failed_add_download_source": "Failed to add download source. Please try again.",
+ "download_source_already_exists": "This download source URL already exists.",
"download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options",
@@ -435,9 +438,16 @@
"add_download_source_description": "Insert the URL of the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
+ "download_source_pending_matching": "Updating soon",
+ "download_source_matched": "Up to date",
+ "download_source_matching": "Updating",
+ "download_source_failed": "Error",
+ "download_source_no_information": "No information available",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"removed_download_sources": "Download sources removed",
+ "removed_all_download_sources": "All download sources removed",
+ "download_sources_synced_successfully": "All download sources are synced",
"cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Yes, delete everything",
"title_confirmation_delete_all_sources": "Delete all download sources",
@@ -468,6 +478,7 @@
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them",
"account": "Account",
+ "hydra_cloud": "Hydra Cloud",
"no_users_blocked": "You have no blocked users",
"subscription_active_until": "Your Hydra Cloud is active until {{date}}",
"manage_subscription": "Manage subscription",
@@ -541,7 +552,9 @@
"hidden": "Hidden",
"test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview",
- "enable_friend_start_game_notifications": "When a friend starts playing a game"
+ "enable_friend_start_game_notifications": "When a friend starts playing a game",
+ "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
+ "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
},
"notifications": {
"download_complete": "Download complete",
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index de9ccfdb..dfa7f7a1 100644
--- a/src/locales/es/translation.json
+++ b/src/locales/es/translation.json
@@ -70,6 +70,24 @@
"edit_game_modal_icon_resolution": "Resolución recomendada: 256x256px",
"edit_game_modal_logo_resolution": "Resolución recomendada: 640x360px",
"edit_game_modal_hero_resolution": "Resolución recomendada: 1920x620px",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar",
+ "decky_plugin_installation_error": "Error instalando plugin Decky: {{error}}",
+ "decky_plugin_installation_failed": "Falló instalar plugin Decky: {{error}}",
+ "decky_plugin_installed": "Plugin Decky v{{version}} instalanda exitosamente",
+ "decky_plugin_installed_version": "Plugin Decky (v{{version}})",
+ "edit_game_modal_drop_hero_image_here": "Soltá la imagen hero acá",
+ "edit_game_modal_drop_icon_image_here": "Soltá la imagen de ícono hero acá",
+ "edit_game_modal_drop_logo_image_here": "Soltá la imagen de logo hero acá",
+ "edit_game_modal_drop_to_replace_hero": "Soltá para reemplazar hero",
+ "edit_game_modal_drop_to_replace_icon": "Soltá para reemplazar el ícono",
+ "edit_game_modal_drop_to_replace_logo": "Soltá para reemplazar el logo",
+ "install_decky_plugin": "Instalar plugin Decky",
+ "install_decky_plugin_message": "Esto va a descargar e instalar el plugin de Decky Loader para Hydra. Esto quizás requierea permisos elevados, ¿querés continuar?",
+ "install_decky_plugin_title": "Instarlar el plugin Decky Hydra",
+ "update_decky_plugin": "Actualizar plugin Decky",
+ "update_decky_plugin_message": "Una nueva versión del plugin Decky para Hydra está disponible. ¿Querés actualizarlo ahora?",
+ "update_decky_plugin_title": "Actualizar plugin Decky para Hydra",
"edit_game_modal_assets": "Recursos"
},
"header": {
@@ -285,6 +303,62 @@
"keyshop_price": "Precio de tiendas de terceros",
"historical_retail": "Precio de tiendas",
"historical_keyshop": "Precio de tiendas de terceros",
+ "add_to_favorites": "Añadir a favoritos",
+ "be_first_to_review": "¡Sé la primera persona en compartir lo que pensas de este juego!",
+ "create_shortcut_simple": "Crear atajo",
+ "delete_review": "Eliminar reseña",
+ "delete_review_modal_cancel_button": "Cancelar",
+ "delete_review_modal_delete_button": "Eliminar",
+ "delete_review_modal_description": "Esta acción no se puede deshacer.",
+ "delete_review_modal_title": "¿De verdad querés eliminar esta reseña?",
+ "failed_remove_files": "Error al eliminar los archivos",
+ "failed_remove_from_library": "Error al eliminar de la librería",
+ "failed_update_favorites": "Error al actualizar favoritos",
+ "files_removed_success": "Archivos eliminados correctamente",
+ "filter_by_source": "Filtrar por fuente",
+ "game_removed_from_library": "Juego eliminado de la librería",
+ "hide_original": "Ocultar original",
+ "leave_a_review": "Crear una reseña",
+ "load_more_reviews": "Cargar más reseñas",
+ "loading_more_reviews": "Cargando más reseñas...",
+ "loading_reviews": "Cargando reseñas...",
+ "maybe_later": "Tal vez después",
+ "no_repacks_found": "Sin fuentes encontradas para este juego",
+ "no_reviews_yet": "Sin reseñas aún",
+ "properties": "Propiedades",
+ "rating": "Calificación",
+ "rating_count": "Calificación",
+ "rating_negative": "Negativa",
+ "rating_neutral": "Neutral",
+ "rating_positive": "Positiva",
+ "rating_stats": "Calificación",
+ "rating_very_negative": "Muy Negativa",
+ "rating_very_positive": "Muy Positiva",
+ "remove_from_favorites": "Eliminar de favoritos",
+ "remove_review": "Eliminar reseña",
+ "review_cannot_be_empty": "El campo de la reseña no puede estar vacío.",
+ "review_deleted_successfully": "Reseña eliminada exitosamente.",
+ "review_deletion_failed": "Error al eliminar reseña. Por favor intentá de nuevo.",
+ "review_submission_failed": "Error al subir reseña. Por favor intentá de nuevo.",
+ "review_submitted_successfully": "¡Reseña eliminada exitosamente!",
+ "reviews": "Reseñas",
+ "show_less": "Ver menos",
+ "show_more": "Ver más",
+ "show_original": "Ver original",
+ "show_original_translated_from": "Ver original (traducido del {{language}})",
+ "show_translation": "Ver traducción",
+ "sort_highest_score": "Puntuación más alta",
+ "sort_lowest_score": "Puntuación más baja",
+ "sort_most_voted": "Más votads",
+ "sort_newest": "Más nuevos",
+ "sort_oldest": "Más viejos",
+ "submit_review": "Enviar",
+ "submitting": "Subiendo...",
+ "vote_failed": "Error al registrar tu voto. Por favor intentá de nuevo.",
+ "would_you_recommend_this_game": "¿Querés escribir una reseña para este juego?",
+ "write_review_placeholder": "Compartí tus pensamientos sobre este juego...",
+ "yes": "Si",
+ "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"language": "Idioma",
"caption": "Subtítulo",
"audio": "Audio"
@@ -345,7 +419,7 @@
"enable_real_debrid": "Habilitar Real-Debrid",
"real_debrid_description": "Real-Debrid es un descargador que te permite descargar archivos más rápidos, solo límitado por la velocidad de tu internet.",
"debrid_invalid_token": "Token API inválido",
- "debrid_api_token_hint": "Podés obtener la el token de tu API <0>acá0>",
+ "debrid_api_token_hint": "Podés obtener el token de tu API <0>acá0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratis. Por favor suscribíte a Real-Debrid",
"debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"save_changes": "Guardar cambios",
@@ -357,7 +431,7 @@
"download_count_zero": "Sin opciones de descarga",
"download_count_one": "{{countFormatted}} opción de descarga",
"download_count_other": "{{countFormatted}} opciones de descarga",
- "download_source_url": "Descargar fuente URL",
+ "download_source_url": "Añadir URL de una fuente",
"add_download_source_description": "Introducí la URL del archivo .json",
"download_source_up_to_date": "Actualizado",
"download_source_errored": "Error",
@@ -409,7 +483,7 @@
"subscription_renew_cancelled": "Renovación automática desactivada",
"subscription_renews_on": "Tu suscripción se renueva el {{date}}",
"bill_sent_until": "Tu próxima factura se enviará este día",
- "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupés, presiona acá para hacer tu primera obra maestra.",
+ "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para hacer tu primera obra maestra.",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Guardar",
@@ -443,7 +517,7 @@
"enable_friend_request_notifications": "Cuando recibís una solicitud de amistad",
"enable_auto_install": "Descargar actualizaciones automáticamente",
"common_redist": "Common redistributables",
- "common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
+ "common_redist_description": "Los common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
"install_common_redist": "Instalar",
"installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
@@ -465,6 +539,8 @@
"hidden": "Oculto",
"test_notification": "Probar notificación",
"notification_preview": "Probar notificación de logro",
+ "debrid": "Debrid",
+ "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
},
"notifications": {
@@ -492,6 +568,7 @@
"game_card": {
"available_one": "Disponible",
"available_other": "Disponibles",
+ "calculating": "Calculando",
"no_downloads": "Sin descargas disponibles"
},
"binary_not_found_modal": {
@@ -593,6 +670,12 @@
"error_adding_friend": "No se pudo enviar la solicitud de amistad. Por favor revisá el código",
"friend_code_length_error": "El código de amistad debe tener mínimo 8 caracteres",
"game_removed_from_pinned": "Juego removido de fijados",
+ "amount_hours_short": "{{amount}}h",
+ "amount_minutes_short": "{{amount}}m",
+ "karma": "Karma",
+ "karma_count": "karma",
+ "karma_description": "Conseguido por me gustas positivos en reseñas",
+ "sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados"
},
"achievement": {
diff --git a/src/locales/index.ts b/src/locales/index.ts
index a44480e6..ca9ec757 100644
--- a/src/locales/index.ts
+++ b/src/locales/index.ts
@@ -28,6 +28,7 @@ import bg from "./bg/translation.json";
import uz from "./uz/translation.json";
import fi from "./fi/translation.json";
import sv from "./sv/translation.json";
+import lv from "./lv/translation.json";
export default {
"pt-BR": ptBR,
@@ -60,4 +61,5 @@ export default {
et,
uz,
sv,
+ lv,
};
diff --git a/src/locales/lv/translation.json b/src/locales/lv/translation.json
new file mode 100644
index 00000000..26aacb74
--- /dev/null
+++ b/src/locales/lv/translation.json
@@ -0,0 +1,708 @@
+{
+ "language_name": "Latviešu",
+ "app": {
+ "successfully_signed_in": "Veiksmīga pieteikšanās"
+ },
+ "home": {
+ "surprise_me": "Pārsteidz mani",
+ "no_results": "Nekas nav atrasts",
+ "start_typing": "Sākt rakstīt...",
+ "hot": "Šobrīd populārs",
+ "weekly": "📅 Nedēļas labākās spēles",
+ "achievements": "🏆 Spēles ar sasniegumiem"
+ },
+ "sidebar": {
+ "catalogue": "Katalogs",
+ "downloads": "Lejupielādes",
+ "settings": "Iestatījumi",
+ "my_library": "Bibliotēka",
+ "downloading_metadata": "{{title}} (Lejupielādē metadatus…)",
+ "paused": "{{title}} (Apturēts)",
+ "downloading": "{{title}} ({{percentage}} - Lejupielādē…)",
+ "filter": "Meklēt",
+ "home": "Sākums",
+ "queued": "{{title}} (Rindā)",
+ "game_has_no_executable": "Spēles palaišanas fails nav izvēlēts",
+ "sign_in": "Pieteikties",
+ "friends": "Draugi",
+ "need_help": "Nepieciešama palīdzība?",
+ "favorites": "Izlase",
+ "playable_button_title": "Rādīt tikai instalētās spēles.",
+ "add_custom_game_tooltip": "Pievienot pielāgotu spēli",
+ "show_playable_only_tooltip": "Rādīt tikai spēlēšanai pieejamās",
+ "custom_game_modal": "Pievienot pielāgotu spēli",
+ "custom_game_modal_description": "Pievienojiet pielāgotu spēli bibliotēkai, izvēloties izpildāmo failu",
+ "custom_game_modal_executable_path": "Ceļš uz izpildāmo failu",
+ "custom_game_modal_select_executable": "Izvēlieties izpildāmo failu",
+ "custom_game_modal_title": "Spēles nosaukums",
+ "custom_game_modal_enter_title": "Ievadiet spēles nosaukumu",
+ "custom_game_modal_browse": "Pārlūkot",
+ "custom_game_modal_cancel": "Atcelt",
+ "custom_game_modal_add": "Pievienot spēli",
+ "custom_game_modal_adding": "Pievieno spēli...",
+ "custom_game_modal_success": "Pielāgota spēle veiksmīgi pievienota",
+ "custom_game_modal_failed": "Neizdevās pievienot pielāgotu spēli",
+ "custom_game_modal_executable": "Izpildāmais fails",
+ "edit_game_modal": "Konfigurēt resursus",
+ "edit_game_modal_description": "Konfigurējiet spēles resursus un detaļas",
+ "edit_game_modal_title": "Nosaukums",
+ "edit_game_modal_enter_title": "Ievadiet nosaukumu",
+ "edit_game_modal_image": "Attēls",
+ "edit_game_modal_select_image": "Izvēlieties attēlu",
+ "edit_game_modal_browse": "Pārlūkot",
+ "edit_game_modal_image_preview": "Attēla priekšskatījums",
+ "edit_game_modal_icon": "Ikona",
+ "edit_game_modal_select_icon": "Izvēlieties ikonu",
+ "edit_game_modal_icon_preview": "Ikona priekšskatījums",
+ "edit_game_modal_logo": "Logotips",
+ "edit_game_modal_select_logo": "Izvēlieties logotipu",
+ "edit_game_modal_logo_preview": "Logotipa priekšskatījums",
+ "edit_game_modal_hero": "Vāka attēls",
+ "edit_game_modal_select_hero": "Izvēlieties spēles vāka attēlu",
+ "edit_game_modal_hero_preview": "Spēles vāka attēla priekšskatījums",
+ "edit_game_modal_cancel": "Atcelt",
+ "edit_game_modal_update": "Atjaunināt",
+ "edit_game_modal_updating": "Atjaunina...",
+ "edit_game_modal_fill_required": "Lūdzu, aizpildiet visus obligātos laukus",
+ "edit_game_modal_success": "Resursi veiksmīgi atjaunināti",
+ "edit_game_modal_failed": "Neizdevās atjaunināt resursus",
+ "edit_game_modal_image_filter": "Attēls",
+ "edit_game_modal_icon_resolution": "Ieteicamā izšķirtspēja: 256x256px",
+ "edit_game_modal_logo_resolution": "Ieteicamā izšķirtspēja: 640x360px",
+ "edit_game_modal_hero_resolution": "Ieteicamā izšķirtspēja: 1920x620px",
+ "edit_game_modal_assets": "Resursi",
+ "edit_game_modal_drop_icon_image_here": "Ievelciet ikonas attēlu šeit",
+ "edit_game_modal_drop_logo_image_here": "Ievelciet logotipa attēlu šeit",
+ "edit_game_modal_drop_hero_image_here": "Ievelciet vāka attēlu šeit",
+ "edit_game_modal_drop_to_replace_icon": "Ievelciet, lai aizstātu ikonu",
+ "edit_game_modal_drop_to_replace_logo": "Ievelciet, lai aizstātu logotipu",
+ "edit_game_modal_drop_to_replace_hero": "Ievelciet, lai aizstātu vāku",
+ "install_decky_plugin": "Instalēt Decky spraudni",
+ "update_decky_plugin": "Atjaunināt Decky spraudni",
+ "decky_plugin_installed_version": "Decky spraudnis (v{{version}})",
+ "install_decky_plugin_title": "Instalēt Hydra Decky spraudni",
+ "install_decky_plugin_message": "Tas lejupielādēs un instalēs Hydra spraudni Decky Loader. Var būt nepieciešamas paaugstinātas atļaujas. Turpināt?",
+ "update_decky_plugin_title": "Atjaunināt Hydra Decky spraudni",
+ "update_decky_plugin_message": "Ir pieejama jauna Hydra Decky spraudņa versija. Vai vēlaties to atjaunināt tagad?",
+ "decky_plugin_installed": "Decky spraudnis v{{version}} veiksmīgi instalēts",
+ "decky_plugin_installation_failed": "Neizdevās instalēt Decky spraudni: {{error}}",
+ "decky_plugin_installation_error": "Decky spraudņa instalēšanas kļūda: {{error}}",
+ "confirm": "Apstiprināt",
+ "cancel": "Atcelt"
+ },
+ "header": {
+ "search": "Meklēt",
+ "home": "Sākums",
+ "catalogue": "Katalogs",
+ "downloads": "Lejupielādes",
+ "search_results": "Meklēšanas rezultāti",
+ "settings": "Iestatījumi",
+ "version_available_install": "Pieejama versija {{version}}. Noklikšķiniet šeit, lai instalētu.",
+ "version_available_download": "Pieejama versija {{version}}. Noklikšķiniet šeit, lai lejupielādētu."
+ },
+ "bottom_panel": {
+ "no_downloads_in_progress": "Nav aktīvu lejupielāžu",
+ "downloading_metadata": "Lejupielādē metadatus {{title}}…",
+ "downloading": "Lejupielādē {{title}}… ({{percentage}} pabeigts) - Beigsies {{eta}} - {{speed}}",
+ "calculating_eta": "Lejupielādē {{title}}… ({{percentage}} pabeigts) - Aprēķina atlikušo laiku…",
+ "checking_files": "Pārbauda failus {{title}}… ({{percentage}} pabeigts)",
+ "installing_common_redist": "{{log}}…",
+ "installation_complete": "Instalēšana pabeigta",
+ "installation_complete_message": "Bibliotēkas veiksmīgi instalētas"
+ },
+ "catalogue": {
+ "search": "Filtrs…",
+ "developers": "Izstrādātāji",
+ "genres": "Žanri",
+ "tags": "Atzīmes",
+ "publishers": "Izdevēji",
+ "download_sources": "Lejupielādes avoti",
+ "result_count": "{{resultCount}} rezultāti",
+ "filter_count": "{{filterCount}} pieejami",
+ "clear_filters": "Notīrīt {{filterCount}} atlasītos"
+ },
+ "game_details": {
+ "open_download_options": "Atvērt avotus",
+ "download_options_zero": "Nav avotu",
+ "download_options_one": "{{count}} avots",
+ "download_options_other": "{{count}} avoti",
+ "updated_at": "Atjaunināts {{updated_at}}",
+ "install": "Instalēt",
+ "resume": "Atsākt",
+ "pause": "Apturēt",
+ "cancel": "Atcelt",
+ "remove": "Dzēst",
+ "space_left_on_disk": "{{space}} brīvs diskā",
+ "eta": "Beigsies {{eta}}",
+ "calculating_eta": "Aprēķina atlikušo laiku…",
+ "downloading_metadata": "Lejupielādē metadatus…",
+ "filter": "Meklēt repakus",
+ "requirements": "Sistēmas prasības",
+ "minimum": "Minimālās",
+ "recommended": "Ieteicamās",
+ "paused": "Apturēts",
+ "release_date": "Izdots {{date}}",
+ "publisher": "Izdevējs {{publisher}}",
+ "hours": "stundas",
+ "minutes": "minūtes",
+ "amount_hours": "{{amount}} stundas",
+ "amount_minutes": "{{amount}} minūtes",
+ "accuracy": "precizitāte {{accuracy}}%",
+ "add_to_library": "Pievienot bibliotēkai",
+ "already_in_library": "Jau bibliotēkā",
+ "remove_from_library": "Dzēst no bibliotēkas",
+ "no_downloads": "Nav pieejamu avotu",
+ "play_time": "Spēlēts {{amount}}",
+ "last_time_played": "Pēdējo reizi spēlēts {{period}}",
+ "not_played_yet": "Jūs vēl neesat spēlējis {{title}}",
+ "next_suggestion": "Nākamais ieteikums",
+ "play": "Spēlēt",
+ "deleting": "Dzēš instalētāju…",
+ "close": "Aizvērt",
+ "playing_now": "Palaists",
+ "change": "Mainīt",
+ "repacks_modal_description": "Izvēlieties repaku lejupielādei",
+ "select_folder_hint": "Lai mainītu noklusējuma lejupielāžu mapi, atveriet <0>Iestatījumus0>",
+ "download_now": "Lejupielādēt tagad",
+ "no_shop_details": "Neizdevās iegūt aprakstu",
+ "download_options": "Avoti",
+ "download_path": "Ceļš lejupielādēm",
+ "previous_screenshot": "Iepriekšējais ekrānuzņēmums",
+ "next_screenshot": "Nākamais ekrānuzņēmums",
+ "screenshot": "Ekrānuzņēmums {{number}}",
+ "open_screenshot": "Atvērt ekrānuzņēmumu {{number}}",
+ "download_settings": "Lejupielādes parametri",
+ "downloader": "Lejupielādētājs",
+ "select_executable": "Izvēlēties",
+ "no_executable_selected": "Fails nav izvēlēts",
+ "open_folder": "Atvērt mapi",
+ "open_download_location": "Pārlūkot lejupielādes mapi",
+ "create_shortcut": "Izveidot īsceļu uz darbvirsmas",
+ "create_shortcut_simple": "Izveidot īsceļu",
+ "clear": "Notīrīt",
+ "remove_files": "Dzēst failus",
+ "remove_from_library_title": "Vai esat pārliecināts?",
+ "remove_from_library_description": "{{game}} tiks dzēsta no jūsu bibliotēkas.",
+ "options": "Iestatījumi",
+ "properties": "Īpašības",
+ "executable_section_title": "Fails",
+ "executable_section_description": "Ceļš uz failu, kas tiks palaists, nospiežot \"Spēlēt\"",
+ "downloads_section_title": "Lejupielādes",
+ "downloads_section_description": "Pārbaudīt atjauninājumu vai citu spēles versiju pieejamību",
+ "danger_zone_section_title": "Bīstamā zona",
+ "danger_zone_section_description": "Jūs varat dzēst šo spēli no savas bibliotēkas vai failus, kas lejupielādēti no Hydra",
+ "download_in_progress": "Notiek lejupielāde",
+ "download_paused": "Lejupielāde apturēta",
+ "last_downloaded_option": "Pēdējais lejupielādes variants",
+ "create_steam_shortcut": "Izveidot Steam īsceļu",
+ "create_shortcut_success": "Īsceļš izveidots",
+ "you_might_need_to_restart_steam": "Iespējams, jums būs jāpārstartē Steam, lai redzētu izmaiņas",
+ "create_shortcut_error": "Neizdevās izveidot īsceļu",
+ "add_to_favorites": "Pievienot izlasei",
+ "remove_from_favorites": "Dzēst no izlases",
+ "failed_update_favorites": "Neizdevās atjaunināt izlasi",
+ "game_removed_from_library": "Spēle dzēsta no bibliotēkas",
+ "failed_remove_from_library": "Neizdevās dzēst no bibliotēkas",
+ "files_removed_success": "Faili veiksmīgi dzēsti",
+ "failed_remove_files": "Neizdevās dzēst failus",
+ "nsfw_content_title": "Šajā spēlē ir nepiemērots saturs",
+ "nsfw_content_description": "{{title}} satur saturu, kas var nebūt piemērots visiem vecumiem. \nVai esat pārliecināts, ka vēlaties turpināt?",
+ "allow_nsfw_content": "Turpināt",
+ "refuse_nsfw_content": "Atpakaļ",
+ "stats": "Statistika",
+ "download_count": "Lejupielādes",
+ "player_count": "Aktīvie spēlētāji",
+ "download_error": "Šis lejupielādes variants nav pieejams",
+ "download": "Lejupielādēt",
+ "executable_path_in_use": "Izpildāmais fails jau tiek izmantots \"{{game}}\"",
+ "warning": "Uzmanību:",
+ "hydra_needs_to_remain_open": "Lai veiktu šo lejupielādi, Hydra jāpaliek atvērtai līdz beigām. Ja Hydra aizvērsies pirms pabeigšanas, jūs zaudēsiet progresu.",
+ "achievements": "Sasniegumi",
+ "achievements_count": "Sasniegumi {{unlockedCount}}/{{achievementsCount}}",
+ "show_more": "Rādīt vairāk",
+ "show_less": "Rādīt mazāk",
+ "reviews": "Atsauksmes",
+ "leave_a_review": "Atstāt atsauksmi",
+ "write_review_placeholder": "Dalieties savās domās par šo spēli...",
+ "sort_newest": "Vispirms jaunākās",
+ "no_reviews_yet": "Pagaidām nav atsauksmju",
+ "be_first_to_review": "Esiet pirmais, kurš dalīsies savās domās par šo spēli!",
+ "sort_oldest": "Vispirms vecākās",
+ "sort_highest_score": "Augstākais vērtējums",
+ "sort_lowest_score": "Zemākais vērtējums",
+ "sort_most_voted": "Vispopulārākās",
+ "rating": "Vērtējums",
+ "rating_stats": "Vērtējums",
+ "rating_very_negative": "Ļoti negatīvs",
+ "rating_negative": "Negatīvs",
+ "rating_neutral": "Neitrāls",
+ "rating_positive": "Pozitīvs",
+ "rating_very_positive": "Ļoti pozitīvs",
+ "submit_review": "Iesniegt",
+ "submitting": "Iesniegšana...",
+ "review_submitted_successfully": "Atsauksme veiksmīgi iesniegta!",
+ "review_submission_failed": "Neizdevās iesniegt atsauksmi. Lūdzu, mēģiniet vēlreiz.",
+ "review_cannot_be_empty": "Atsauksmes teksta lauks nevar būt tukšs.",
+ "review_deleted_successfully": "Atsauksme veiksmīgi dzēsta.",
+ "review_deletion_failed": "Neizdevās dzēst atsauksmi. Lūdzu, mēģiniet vēlreiz.",
+ "loading_reviews": "Ielādē atsauksmes...",
+ "loading_more_reviews": "Ielādē papildu atsauksmes...",
+ "load_more_reviews": "Ielādēt vairāk atsauksmju",
+ "you_seemed_to_enjoy_this_game": "Šķiet, jums patika šī spēle",
+ "would_you_recommend_this_game": "Vai vēlaties atstāt atsauksmi par šo spēli?",
+ "yes": "Jā",
+ "maybe_later": "Varbūt vēlāk",
+ "rating_count": "Vērtējums",
+ "delete_review": "Dzēst atsauksmi",
+ "remove_review": "Dzēst atsauksmi",
+ "delete_review_modal_title": "Vai esat pārliecināts, ka vēlaties dzēst savu atsauksmi?",
+ "delete_review_modal_description": "Šo darbību nevar atsaukt.",
+ "delete_review_modal_delete_button": "Dzēst",
+ "delete_review_modal_cancel_button": "Atcelt",
+ "show_original": "Rādīt oriģinālu",
+ "show_translation": "Rādīt tulkojumu",
+ "show_original_translated_from": "Rādīt oriģinālu (tulkot no {{language}})",
+ "hide_original": "Slēpt oriģinālu",
+ "cloud_save": "Mākoņglabāšana",
+ "cloud_save_description": "Glabājiet savu progresu mākonī un turpiniet spēlēt jebkurā ierīcē",
+ "backups": "Rezerves kopijas",
+ "install_backup": "Instalēt",
+ "delete_backup": "Dzēst",
+ "create_backup": "Izveidot jaunu rezerves kopiju",
+ "last_backup_date": "Pēdējā rezerves kopija no {{date}}",
+ "no_backup_preview": "Šim nosaukumam saglabājumi nav atrasti",
+ "restoring_backup": "Atjauno rezerves kopiju ({{progress}} pabeigts)…",
+ "uploading_backup": "Augšupielādē rezerves kopiju…",
+ "no_backups": "Jūs vēl neesat izveidojis rezerves kopijas šai spēlei",
+ "backup_uploaded": "Rezerves kopija augšupielādēta",
+ "backup_failed": "Rezerves kopēšanas kļūda",
+ "backup_deleted": "Rezerves kopija dzēsta",
+ "backup_restored": "Rezerves kopija atjaunota",
+ "see_all_achievements": "Skatīt visus sasniegumus",
+ "sign_in_to_see_achievements": "Piesakieties, lai redzētu sasniegumus",
+ "mapping_method_automatic": "Automātiska",
+ "mapping_method_manual": "Manuāla",
+ "mapping_method_label": "Kartēšanas metode",
+ "files_automatically_mapped": "Faili automātiski kartēti",
+ "no_backups_created": "Šai spēlei nav izveidotas rezerves kopijas",
+ "manage_files": "Failu pārvaldība",
+ "loading_save_preview": "Meklē saglabājumus…",
+ "wine_prefix": "Wine prefikss",
+ "wine_prefix_description": "Wine prefikss, ko izmanto šīs spēles palaišanai",
+ "launch_options": "Palaišanas parametri",
+ "launch_options_description": "Pieredzējuši lietotāji var veikt izmaiņas palaišanas parametros",
+ "launch_options_placeholder": "Parametrs nav norādīts",
+ "no_download_option_info": "Informācija nav pieejama",
+ "backup_deletion_failed": "Neizdevās dzēst rezerves kopiju",
+ "max_number_of_artifacts_reached": "Sasniegts maksimālais rezerves kopiju skaits šai spēlei",
+ "achievements_not_sync": "Jūsu sasniegumi nav sinhronizēti",
+ "manage_files_description": "Pārvaldiet failus, kas tiks saglabāti un atjaunoti",
+ "select_folder": "Izvēlēties mapi",
+ "backup_from": "Rezerves kopija no {{date}}",
+ "automatic_backup_from": "Automātiska rezerves kopija no {{date}}",
+ "enable_automatic_cloud_sync": "Iespējot automātisku sinhronizāciju mākonī",
+ "custom_backup_location_set": "Iestatīta pielāgota rezerves kopēšanas vieta",
+ "no_directory_selected": "Nav izvēlēts katalogs",
+ "no_write_permission": "Nevar augšupielādēt šajā direktorijā. Noklikšķiniet šeit, lai uzzinātu vairāk.",
+ "reset_achievements": "Atiestatīt sasniegumus",
+ "reset_achievements_description": "Tas atiestatīs visus sasniegumus {{game}} spēlei",
+ "reset_achievements_title": "Vai esat pārliecināts?",
+ "reset_achievements_success": "Sasniegumi veiksmīgi atiestatīti",
+ "reset_achievements_error": "Neizdevās atiestatīt sasniegumus",
+ "download_error_gofile_quota_exceeded": "Jūs pārsniedzāt Gofile mēneša kvotu. Lūdzu, uzgaidiet, kamēr kvota tiks atjaunota.",
+ "download_error_real_debrid_account_not_authorized": "Jūsu Real-Debrid konts nav autorizēts jaunām lejupielādēm. Lūdzu, pārbaudiet konta iestatījumus un mēģiniet vēlreiz.",
+ "download_error_not_cached_on_real_debrid": "Šī lejupielāde nav pieejama Real-Debrid, un Real-Debrid lejupielādes statusu pagaidām nav iespējams iegūt.",
+ "update_playtime_title": "Atjaunināt spēles laiku",
+ "update_playtime_description": "Manuāli atjauniniet spēles laiku {{game}} spēlei",
+ "update_playtime": "Atjaunināt spēles laiku",
+ "update_playtime_success": "Spēles laiks veiksmīgi atjaunināts",
+ "update_playtime_error": "Neizdevās atjaunināt spēles laiku",
+ "update_game_playtime": "Atjaunināt spēles laiku",
+ "manual_playtime_warning": "Jūsu stundas tiks atzīmētas kā manuāli atjauninātas. Šo darbību nevar atcelt.",
+ "manual_playtime_tooltip": "Šis spēles laiks tika atjaunināts manuāli",
+ "download_error_not_cached_on_torbox": "Šī lejupielāde nav pieejama TorBox, un TorBox lejupielādes statusu pagaidām nav iespējams iegūt.",
+ "download_error_not_cached_on_hydra": "Šī lejupielāde nav pieejama Nimbus.",
+ "game_removed_from_favorites": "Spēle dzēsta no izlases",
+ "game_added_to_favorites": "Spēle pievienota izlasei",
+ "game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
+ "game_added_to_pinned": "Spēle pievienota piespraustajiem",
+ "automatically_extract_downloaded_files": "Automātiska lejupielādēto failu izpakošana",
+ "create_start_menu_shortcut": "Izveidot saīsni sākuma izvēlnē",
+ "invalid_wine_prefix_path": "Nederīgs Wine prefiksa ceļš",
+ "invalid_wine_prefix_path_description": "Wine prefiksa ceļš nav derīgs. Lūdzu, pārbaudiet ceļu un mēģiniet vēlreiz.",
+ "missing_wine_prefix": "Wine prefikss ir nepieciešams, lai izveidotu rezerves kopiju Linux vidē",
+ "artifact_renamed": "Rezerves kopija veiksmīgi pārsaukta",
+ "rename_artifact": "Pārsaukt rezerves kopiju",
+ "rename_artifact_description": "Pārsauciet rezerves kopiju, piešķirot tai aprakstošāku nosaukumu.",
+ "artifact_name_label": "Rezerves kopijas nosaukums",
+ "artifact_name_placeholder": "Ievadiet nosaukumu rezerves kopijai",
+ "save_changes": "Saglabāt izmaiņas",
+ "required_field": "Šis lauks ir obligāts",
+ "max_length_field": "Šim laukam jābūt mazāk par {{length}} simboliem",
+ "freeze_backup": "Piespraust, lai to nepārrakstītu automātiskās rezerves kopijas",
+ "unfreeze_backup": "Atspraust",
+ "backup_frozen": "Rezerves kopija piesprausta",
+ "backup_unfrozen": "Rezerves kopija atsprausta",
+ "backup_freeze_failed": "Neizdevās piespraust rezerves kopiju",
+ "backup_freeze_failed_description": "Jums jāatstāj vismaz viens brīvs slots automātiskajām rezerves kopijām",
+ "edit_game_modal_button": "Rediģēt spēles detaļas",
+ "game_details": "Spēles detaļas",
+ "currency_symbol": "₽",
+ "currency_country": "ru",
+ "prices": "Cenas",
+ "no_prices_found": "Cenas nav atrastas",
+ "view_all_prices": "Noklikšķiniet, lai skatītu visas cenas",
+ "retail_price": "Mazumtirdzniecības cena",
+ "keyshop_price": "Atslēgu veikala cena",
+ "historical_retail": "Vēsturiskās mazumtirdzniecības cenas",
+ "historical_keyshop": "Vēsturiskās atslēgu veikalu cenas",
+ "language": "Valoda",
+ "caption": "Subtitri",
+ "audio": "Audio",
+ "filter_by_source": "Filtrēt pēc avota",
+ "no_repacks_found": "Avoti šai spēlei nav atrasti"
+ },
+ "activation": {
+ "title": "Aktivizēt Hydra",
+ "installation_id": "Instalācijas ID:",
+ "enter_activation_code": "Ievadiet savu aktivizācijas kodu",
+ "message": "Ja nezināt, kur to pieprasīt, jums to nevajadzētu būt.",
+ "activate": "Aktivizēt",
+ "loading": "Ielādēšana…"
+ },
+ "downloads": {
+ "resume": "Atsākt",
+ "pause": "Apturēt",
+ "eta": "Beigsies {{eta}}",
+ "paused": "Apturēts",
+ "verifying": "Pārbauda…",
+ "completed": "Pabeigts",
+ "removed": "Nav lejupielādēts",
+ "cancel": "Atcelt",
+ "filter": "Meklēt lejupielādētās spēles",
+ "remove": "Dzēst",
+ "downloading_metadata": "Lejupielādē metadatus…",
+ "deleting": "Dzēš instalētāju…",
+ "delete": "Dzēst instalētāju",
+ "delete_modal_title": "Vai esat pārliecināts?",
+ "delete_modal_description": "Tas dzēsīs visus instalētājus no jūsu datora",
+ "install": "Instalēt",
+ "download_in_progress": "Procesā",
+ "queued_downloads": "Lejupielādes rindā",
+ "downloads_completed": "Pabeigts",
+ "queued": "Rindā",
+ "no_downloads_title": "Šeit ir tik tukšs...",
+ "no_downloads_description": "Jūs vēl neko neesat lejupielādējis, izmantojot Hydra, bet nekad nav par vēlu sākt.",
+ "checking_files": "Pārbauda failus…",
+ "seeding": "Sēdēšana",
+ "stop_seeding": "Apturēt sēdēšanu",
+ "resume_seeding": "Turpināt sēdēšanu",
+ "options": "Pārvaldīt",
+ "extract": "Izpakot failus",
+ "extracting": "Izpako failus…"
+ },
+ "settings": {
+ "downloads_path": "Lejupielāžu ceļš",
+ "change": "Mainīt",
+ "notifications": "Paziņojumi",
+ "enable_download_notifications": "Pēc lejupielādes pabeigšanas",
+ "enable_repack_list_notifications": "Pievienojot jaunu repaku",
+ "real_debrid_api_token_label": "Real-Debrid API-atslēga",
+ "quit_app_instead_hiding": "Aizvērt lietotni, nevis minimizēt uz paplātes",
+ "launch_with_system": "Palaist Hydra kopā ar sistēmu",
+ "general": "Vispārīgi",
+ "behavior": "Uzvedība",
+ "download_sources": "Lejupielādes avoti",
+ "language": "Valoda",
+ "api_token": "API atslēga",
+ "enable_real_debrid": "Iespējot Real-Debrid",
+ "real_debrid_description": "Real-Debrid ir neierobežots lejupielādētājs, kas ļauj ātri lejupielādēt failus, kas izvietoti internetā, vai uzreiz pārsūtīt tos uz atskaņotāju, izmantojot privātu tīklu, kas ļauj apiet jebkādus bloķējumus.",
+ "debrid_invalid_token": "Nederīga API atslēga",
+ "debrid_api_token_hint": "API atslēgu var iegūt <0>šeit0>",
+ "real_debrid_free_account_error": "Kontam \"{{username}}\" nav abonementa. Lūdzu, iegādājieties Real-Debrid abonementu",
+ "debrid_linked_message": "Piesaistīts konts \"{{username}}\"",
+ "save_changes": "Saglabāt izmaiņas",
+ "changes_saved": "Izmaiņas veiksmīgi saglabātas",
+ "download_sources_description": "Hydra saņems lejupielādes saites no šiem avotiem. URL jāietver tieša saite uz .json failu ar lejupielādes saitēm.",
+ "validate_download_source": "Pārbaudīt",
+ "remove_download_source": "Dzēst",
+ "add_download_source": "Pievienot avotu",
+ "download_count_zero": "Sarakstā nav lejupielāžu",
+ "download_count_one": "{{countFormatted}} lejupielāde sarakstā",
+ "download_count_other": "{{countFormatted}} lejupielādes sarakstā",
+ "download_source_url": "Saite uz avotu",
+ "add_download_source_description": "Ievietojiet saiti uz .json failu",
+ "download_source_up_to_date": "Atjaunināts",
+ "download_source_errored": "Kļūda",
+ "sync_download_sources": "Atjaunināt avotus",
+ "removed_download_source": "Avots dzēsts",
+ "removed_download_sources": "Avoti dzēsti",
+ "cancel_button_confirmation_delete_all_sources": "Nē",
+ "confirm_button_confirmation_delete_all_sources": "Jā, dzēst visus",
+ "title_confirmation_delete_all_sources": "Dzēst visus avotus",
+ "description_confirmation_delete_all_sources": "Jūs dzēsīsiet visus avotus",
+ "button_delete_all_sources": "Dzēst visus avotus",
+ "added_download_source": "Avots pievienots",
+ "download_sources_synced": "Visi avoti atjaunināti",
+ "insert_valid_json_url": "Ievietojiet derīgu JSON faila URL",
+ "found_download_option_zero": "Nav atrasts lejupielādes variantu",
+ "found_download_option_one": "Atrasts {{countFormatted}} lejupielādes variants",
+ "found_download_option_other": "Atrasti {{countFormatted}} lejupielādes varianti",
+ "import": "Importēt",
+ "importing": "Importē...",
+ "public": "Publisks",
+ "private": "Privāts",
+ "friends_only": "Tikai draugiem",
+ "privacy": "Konfidencialitāte",
+ "profile_visibility": "Profila redzamība",
+ "profile_visibility_description": "Izvēlieties, kurš var redzēt jūsu profilu un bibliotēku",
+ "required_field": "Šis lauks ir obligāts",
+ "source_already_exists": "Šis avots jau ir pievienots",
+ "must_be_valid_url": "Avotam jābūt pareizam URL",
+ "blocked_users": "Bloķētie lietotāji",
+ "user_unblocked": "Lietotājs atbloķēts",
+ "enable_achievement_notifications": "Kad sasniegums ir atbloķēts",
+ "launch_minimized": "Palaist Hydra minimizētā veidā",
+ "disable_nsfw_alert": "Atspējot brīdinājumu par neķītru saturu",
+ "seed_after_download_complete": "Sēdēt pēc lejupielādes pabeigšanas",
+ "show_hidden_achievement_description": "Rādīt slēpto sasniegumu aprakstu pirms to iegūšanas",
+ "account": "Konts",
+ "no_users_blocked": "Jums nav bloķētu lietotāju",
+ "subscription_active_until": "Jūsu Hydra Cloud abonements ir aktīvs līdz {{date}}",
+ "manage_subscription": "Pārvaldīt abonementu",
+ "update_email": "Atjaunināt e-pastu",
+ "update_password": "Atjaunināt paroli",
+ "current_email": "Pašreizējais e-pasts:",
+ "no_email_account": "Jūs vēl neesat iestatījis e-pastu",
+ "account_data_updated_successfully": "Konta dati veiksmīgi atjaunināti",
+ "renew_subscription": "Atjaunot Hydra Cloud abonementu",
+ "subscription_expired_at": "Jūsu abonementa termiņš beidzās {{date}}",
+ "no_subscription": "Izbaudiet Hydra pilnībā",
+ "become_subscriber": "Kļūstiet par Hydra Cloud īpašnieku",
+ "subscription_renew_cancelled": "Automātiskā atjaunošana atspējota",
+ "subscription_renews_on": "Jūsu abonements tiek atjaunots {{date}}",
+ "bill_sent_until": "Jūsu nākamais rēķins tiks nosūtīts līdz šai dienai",
+ "no_themes": "Šķiet, ka jums vēl nav tēmu, bet neuztraucieties, noklikšķiniet šeit, lai izveidotu savu pirmo šedevru",
+ "editor_tab_code": "Kods",
+ "editor_tab_info": "Informācija",
+ "editor_tab_save": "Saglabāt",
+ "web_store": "Tīmekļa veikals",
+ "clear_themes": "Notīrīt",
+ "create_theme": "Izveidot",
+ "create_theme_modal_title": "Izveidot pielāgotu tēmu",
+ "create_theme_modal_description": "Izveidot jaunu tēmu, lai pielāgotu Hydra izskatu",
+ "theme_name": "Nosaukums",
+ "insert_theme_name": "Ievietot tēmas nosaukumu",
+ "set_theme": "Iestatīt tēmu",
+ "unset_theme": "Noņemt tēmu",
+ "delete_theme": "Dzēst tēmu",
+ "edit_theme": "Rediģēt tēmu",
+ "delete_all_themes": "Dzēst visas tēmas",
+ "delete_all_themes_description": "Tas dzēsīs visas jūsu pielāgotās tēmas",
+ "delete_theme_description": "Tas dzēsīs tēmu {{theme}}",
+ "cancel": "Atcelt",
+ "appearance": "Izskats",
+ "debrid": "Debrid",
+ "debrid_description": "Debrid servisi ir premium lejupielādētāji bez ierobežojumiem, kas ļauj ātri lejupielādēt failus no dažādiem failu apmaiņas servisiem, ierobežojoties tikai ar jūsu interneta ātrumu.",
+ "enable_torbox": "Iespējot TorBox",
+ "torbox_description": "TorBox ir jūsu premium serviss, kas konkurē pat ar labākajiem serveriem tirgū.",
+ "torbox_account_linked": "TorBox konts piesaistīts",
+ "create_real_debrid_account": "Noklikšķiniet šeit, ja jums vēl nav Real-Debrid konta",
+ "create_torbox_account": "Noklikšķiniet šeit, ja jums vēl nav TorBox konta",
+ "real_debrid_account_linked": "Real-Debrid konts piesaistīts",
+ "name_min_length": "Tēmas nosaukumam jābūt vismaz 3 simbolus garam",
+ "import_theme": "Importēt tēmu",
+ "import_theme_description": "Jūs importēsiet {{theme}} no tēmu veikala",
+ "error_importing_theme": "Kļūda importējot tēmu",
+ "theme_imported": "Tēma veiksmīgi importēta",
+ "enable_friend_request_notifications": "Saņemot draudzības pieprasījumu",
+ "enable_auto_install": "Automātiski lejupielādēt atjauninājumus",
+ "common_redist": "Bibliotēkas",
+ "common_redist_description": "Dažu spēļu palaišanai ir nepieciešamas bibliotēkas. Lai izvairītos no problēmām, ieteicams tās instalēt.",
+ "install_common_redist": "Instalēt",
+ "installing_common_redist": "Instalēšana…",
+ "show_download_speed_in_megabytes": "Rādīt lejupielādes ātrumu megabaitos sekundē",
+ "extract_files_by_default": "Izpakot failus pēc noklusējuma pēc lejupielādes",
+ "enable_steam_achievements": "Iespējot Steam sasniegumu meklēšanu",
+ "achievement_custom_notification_position": "Sasniegumu paziņojumu pozīcija",
+ "top-left": "Augšējais kreisais stūris",
+ "top-center": "Augšējais centrs",
+ "top-right": "Augšējais labais stūris",
+ "bottom-left": "Apakšējais kreisais stūris",
+ "bottom-center": "Apakšējais centrs",
+ "bottom-right": "Apakšējais labais stūris",
+ "enable_achievement_custom_notifications": "Iespējot sasniegumu paziņojumus",
+ "alignment": "Izlīdzināšana",
+ "variation": "Variācija",
+ "default": "Pēc noklusējuma",
+ "rare": "Retais",
+ "platinum": "Platīna",
+ "hidden": "Slēpts",
+ "test_notification": "Testa paziņojums",
+ "notification_preview": "Sasnieguma paziņojuma priekšskatījums",
+ "enable_friend_start_game_notifications": "Kad draugs sāk spēlēt spēli"
+ },
+ "notifications": {
+ "download_complete": "Lejupielāde pabeigta",
+ "game_ready_to_install": "{{title}} ir gatava instalēšanai",
+ "repack_list_updated": "Repaku saraksts atjaunināts",
+ "repack_count_one": "{{count}} repaks pievienots",
+ "repack_count_other": "{{count}} repaki pievienoti",
+ "new_update_available": "Pieejama jauna versija {{version}}",
+ "restart_to_install_update": "Pārstartējiet Hydra, lai instalētu atjauninājumu",
+ "notification_achievement_unlocked_title": "Sasniegums atbloķēts spēlei {{game}}",
+ "notification_achievement_unlocked_body": "tika atbloķēti {{achievement}} un citi {{count}}",
+ "new_friend_request_description": "{{displayName}} nosūtīja jums draudzības pieprasījumu",
+ "new_friend_request_title": "Jauns draudzības pieprasījums",
+ "extraction_complete": "Izpakošana pabeigta",
+ "game_extracted": "{{title}} veiksmīgi izpakots",
+ "friend_started_playing_game": "{{displayName}} sāka spēlēt spēli",
+ "test_achievement_notification_title": "Šis ir testa paziņojums",
+ "test_achievement_notification_description": "Diezgan forši, vai ne?"
+ },
+ "system_tray": {
+ "open": "Atvērt Hydra",
+ "quit": "Iziet"
+ },
+ "game_card": {
+ "available_one": "Pieejams",
+ "available_other": "Pieejams",
+ "no_downloads": "Nav pieejamu avotu",
+ "calculating": "Aprēķina"
+ },
+ "binary_not_found_modal": {
+ "title": "Programmas nav instalētas",
+ "description": "Wine vai Lutris nav atrasti",
+ "instructions": "Uzziniet pareizo veidu, kā instalēt kādu no tiem jūsu Linux distribūcijā, lai spēle varētu normāli darboties"
+ },
+ "modal": {
+ "close": "Aizvērt"
+ },
+ "forms": {
+ "toggle_password_visibility": "Rādīt paroli"
+ },
+ "user_profile": {
+ "amount_hours": "{{amount}} stundas",
+ "amount_minutes": "{{amount}} minūtes",
+ "amount_hours_short": "{{amount}}h",
+ "amount_minutes_short": "{{amount}}m",
+ "last_time_played": "Pēdējā spēle {{period}}",
+ "activity": "Nesenā aktivitāte",
+ "library": "Bibliotēka",
+ "pinned": "Piespraustās",
+ "achievements_earned": "Nopelnītie sasniegumi",
+ "played_recently": "Nesen spēlētās",
+ "playtime": "Spēles laiks",
+ "total_play_time": "Kopējais spēles laiks",
+ "manual_playtime_tooltip": "Spēles laiks tika atjaunināts manuāli",
+ "no_recent_activity_title": "Hmmmm... Šeit nav nekā",
+ "no_recent_activity_description": "Jūs sen neesat neko spēlējis. Ir laiks to mainīt!",
+ "display_name": "Parādāmais vārds",
+ "saving": "Saglabāšana",
+ "save": "Saglabāt",
+ "edit_profile": "Rediģēt profilu",
+ "saved_successfully": "Veiksmīgi saglabāts",
+ "try_again": "Lūdzu, mēģiniet vēlreiz",
+ "sign_out_modal_title": "Vai esat pārliecināts?",
+ "cancel": "Atcelt",
+ "successfully_signed_out": "Veiksmīga izrakstīšanās no konta",
+ "sign_out": "Iziet",
+ "playing_for": "Spēlēts {{amount}}",
+ "sign_out_modal_text": "Jūsu bibliotēka ir saistīta ar pašreizējo kontu. Izejot no sistēmas, jūsu bibliotēka kļūs nepieejama, un progress netiks saglabāts. Iziet?",
+ "add_friends": "Pievienot draugus",
+ "add": "Pievienot",
+ "friend_code": "Drauga kods",
+ "see_profile": "Skatīt profilu",
+ "sending": "Sūtīšana",
+ "friend_request_sent": "Draudzības pieprasījums nosūtīts",
+ "friends": "Draugi",
+ "friends_list": "Draugu saraksts",
+ "user_not_found": "Lietotājs nav atrasts",
+ "block_user": "Bloķēt lietotāju",
+ "add_friend": "Pievienot draugu",
+ "request_sent": "Pieprasījums nosūtīts",
+ "request_received": "Pieprasījums saņemts",
+ "accept_request": "Pieņemt pieprasījumu",
+ "ignore_request": "Ignorēt pieprasījumu",
+ "cancel_request": "Atcelt pieprasījumu",
+ "undo_friendship": "Dzēst draugu",
+ "request_accepted": "Pieprasījums pieņemts",
+ "user_blocked_successfully": "Lietotājs veiksmīgi bloķēts",
+ "user_block_modal_text": "{{displayName}} tiks bloķēts",
+ "blocked_users": "Bloķētie lietotāji",
+ "unblock": "Atbloķēt",
+ "no_friends_added": "Jūs vēl neesat pievienojis nevienu draugu",
+ "pending": "Gaida",
+ "no_pending_invites": "Jums nav pieprasījumu, kas gaida atbildi",
+ "no_blocked_users": "Jūs neesat bloķējis nevienu lietotāju",
+ "friend_code_copied": "Drauga kods kopēts",
+ "undo_friendship_modal_text": "Tas atcels jūsu draudzību ar {{displayName}}.",
+ "privacy_hint": "Lai norādītu, kurš to var redzēt, dodieties uz <0>Iestatījumiem0>.",
+ "locked_profile": "Šis profils ir privāts",
+ "image_process_failure": "Attēlu apstrādes kļūme",
+ "required_field": "Šis lauks ir obligāts",
+ "displayname_min_length": "Parādāmam vārdam jābūt vismaz 3 simbolus garam.",
+ "displayname_max_length": "Parādāmam vārdam jābūt ne vairāk kā 50 simboliem.",
+ "report_profile": "Ziņot par šo profilu",
+ "report_reason": "Kāpēc jūs ziņojat par šo profilu?",
+ "report_description": "Papildu informācija",
+ "report_description_placeholder": "Papildu informācija",
+ "report": "Ziņot",
+ "report_reason_hate": "Naida runa",
+ "report_reason_sexual_content": "Seksuāls saturs",
+ "report_reason_violence": "Vardarbība",
+ "report_reason_spam": "Surogātpasts",
+ "report_reason_other": "Cits",
+ "profile_reported": "Ziņojums par profilu nosūtīts",
+ "your_friend_code": "Jūsu drauga kods:",
+ "upload_banner": "Augšupielādēt reklāmkarogu",
+ "uploading_banner": "Augšupielādē reklāmkarogu...",
+ "background_image_updated": "Fona attēls atjaunināts",
+ "stats": "Statistika",
+ "achievements": "Sasniegumi",
+ "games": "Spēles",
+ "top_percentile": "Top {{percentile}}%",
+ "ranking_updated_weekly": "Reitings tiek atjaunināts katru nedēļu",
+ "playing": "Spēlē {{game}}",
+ "achievements_unlocked": "Sasniegumi atbloķēti",
+ "earned_points": "Nopelnītie punkti:",
+ "show_achievements_on_profile": "Rādīt savus sasniegumus profilā",
+ "show_points_on_profile": "Rādīt nopelnītos punktus savā profilā",
+ "error_adding_friend": "Neizdevās nosūtīt draudzības pieprasījumu. Lūdzu, pārbaudiet drauga kodu",
+ "friend_code_length_error": "Drauga kodam jāsatur 8 simboli",
+ "game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
+ "game_added_to_pinned": "Spēle pievienota piespraustajiem",
+ "karma": "Karma",
+ "karma_count": "karma",
+ "karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
+ },
+ "achievement": {
+ "achievement_unlocked": "Sasniegums atbloķēts",
+ "user_achievements": "{{displayName}} sasniegumi",
+ "your_achievements": "Jūsu sasniegumi",
+ "unlocked_at": "Atbloķēts: {{date}}",
+ "subscription_needed": "Šī satura apskatīšanai nepieciešams Hydra Cloud abonements",
+ "new_achievements_unlocked": "Atbloķēti {{achievementCount}} jauni sasniegumi no {{gameCount}} spēlēm",
+ "achievement_progress": "{{unlockedCount}}/{{totalCount}} sasniegumi",
+ "achievements_unlocked_for_game": "Atbloķēti {{achievementCount}} jauni sasniegumi spēlei {{gameTitle}}",
+ "hidden_achievement_tooltip": "Šis ir slēpts sasniegums",
+ "achievement_earn_points": "Nopelniet {{points}} punktus ar šo sasniegumu",
+ "earned_points": "Nopelnītie punkti:",
+ "available_points": "Pieejamie punkti:",
+ "how_to_earn_achievements_points": "Kā nopelnīt sasniegumu punktus?"
+ },
+ "hydra_cloud": {
+ "subscription_tour_title": "Hydra Cloud abonements",
+ "subscribe_now": "Abonējiet tūlīt",
+ "cloud_saving": "Saglabāšana mākonī",
+ "cloud_achievements": "Saglabājiet savus sasniegumus mākonī",
+ "animated_profile_picture": "Animētas profila bildes",
+ "premium_support": "Premium atbalsts",
+ "show_and_compare_achievements": "Rādiet un salīdziniet savus sasniegumus ar citu lietotāju sasniegumiem",
+ "animated_profile_banner": "Animēts profila reklāmkarogs",
+ "hydra_cloud": "Hydra Cloud",
+ "hydra_cloud_feature_found": "Jūs tikko atklājāt Hydra Cloud funkciju!",
+ "learn_more": "Uzzināt vairāk",
+ "debrid_description": "Lejupielādējiet 4 reizes ātrāk ar Nimbus"
+ }
+}
diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json
index 4ea77015..c9e908ac 100755
--- a/src/locales/pt-BR/translation.json
+++ b/src/locales/pt-BR/translation.json
@@ -416,6 +416,9 @@
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
+ "adding": "Adicionando…",
+ "failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.",
+ "download_source_already_exists": "Esta URL de fonte de download já existe.",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
@@ -423,7 +426,13 @@
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
+ "download_source_pending_matching": "Importando em breve",
+ "download_source_matched": "Sincronizada",
+ "download_source_matching": "Sincronizando",
+ "download_source_failed": "Erro",
+ "download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
+ "download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json
index 962504d4..2894cf65 100644
--- a/src/locales/pt-PT/translation.json
+++ b/src/locales/pt-PT/translation.json
@@ -252,7 +252,13 @@
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
+ "download_source_pending_matching": "A atualizar em breve",
+ "download_source_matched": "Atualizado",
+ "download_source_matching": "A atualizar",
+ "download_source_failed": "Erro",
+ "download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
+ "download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, apague tudo",
diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts
index de1d2b1f..0e45f886 100644
--- a/src/main/events/catalogue/get-game-assets.ts
+++ b/src/main/events/catalogue/get-game-assets.ts
@@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
+ if (shop === "custom") {
+ return null;
+ }
+
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);
diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts
index d6d27b9c..1a7fc455 100644
--- a/src/main/events/catalogue/get-game-shop-details.ts
+++ b/src/main/events/catalogue/get-game-shop-details.ts
@@ -26,6 +26,8 @@ const getGameShopDetails = async (
shop: GameShop,
language: string
): Promise => {
+ if (shop === "custom") return null;
+
if (shop === "steam") {
const [cachedData, cachedAssets] = await Promise.all([
gamesShopCacheSublevel.get(
diff --git a/src/main/events/catalogue/get-game-stats.ts b/src/main/events/catalogue/get-game-stats.ts
index b836531d..b7b7125c 100644
--- a/src/main/events/catalogue/get-game-stats.ts
+++ b/src/main/events/catalogue/get-game-stats.ts
@@ -10,6 +10,10 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
+ if (shop === "custom") {
+ return null;
+ }
+
const cachedStats = await gamesStatsCacheSublevel.get(
levelKeys.game(shop, objectId)
);
diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts
index e51cae3e..bea009cb 100644
--- a/src/main/events/download-sources/add-download-source.ts
+++ b/src/main/events/download-sources/add-download-source.ts
@@ -1,76 +1,50 @@
import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { HydraApi, logger } from "@main/services";
-import { importDownloadSourceToLocal } from "./helpers";
+import { HydraApi } from "@main/services/hydra-api";
+import { downloadSourcesSublevel } from "@main/level";
+import type { DownloadSource } from "@types";
+import { logger } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
- const result = await importDownloadSourceToLocal(url, true);
- if (!result) {
- throw new Error("Failed to import download source");
- }
+ try {
+ const existingSources = await downloadSourcesSublevel.values().all();
+ const urlExists = existingSources.some((source) => source.url === url);
- // Verify that repacks were actually written to the database (read-after-write)
- // This ensures all async operations are complete before proceeding
- let repackCount = 0;
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === result.id) {
- repackCount++;
+ if (urlExists) {
+ throw new Error("Download source with this URL already exists");
}
- }
- await HydraApi.post("/profile/download-sources", {
- urls: [url],
- });
+ const downloadSource = await HydraApi.post(
+ "/download-sources",
+ {
+ url,
+ },
+ { needsAuth: false }
+ );
- const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
- "/download-sources",
- {
- objectIds: result.objectIds,
- },
- { needsAuth: false }
- );
+ if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
+ try {
+ await HydraApi.post("/profile/download-sources", {
+ urls: [url],
+ });
+ } catch (error) {
+ logger.error("Failed to add download source to profile:", error);
+ }
+ }
- // Update the source with fingerprint
- const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
- if (updatedSource) {
- await downloadSourcesSublevel.put(`${result.id}`, {
- ...updatedSource,
- fingerprint,
- updatedAt: new Date(),
+ await downloadSourcesSublevel.put(downloadSource.id, {
+ ...downloadSource,
+ isRemote: true,
+ createdAt: new Date().toISOString(),
});
- }
- // Final verification: ensure the source with fingerprint is persisted
- const finalSource = await downloadSourcesSublevel.get(`${result.id}`);
- if (!finalSource || !finalSource.fingerprint) {
- throw new Error("Failed to persist download source with fingerprint");
+ return downloadSource;
+ } catch (error) {
+ logger.error("Failed to add download source:", error);
+ throw error;
}
-
- // Verify repacks still exist after fingerprint update
- let finalRepackCount = 0;
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === result.id) {
- finalRepackCount++;
- }
- }
-
- if (finalRepackCount !== repackCount) {
- logger.warn(
- `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
- );
- } else {
- logger.info(
- `Final verification passed: ${finalRepackCount} repacks confirmed`
- );
- }
-
- return {
- ...result,
- fingerprint,
- };
};
registerEvent("addDownloadSource", addDownloadSource);
diff --git a/src/main/events/download-sources/check-download-source-exists.ts b/src/main/events/download-sources/check-download-source-exists.ts
deleted file mode 100644
index 36dd88ce..00000000
--- a/src/main/events/download-sources/check-download-source-exists.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel } from "@main/level";
-
-const checkDownloadSourceExists = async (
- _event: Electron.IpcMainInvokeEvent,
- url: string
-): Promise => {
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- if (source.url === url) {
- return true;
- }
- }
-
- return false;
-};
-
-registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);
diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts
deleted file mode 100644
index cbf3958f..00000000
--- a/src/main/events/download-sources/delete-all-download-sources.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { invalidateIdCaches } from "./helpers";
-
-const deleteAllDownloadSources = async (
- _event: Electron.IpcMainInvokeEvent
-) => {
- await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
-
- invalidateIdCaches();
-};
-
-registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);
diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts
deleted file mode 100644
index 5322b96c..00000000
--- a/src/main/events/download-sources/delete-download-source.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { invalidateIdCaches } from "./helpers";
-
-const deleteDownloadSource = async (
- _event: Electron.IpcMainInvokeEvent,
- id: number
-) => {
- const repacksToDelete: string[] = [];
-
- for await (const [key, repack] of repacksSublevel.iterator()) {
- if (repack.downloadSourceId === id) {
- repacksToDelete.push(key);
- }
- }
-
- const batch = repacksSublevel.batch();
- for (const key of repacksToDelete) {
- batch.del(key);
- }
- await batch.write();
-
- await downloadSourcesSublevel.del(`${id}`);
-
- invalidateIdCaches();
-};
-
-registerEvent("deleteDownloadSource", deleteDownloadSource);
diff --git a/src/main/events/download-sources/get-download-sources-list.ts b/src/main/events/download-sources/get-download-sources-list.ts
deleted file mode 100644
index db26ad01..00000000
--- a/src/main/events/download-sources/get-download-sources-list.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel, DownloadSource } from "@main/level";
-
-const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => {
- const sources: DownloadSource[] = [];
-
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- sources.push(source);
- }
-
- // Sort by createdAt descending
- sources.sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
- );
-
- return sources;
-};
-
-registerEvent("getDownloadSourcesList", getDownloadSourcesList);
diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts
index bbebd06c..48583d9e 100644
--- a/src/main/events/download-sources/get-download-sources.ts
+++ b/src/main/events/download-sources/get-download-sources.ts
@@ -1,8 +1,10 @@
-import { HydraApi } from "@main/services";
+import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
+import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
- return HydraApi.get("/profile/download-sources");
+ const allSources = await downloadSourcesSublevel.values().all();
+ return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);
diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts
deleted file mode 100644
index 2e7489fd..00000000
--- a/src/main/events/download-sources/helpers.ts
+++ /dev/null
@@ -1,367 +0,0 @@
-import axios from "axios";
-import { z } from "zod";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { DownloadSourceStatus } from "@shared";
-import crypto from "node:crypto";
-import { logger, ResourceCache } from "@main/services";
-
-export const downloadSourceSchema = z.object({
- name: z.string().max(255),
- downloads: z.array(
- z.object({
- title: z.string().max(255),
- uris: z.array(z.string()),
- uploadDate: z.string().max(255),
- fileSize: z.string().max(255),
- })
- ),
-});
-
-export type TitleHashMapping = Record;
-
-let titleHashMappingCache: TitleHashMapping | null = null;
-
-export const getTitleHashMapping = async (): Promise => {
- if (titleHashMappingCache) {
- return titleHashMappingCache;
- }
-
- try {
- const cached =
- ResourceCache.getCachedData("sources-manifest");
- if (cached) {
- titleHashMappingCache = cached;
- return cached;
- }
-
- const fetched = await ResourceCache.fetchAndCache(
- "sources-manifest",
- "https://cdn.losbroxas.org/sources-manifest.json",
- 10000
- );
- titleHashMappingCache = fetched;
- return fetched;
- } catch (error) {
- logger.error("Failed to fetch title hash mapping:", error);
- return {} as TitleHashMapping;
- }
-};
-
-export const hashTitle = (title: string): string => {
- return crypto.createHash("sha256").update(title).digest("hex");
-};
-
-export type SteamGamesByLetter = Record;
-export type FormattedSteamGame = {
- id: string;
- name: string;
- formattedName: string;
-};
-export type FormattedSteamGamesByLetter = Record;
-
-export const formatName = (name: string) => {
- return name
- .normalize("NFD")
- .replaceAll(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replaceAll(/[^a-z0-9]/g, "");
-};
-
-export const formatRepackName = (name: string) => {
- return formatName(name.replace("[DL]", ""));
-};
-
-interface DownloadSource {
- id: number;
- url: string;
- name: string;
- etag: string | null;
- status: number;
- downloadCount: number;
- objectIds: string[];
- fingerprint?: string;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const getDownloadSourcesMap = async (): Promise<
- Map
-> => {
- const map = new Map();
- for await (const [key, source] of downloadSourcesSublevel.iterator()) {
- map.set(key, source);
- }
-
- return map;
-};
-
-export const checkUrlExists = async (url: string): Promise => {
- const sources = await getDownloadSourcesMap();
- for (const source of sources.values()) {
- if (source.url === url) {
- return true;
- }
- }
- return false;
-};
-
-let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
-
-export const getSteamGames = async (): Promise => {
- if (steamGamesFormattedCache) {
- return steamGamesFormattedCache;
- }
-
- let steamGames: SteamGamesByLetter;
-
- const cached = ResourceCache.getCachedData(
- "steam-games-by-letter"
- );
- if (cached) {
- steamGames = cached;
- } else {
- steamGames = await ResourceCache.fetchAndCache(
- "steam-games-by-letter",
- `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
- );
- }
-
- const formattedData: FormattedSteamGamesByLetter = {};
- for (const [letter, games] of Object.entries(steamGames)) {
- formattedData[letter] = games.map((game) => ({
- ...game,
- formattedName: formatName(game.name),
- }));
- }
-
- steamGamesFormattedCache = formattedData;
- return formattedData;
-};
-
-export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
-
-export interface SublevelWithId {
- iterator: () => SublevelIterator;
-}
-
-let maxRepackId: number | null = null;
-let maxDownloadSourceId: number | null = null;
-
-export const getNextId = async (sublevel: SublevelWithId): Promise => {
- const isRepackSublevel = sublevel === repacksSublevel;
- const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
-
- if (isRepackSublevel && maxRepackId !== null) {
- return ++maxRepackId;
- }
-
- if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
- return ++maxDownloadSourceId;
- }
-
- let maxId = 0;
- for await (const [, value] of sublevel.iterator()) {
- if (value.id > maxId) {
- maxId = value.id;
- }
- }
-
- if (isRepackSublevel) {
- maxRepackId = maxId;
- } else if (isDownloadSourceSublevel) {
- maxDownloadSourceId = maxId;
- }
-
- return maxId + 1;
-};
-
-export const invalidateIdCaches = () => {
- maxRepackId = null;
- maxDownloadSourceId = null;
-};
-
-export const addNewDownloads = async (
- downloadSource: { id: number; name: string },
- downloads: z.infer["downloads"],
- steamGames: FormattedSteamGamesByLetter
-) => {
- const now = new Date();
- const objectIdsOnSource = new Set();
-
- let nextRepackId = await getNextId(repacksSublevel);
-
- const batch = repacksSublevel.batch();
-
- const titleHashMapping = await getTitleHashMapping();
- let hashMatchCount = 0;
- let fuzzyMatchCount = 0;
- let noMatchCount = 0;
-
- for (const download of downloads) {
- let objectIds: string[] = [];
- let usedHashMatch = false;
-
- const titleHash = hashTitle(download.title);
- const steamIdsFromHash = titleHashMapping[titleHash];
-
- if (steamIdsFromHash && steamIdsFromHash.length > 0) {
- hashMatchCount++;
- usedHashMatch = true;
-
- objectIds = steamIdsFromHash.map(String);
- }
-
- if (!usedHashMatch) {
- let gamesInSteam: FormattedSteamGame[] = [];
- const formattedTitle = formatRepackName(download.title);
-
- if (formattedTitle && formattedTitle.length > 0) {
- const [firstLetter] = formattedTitle;
- const games = steamGames[firstLetter] || [];
-
- gamesInSteam = games.filter((game) =>
- formattedTitle.startsWith(game.formattedName)
- );
-
- if (gamesInSteam.length === 0) {
- gamesInSteam = games.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- }
-
- if (gamesInSteam.length === 0) {
- for (const letter of Object.keys(steamGames)) {
- const letterGames = steamGames[letter] || [];
- const matches = letterGames.filter(
- (game) =>
- formattedTitle.includes(game.formattedName) ||
- game.formattedName.includes(formattedTitle)
- );
- if (matches.length > 0) {
- gamesInSteam = matches;
- break;
- }
- }
- }
-
- if (gamesInSteam.length > 0) {
- fuzzyMatchCount++;
- objectIds = gamesInSteam.map((game) => String(game.id));
- } else {
- noMatchCount++;
- }
- } else {
- noMatchCount++;
- }
- }
-
- for (const id of objectIds) {
- objectIdsOnSource.add(id);
- }
-
- const repack = {
- id: nextRepackId++,
- objectIds: objectIds,
- title: download.title,
- uris: download.uris,
- fileSize: download.fileSize,
- repacker: downloadSource.name,
- uploadDate: download.uploadDate,
- downloadSourceId: downloadSource.id,
- createdAt: now,
- updatedAt: now,
- };
-
- batch.put(`${repack.id}`, repack);
- }
-
- await batch.write();
-
- logger.info(
- `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
- );
-
- const existingSource = await downloadSourcesSublevel.get(
- `${downloadSource.id}`
- );
- if (existingSource) {
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...existingSource,
- objectIds: Array.from(objectIdsOnSource),
- });
- }
-
- return Array.from(objectIdsOnSource);
-};
-
-export const importDownloadSourceToLocal = async (
- url: string,
- throwOnDuplicate = false
-) => {
- const urlExists = await checkUrlExists(url);
- if (urlExists) {
- if (throwOnDuplicate) {
- throw new Error("Download source with this URL already exists");
- }
- return null;
- }
-
- const response = await axios.get>(url);
-
- const steamGames = await getSteamGames();
-
- const now = new Date();
-
- const nextId = await getNextId(downloadSourcesSublevel);
-
- const downloadSource = {
- id: nextId,
- url,
- name: response.data.name,
- etag: response.headers["etag"] || null,
- status: DownloadSourceStatus.UpToDate,
- downloadCount: response.data.downloads.length,
- objectIds: [],
- createdAt: now,
- updatedAt: now,
- };
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
-
- const objectIds = await addNewDownloads(
- downloadSource,
- response.data.downloads,
- steamGames
- );
-
- // Invalidate ID caches after creating new repacks to prevent ID collisions
- invalidateIdCaches();
-
- return {
- ...downloadSource,
- objectIds,
- };
-};
-
-export const updateDownloadSourcePreservingTimestamp = async (
- existingSource: DownloadSource,
- url: string
-) => {
- const response = await axios.get>(url);
-
- const updatedSource = {
- ...existingSource,
- name: response.data.name,
- etag: response.headers["etag"] || null,
- status: DownloadSourceStatus.UpToDate,
- downloadCount: response.data.downloads.length,
- updatedAt: new Date(),
- // Preserve the original createdAt timestamp
- };
-
- await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
-
- return updatedSource;
-};
diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts
index bcc66998..9caeaba5 100644
--- a/src/main/events/download-sources/remove-download-source.ts
+++ b/src/main/events/download-sources/remove-download-source.ts
@@ -1,18 +1,27 @@
import { HydraApi } from "@main/services";
+import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
- url?: string,
- removeAll = false
+ removeAll = false,
+ downloadSourceId?: string
) => {
const params = new URLSearchParams({
all: removeAll.toString(),
});
- if (url) params.set("url", url);
+ if (downloadSourceId) params.set("downloadSourceId", downloadSourceId);
- return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
+ if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
+ void HydraApi.delete(`/profile/download-sources?${params.toString()}`);
+ }
+
+ if (removeAll) {
+ await downloadSourcesSublevel.clear();
+ } else if (downloadSourceId) {
+ await downloadSourcesSublevel.del(downloadSourceId);
+ }
};
registerEvent("removeDownloadSource", removeDownloadSource);
diff --git a/src/main/events/download-sources/sync-download-sources-from-api.ts b/src/main/events/download-sources/sync-download-sources-from-api.ts
deleted file mode 100644
index 3cac8819..00000000
--- a/src/main/events/download-sources/sync-download-sources-from-api.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { HydraApi, logger } from "@main/services";
-import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
-
-export const syncDownloadSourcesFromApi = async () => {
- try {
- const apiSources = await HydraApi.get<
- { url: string; createdAt: string; updatedAt: string }[]
- >("/profile/download-sources");
-
- for (const apiSource of apiSources) {
- const exists = await checkUrlExists(apiSource.url);
- if (!exists) {
- await importDownloadSourceToLocal(apiSource.url, false);
- }
- }
- } catch (error) {
- logger.error("Failed to sync download sources from API:", error);
- }
-};
diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts
index 88861074..68a6be3f 100644
--- a/src/main/events/download-sources/sync-download-sources.ts
+++ b/src/main/events/download-sources/sync-download-sources.ts
@@ -1,114 +1,28 @@
+import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
-import axios, { AxiosError } from "axios";
-import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
-import { DownloadSourceStatus } from "@shared";
-import {
- invalidateIdCaches,
- downloadSourceSchema,
- getSteamGames,
- addNewDownloads,
-} from "./helpers";
+import { downloadSourcesSublevel } from "@main/level";
+import type { DownloadSource } from "@types";
-const syncDownloadSources = async (
- _event: Electron.IpcMainInvokeEvent
-): Promise => {
- let newRepacksCount = 0;
+const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
+ const downloadSources = await downloadSourcesSublevel.values().all();
- try {
- const downloadSources: Array<{
- id: number;
- url: string;
- name: string;
- etag: string | null;
- status: number;
- downloadCount: number;
- objectIds: string[];
- fingerprint?: string;
- createdAt: Date;
- updatedAt: Date;
- }> = [];
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- downloadSources.push(source);
- }
+ const response = await HydraApi.post(
+ "/download-sources/sync",
+ {
+ ids: downloadSources.map((downloadSource) => downloadSource.id),
+ },
+ { needsAuth: false }
+ );
- const existingRepacks: Array<{
- id: number;
- title: string;
- uris: string[];
- repacker: string;
- fileSize: string | null;
- objectIds: string[];
- uploadDate: Date | string | null;
- downloadSourceId: number;
- createdAt: Date;
- updatedAt: Date;
- }> = [];
- for await (const [, repack] of repacksSublevel.iterator()) {
- existingRepacks.push(repack);
- }
-
- // Handle sources with missing fingerprints individually, don't delete all sources
- const sourcesWithFingerprints = downloadSources.filter(
- (source) => source.fingerprint
- );
- const sourcesWithoutFingerprints = downloadSources.filter(
- (source) => !source.fingerprint
+ for (const downloadSource of response) {
+ const existingDownloadSource = downloadSources.find(
+ (source) => source.id === downloadSource.id
);
- // For sources without fingerprints, just continue with normal sync
- // They will get fingerprints updated later by updateMissingFingerprints
- const allSourcesToSync = [
- ...sourcesWithFingerprints,
- ...sourcesWithoutFingerprints,
- ];
-
- for (const downloadSource of allSourcesToSync) {
- const headers: Record = {};
-
- if (downloadSource.etag) {
- headers["If-None-Match"] = downloadSource.etag;
- }
-
- try {
- const response = await axios.get(downloadSource.url, {
- headers,
- });
-
- const source = downloadSourceSchema.parse(response.data);
- const steamGames = await getSteamGames();
-
- const repacks = source.downloads.filter(
- (download) =>
- !existingRepacks.some((repack) => repack.title === download.title)
- );
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...downloadSource,
- etag: response.headers["etag"] || null,
- downloadCount: source.downloads.length,
- status: DownloadSourceStatus.UpToDate,
- });
-
- await addNewDownloads(downloadSource, repacks, steamGames);
-
- newRepacksCount += repacks.length;
- } catch (err: unknown) {
- const isNotModified = (err as AxiosError).response?.status === 304;
-
- await downloadSourcesSublevel.put(`${downloadSource.id}`, {
- ...downloadSource,
- status: isNotModified
- ? DownloadSourceStatus.UpToDate
- : DownloadSourceStatus.Errored,
- });
- }
- }
-
- invalidateIdCaches();
-
- return newRepacksCount;
- } catch (err) {
- return -1;
+ await downloadSourcesSublevel.put(downloadSource.id, {
+ ...existingDownloadSource,
+ ...downloadSource,
+ });
}
};
diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts
deleted file mode 100644
index 7fd43c63..00000000
--- a/src/main/events/download-sources/update-missing-fingerprints.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { registerEvent } from "../register-event";
-import { downloadSourcesSublevel } from "@main/level";
-import { HydraApi, logger } from "@main/services";
-
-const updateMissingFingerprints = async (
- _event: Electron.IpcMainInvokeEvent
-): Promise => {
- const sourcesNeedingFingerprints: Array<{
- id: number;
- objectIds: string[];
- }> = [];
-
- for await (const [, source] of downloadSourcesSublevel.iterator()) {
- if (
- !source.fingerprint &&
- source.objectIds &&
- source.objectIds.length > 0
- ) {
- sourcesNeedingFingerprints.push({
- id: source.id,
- objectIds: source.objectIds,
- });
- }
- }
-
- if (sourcesNeedingFingerprints.length === 0) {
- return 0;
- }
-
- logger.info(
- `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
- );
-
- await Promise.all(
- sourcesNeedingFingerprints.map(async (source) => {
- try {
- const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
- "/download-sources",
- {
- objectIds: source.objectIds,
- },
- { needsAuth: false }
- );
-
- const existingSource = await downloadSourcesSublevel.get(
- `${source.id}`
- );
- if (existingSource) {
- await downloadSourcesSublevel.put(`${source.id}`, {
- ...existingSource,
- fingerprint,
- updatedAt: new Date(),
- });
- }
- } catch (error) {
- logger.error(
- `Failed to update fingerprint for source ${source.id}:`,
- error
- );
- }
- })
- );
-
- return sourcesNeedingFingerprints.length;
-};
-
-registerEvent("updateMissingFingerprints", updateMissingFingerprints);
diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts
deleted file mode 100644
index 2bc86df7..00000000
--- a/src/main/events/download-sources/validate-download-source.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { registerEvent } from "../register-event";
-import axios from "axios";
-import { z } from "zod";
-
-const downloadSourceSchema = z.object({
- name: z.string().max(255),
- downloads: z.array(
- z.object({
- title: z.string().max(255),
- uris: z.array(z.string()),
- uploadDate: z.string().max(255),
- fileSize: z.string().max(255),
- })
- ),
-});
-
-const validateDownloadSource = async (
- _event: Electron.IpcMainInvokeEvent,
- url: string
-) => {
- const response = await axios.get>(url);
-
- const { name } = downloadSourceSchema.parse(response.data);
-
- return {
- name,
- etag: response.headers["etag"] || null,
- downloadCount: response.data.downloads.length,
- };
-};
-
-registerEvent("validateDownloadSource", validateDownloadSource);
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index 8d21aa11..0ab5499a 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -63,14 +63,7 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
-import "./download-sources/update-missing-fingerprints";
-import "./download-sources/delete-download-source";
-import "./download-sources/delete-all-download-sources";
-import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
-import "./download-sources/get-download-sources-list";
-import "./download-sources/check-download-source-exists";
-import "./repacks/get-all-repacks";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts
index f2f2dd40..6a90087e 100644
--- a/src/main/events/library/add-custom-game-to-library.ts
+++ b/src/main/events/library/add-custom-game-to-library.ts
@@ -37,6 +37,7 @@ const addCustomGameToLibrary = async (
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
+ downloadSources: [],
};
await gamesShopAssetsSublevel.put(gameKey, assets);
diff --git a/src/main/events/library/add-game-to-favorites.ts b/src/main/events/library/add-game-to-favorites.ts
index 68c81abb..53985a09 100644
--- a/src/main/events/library/add-game-to-favorites.ts
+++ b/src/main/events/library/add-game-to-favorites.ts
@@ -13,7 +13,9 @@ const addGameToFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
- HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
+ if (shop !== "custom") {
+ HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
+ }
try {
await gamesSublevel.put(gameKey, {
diff --git a/src/main/events/library/remove-game-from-favorites.ts b/src/main/events/library/remove-game-from-favorites.ts
index f06f55ce..7c79cbf4 100644
--- a/src/main/events/library/remove-game-from-favorites.ts
+++ b/src/main/events/library/remove-game-from-favorites.ts
@@ -13,7 +13,11 @@ const removeGameFromFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
- HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {});
+ if (shop !== "custom") {
+ HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(
+ () => {}
+ );
+ }
try {
await gamesSublevel.put(gameKey, {
diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts
index fbb60ab2..95133c70 100644
--- a/src/main/events/library/remove-game-from-library.ts
+++ b/src/main/events/library/remove-game-from-library.ts
@@ -84,7 +84,7 @@ const removeGameFromLibrary = async (
await resetShopAssets(gameKey);
}
- if (game?.remoteId) {
+ if (game.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}
diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts
deleted file mode 100644
index 6eb83a39..00000000
--- a/src/main/events/repacks/get-all-repacks.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { registerEvent } from "../register-event";
-import { repacksSublevel, GameRepack } from "@main/level";
-
-const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => {
- const repacks: GameRepack[] = [];
-
- for await (const [, repack] of repacksSublevel.iterator()) {
- if (Array.isArray(repack.objectIds)) {
- repacks.push(repack);
- }
- }
-
- return repacks;
-};
-
-registerEvent("getAllRepacks", getAllRepacks);
diff --git a/src/main/helpers/migrate-download-sources.ts b/src/main/helpers/migrate-download-sources.ts
new file mode 100644
index 00000000..fd627f20
--- /dev/null
+++ b/src/main/helpers/migrate-download-sources.ts
@@ -0,0 +1,27 @@
+import { downloadSourcesSublevel } from "@main/level";
+import { HydraApi } from "@main/services/hydra-api";
+import { DownloadSource } from "@types";
+
+export const migrateDownloadSources = async () => {
+ const downloadSources = downloadSourcesSublevel.iterator();
+
+ for await (const [key, value] of downloadSources) {
+ if (!value.isRemote) {
+ const downloadSource = await HydraApi.post(
+ "/download-sources",
+ {
+ url: value.url,
+ },
+ { needsAuth: false }
+ );
+
+ await downloadSourcesSublevel.put(downloadSource.id, {
+ ...downloadSource,
+ isRemote: true,
+ createdAt: new Date().toISOString(),
+ });
+
+ await downloadSourcesSublevel.del(key);
+ }
+ }
+};
diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts
index 59104e3c..b6cdad0b 100644
--- a/src/main/level/sublevels/download-sources.ts
+++ b/src/main/level/sublevels/download-sources.ts
@@ -1,18 +1,6 @@
import { db } from "../level";
import { levelKeys } from "./keys";
-
-export interface DownloadSource {
- id: number;
- name: string;
- url: string;
- status: number;
- objectIds: string[];
- downloadCount: number;
- fingerprint?: string;
- etag: string | null;
- createdAt: Date;
- updatedAt: Date;
-}
+import type { DownloadSource } from "@types";
export const downloadSourcesSublevel = db.sublevel(
levelKeys.downloadSources,
diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts
index 7224fc64..3619ae26 100644
--- a/src/main/level/sublevels/index.ts
+++ b/src/main/level/sublevels/index.ts
@@ -7,4 +7,3 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
-export * from "./repacks";
diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts
index 6faacd52..a28690b2 100644
--- a/src/main/level/sublevels/keys.ts
+++ b/src/main/level/sublevels/keys.ts
@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
- repacks: "repacks",
};
diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts
deleted file mode 100644
index 6257665b..00000000
--- a/src/main/level/sublevels/repacks.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { db } from "../level";
-import { levelKeys } from "./keys";
-
-export interface GameRepack {
- id: number;
- title: string;
- uris: string[];
- repacker: string;
- fileSize: string | null;
- objectIds: string[];
- uploadDate: Date | string | null;
- downloadSourceId: number;
- createdAt: Date;
- updatedAt: Date;
-}
-
-export const repacksSublevel = db.sublevel(
- levelKeys.repacks,
- {
- valueEncoding: "json",
- }
-);
diff --git a/src/main/main.ts b/src/main/main.ts
index 5eecb101..ffb8f8a9 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -16,15 +16,12 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
- ResourceCache,
} from "@main/services";
+import { migrateDownloadSources } from "./helpers/migrate-download-sources";
export const loadState = async () => {
await Lock.acquireLock();
- ResourceCache.initialize();
- await ResourceCache.updateResourcesOnStartup();
-
const userPreferences = await db.get(
levelKeys.userPreferences,
{
@@ -53,8 +50,12 @@ export const loadState = async () => {
DeckyPlugin.checkAndUpdateIfOutdated();
}
- await HydraApi.setupApi().then(() => {
+ await HydraApi.setupApi().then(async () => {
uploadGamesBatch();
+ void migrateDownloadSources();
+
+ const { syncDownloadSourcesFromApi } = await import("./services/user");
+ void syncDownloadSourcesFromApi();
// WSClient.connect();
});
diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts
index ffbfac1a..69437801 100644
--- a/src/main/services/achievements/get-game-achievement-data.ts
+++ b/src/main/services/achievements/get-game-achievement-data.ts
@@ -27,6 +27,10 @@ export const getGameAchievementData = async (
shop: GameShop,
useCachedData: boolean
) => {
+ if (shop === "custom") {
+ return [];
+ }
+
const gameKey = levelKeys.game(shop, objectId);
const cachedAchievements = await gameAchievementsSublevel.get(gameKey);
diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts
index dd26e6f0..12090df3 100644
--- a/src/main/services/hydra-api.ts
+++ b/src/main/services/hydra-api.ts
@@ -46,7 +46,7 @@ export class HydraApi {
return this.userAuth.authToken !== "";
}
- private static hasActiveSubscription() {
+ public static hasActiveSubscription() {
const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
}
@@ -106,9 +106,7 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
- const { syncDownloadSourcesFromApi } = await import(
- "../events/download-sources/sync-download-sources-from-api"
- );
+ const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
}
}
diff --git a/src/main/services/index.ts b/src/main/services/index.ts
index c98f09e1..da4e6848 100644
--- a/src/main/services/index.ts
+++ b/src/main/services/index.ts
@@ -18,4 +18,4 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
-export * from "./resource-cache";
+export * from "./user";
diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts
index f7ea2744..c00e4961 100644
--- a/src/main/services/library-sync/merge-with-remote-games.ts
+++ b/src/main/services/library-sync/merge-with-remote-games.ts
@@ -72,6 +72,7 @@ export const mergeWithRemoteGames = async () => {
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
+ downloadSources: game.downloadSources,
});
}
})
diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts
index 06f5f7d8..6408c30d 100644
--- a/src/main/services/process-watcher.ts
+++ b/src/main/services/process-watcher.ts
@@ -1,10 +1,10 @@
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
-import type { Game, GameRunning } from "@types";
+import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
import { ProcessPayload } from "./download/types";
-import { gamesSublevel, levelKeys } from "@main/level";
+import { db, gamesSublevel, levelKeys } from "@main/level";
import { CloudSync } from "./cloud-sync";
import { logger } from "./logger";
import path from "path";
@@ -209,6 +209,17 @@ function onOpenGame(game: Game) {
lastSyncTick: now,
});
+ // Hide Hydra to tray on game startup if enabled
+ db.get(levelKeys.userPreferences, {
+ valueEncoding: "json",
+ })
+ .then((userPreferences) => {
+ if (userPreferences?.hideToTrayOnGameStart) {
+ WindowManager.mainWindow?.hide();
+ }
+ })
+ .catch(() => {});
+
if (game.remoteId) {
updateGamePlaytime(
game,
diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts
index f3ce9f6c..2a1dce79 100644
--- a/src/main/services/python-rpc.ts
+++ b/src/main/services/python-rpc.ts
@@ -106,7 +106,7 @@ export class PythonRPC {
"main.py"
);
- const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
+ const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
stdio: ["inherit", "inherit"],
});
diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts
deleted file mode 100644
index c59f873d..00000000
--- a/src/main/services/resource-cache.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { app } from "electron";
-import axios from "axios";
-import fs from "node:fs";
-import path from "node:path";
-import { logger } from "./logger";
-
-interface CachedResource {
- data: T;
- etag: string | null;
-}
-
-export class ResourceCache {
- private static cacheDir: string;
-
- static initialize() {
- this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
-
- if (!fs.existsSync(this.cacheDir)) {
- fs.mkdirSync(this.cacheDir, { recursive: true });
- }
- }
-
- private static getCacheFilePath(resourceName: string): string {
- return path.join(this.cacheDir, `${resourceName}.json`);
- }
-
- private static getEtagFilePath(resourceName: string): string {
- return path.join(this.cacheDir, `${resourceName}.etag`);
- }
-
- private static readCachedResource(
- resourceName: string
- ): CachedResource | null {
- const dataPath = this.getCacheFilePath(resourceName);
- const etagPath = this.getEtagFilePath(resourceName);
-
- if (!fs.existsSync(dataPath)) {
- return null;
- }
-
- try {
- const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
- const etag = fs.existsSync(etagPath)
- ? fs.readFileSync(etagPath, "utf-8")
- : null;
-
- return { data, etag };
- } catch (error) {
- logger.error(`Failed to read cached resource ${resourceName}:`, error);
- return null;
- }
- }
-
- private static writeCachedResource(
- resourceName: string,
- data: T,
- etag: string | null
- ): void {
- const dataPath = this.getCacheFilePath(resourceName);
- const etagPath = this.getEtagFilePath(resourceName);
-
- try {
- fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
-
- if (etag) {
- fs.writeFileSync(etagPath, etag, "utf-8");
- }
-
- logger.info(
- `Cached resource ${resourceName} with etag: ${etag || "none"}`
- );
- } catch (error) {
- logger.error(`Failed to write cached resource ${resourceName}:`, error);
- }
- }
-
- static async fetchAndCache(
- resourceName: string,
- url: string,
- timeout: number = 10000
- ): Promise {
- const cached = this.readCachedResource(resourceName);
- const headers: Record = {};
-
- if (cached?.etag) {
- headers["If-None-Match"] = cached.etag;
- }
-
- try {
- const response = await axios.get(url, {
- headers,
- timeout,
- });
-
- const newEtag = response.headers["etag"] || null;
- this.writeCachedResource(resourceName, response.data, newEtag);
-
- return response.data;
- } catch (error: unknown) {
- const axiosError = error as {
- response?: { status?: number };
- message?: string;
- };
-
- if (axiosError.response?.status === 304 && cached) {
- logger.info(`Resource ${resourceName} not modified, using cache`);
- return cached.data;
- }
-
- if (cached) {
- logger.warn(
- `Failed to fetch ${resourceName}, using cached version:`,
- axiosError.message || "Unknown error"
- );
- return cached.data;
- }
-
- logger.error(
- `Failed to fetch ${resourceName} and no cache available:`,
- error
- );
- throw error;
- }
- }
-
- static getCachedData(resourceName: string): T | null {
- const cached = this.readCachedResource(resourceName);
- return cached?.data || null;
- }
-
- static async updateResourcesOnStartup(): Promise {
- logger.info("Starting background resource cache update...");
-
- const resources = [
- {
- name: "steam-games-by-letter",
- url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
- },
- {
- name: "sources-manifest",
- url: "https://cdn.losbroxas.org/sources-manifest.json",
- },
- ];
-
- await Promise.allSettled(
- resources.map(async (resource) => {
- try {
- await this.fetchAndCache(resource.name, resource.url);
- } catch (error) {
- logger.error(`Failed to update ${resource.name} on startup:`, error);
- }
- })
- );
-
- logger.info("Resource cache update complete");
- }
-}
diff --git a/src/main/services/user/index.ts b/src/main/services/user/index.ts
new file mode 100644
index 00000000..b1d8c9b7
--- /dev/null
+++ b/src/main/services/user/index.ts
@@ -0,0 +1,2 @@
+export * from "./get-user-data";
+export * from "./sync-download-sources";
diff --git a/src/main/services/user/sync-download-sources.ts b/src/main/services/user/sync-download-sources.ts
new file mode 100644
index 00000000..ff9819ce
--- /dev/null
+++ b/src/main/services/user/sync-download-sources.ts
@@ -0,0 +1,41 @@
+import { HydraApi, logger } from "../";
+import { downloadSourcesSublevel } from "@main/level";
+import type { DownloadSource } from "@types";
+
+export const syncDownloadSourcesFromApi = async () => {
+ if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) {
+ return;
+ }
+
+ try {
+ const profileSources = await HydraApi.get(
+ "/profile/download-sources"
+ );
+
+ const existingSources = await downloadSourcesSublevel.values().all();
+ const existingUrls = new Set(existingSources.map((source) => source.url));
+
+ for (const downloadSource of profileSources) {
+ if (!existingUrls.has(downloadSource.url)) {
+ try {
+ await downloadSourcesSublevel.put(downloadSource.id, {
+ ...downloadSource,
+ isRemote: true,
+ createdAt: new Date().toISOString(),
+ });
+
+ logger.log(
+ `Synced download source from profile: ${downloadSource.url}`
+ );
+ } catch (error) {
+ logger.error(
+ `Failed to sync download source ${downloadSource.url}:`,
+ error
+ );
+ }
+ }
+ }
+ } catch (error) {
+ logger.error("Failed to sync download sources from API:", error);
+ }
+};
diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts
index 7055fc09..118ff98b 100644
--- a/src/main/services/window-manager.ts
+++ b/src/main/services/window-manager.ts
@@ -462,6 +462,7 @@ export class WindowManager {
editorWindow.once("ready-to-show", () => {
editorWindow.show();
+ this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools();
}
@@ -469,11 +470,12 @@ export class WindowManager {
editorWindow.webContents.on("before-input-event", (_event, input) => {
if (input.key === "F12") {
- editorWindow.webContents.toggleDevTools();
+ this.mainWindow?.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
+ this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index da914b92..f89ec4db 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
- updateMissingFingerprints: () =>
- ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
- deleteDownloadSource: (id: number) =>
- ipcRenderer.invoke("deleteDownloadSource", id),
- deleteAllDownloadSources: () =>
- ipcRenderer.invoke("deleteAllDownloadSources"),
- validateDownloadSource: (url: string) =>
- ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
- getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
- checkDownloadSourceExists: (url: string) =>
- ipcRenderer.invoke("checkDownloadSourceExists", url),
- getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (
diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx
index 74a2a97e..168a4435 100644
--- a/src/renderer/src/app.tsx
+++ b/src/renderer/src/app.tsx
@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
- useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
- setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
- const { updateRepacks } = useRepacks();
-
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
- useEffect(() => {
- (async () => {
- dispatch(setIsImportingSources(true));
-
- try {
- // Initial repacks load
- await updateRepacks();
-
- // Sync all local sources (check for updates)
- const newRepacksCount = await window.electron.syncDownloadSources();
-
- if (newRepacksCount > 0) {
- window.electron.publishNewRepacksNotification(newRepacksCount);
- }
-
- // Update fingerprints for sources that don't have them
- await window.electron.updateMissingFingerprints();
-
- // Update repacks AFTER all syncing and fingerprint updates are complete
- await updateRepacks();
- } catch (error) {
- console.error("Error syncing download sources:", error);
- // Still update repacks even if sync fails
- await updateRepacks();
- } finally {
- dispatch(setIsImportingSources(false));
- }
- })();
- }, [updateRepacks, dispatch]);
-
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index 5752ba19..edea8d50 100644
--- a/src/renderer/src/components/game-card/game-card.tsx
+++ b/src/renderer/src/components/game-card/game-card.tsx
@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
-import type { GameStats } from "@types";
+import type { GameStats, ShopAssets } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
-import { useCallback, useState, useMemo } from "react";
-import { useFormat, useRepacks } from "@renderer/hooks";
+import { useCallback, useState } from "react";
+import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes,
HTMLButtonElement
> {
- game: any;
+ game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState(null);
- const { getRepacksForObjectId } = useRepacks();
- const repacks = getRepacksForObjectId(game.objectId);
-
- const uniqueRepackers = Array.from(
- new Set(repacks.map((repack) => repack.repacker))
- );
-
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat();
- const firstThreeRepackers = useMemo(
- () => uniqueRepackers.slice(0, 3),
- [uniqueRepackers]
- );
- const remainingCount = useMemo(
- () => uniqueRepackers.length - 3,
- [uniqueRepackers]
- );
-
return (
- {uniqueRepackers.length > 0 ? (
+ {game.downloadSources.length > 0 ? (
- {firstThreeRepackers.map((repacker) => (
- -
- {repacker}
+ {game.downloadSources.slice(0, 3).map((sourceName) => (
+
-
+ {sourceName}
))}
- {remainingCount > 0 && (
+ {game.downloadSources.length > 3 && (
-
- +{remainingCount}{" "}
- {t("game_card:available", { count: remainingCount })}
+ +{game.downloadSources.length - 3}{" "}
+ {t("game_card:available", {
+ count: game.downloadSources.length - 3,
+ })}
)}
diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
index b94c94d7..abc359e9 100644
--- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
+++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
+ if (shop === "custom") {
+ setArtifacts([]);
+ return;
+ }
+
const params = new URLSearchParams({
objectId,
shop,
diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx
index 14e5d587..c5b88607 100644
--- a/src/renderer/src/context/game-details/game-details.context.tsx
+++ b/src/renderer/src/context/game-details/game-details.context.tsx
@@ -1,11 +1,4 @@
-import {
- createContext,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
- useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
+ GameRepack,
GameShop,
GameStats,
LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
-
- const { getRepacksForObjectId } = useRepacks();
-
- const repacks = useMemo(() => {
- return getRepacksForObjectId(objectId);
- }, [getRepacksForObjectId, objectId]);
+ const [repacks, setRepacks] = useState([]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
@@ -142,10 +130,12 @@ export function GameDetailsContextProvider({
}
});
- window.electron.getGameStats(objectId, shop).then((result) => {
- if (abortController.signal.aborted) return;
- setStats(result);
- });
+ if (shop !== "custom") {
+ window.electron.getGameStats(objectId, shop).then((result) => {
+ if (abortController.signal.aborted) return;
+ setStats(result);
+ });
+ }
const assetsPromise = window.electron.getGameAssets(objectId, shop);
@@ -167,7 +157,7 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
- if (userDetails) {
+ if (userDetails && shop !== "custom") {
window.electron
.getUnlockedAchievements(objectId, shop)
.then((achievements) => {
@@ -287,19 +277,6 @@ export function GameDetailsContextProvider({
}
}, [location]);
- const lastDownloadedOption = useMemo(() => {
- if (game?.download) {
- const repack = repacks.find((repack) =>
- repack.uris.some((uri) => uri.includes(game.download!.uri))
- );
-
- if (!repack) return null;
- return repack;
- }
-
- return null;
- }, [game?.download, repacks]);
-
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
@@ -315,6 +292,34 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ try {
+ const sources = await window.electron.getDownloadSources();
+
+ const params = {
+ take: 100,
+ skip: 0,
+ downloadSourceIds: sources.map((source) => source.id),
+ };
+
+ const downloads = await window.electron.hydraApi.get(
+ `/games/${shop}/${objectId}/download-sources`,
+ {
+ params,
+ needsAuth: false,
+ }
+ );
+
+ setRepacks(downloads);
+ } catch (error) {
+ console.error("Failed to fetch download sources:", error);
+ }
+ };
+
+ fetchDownloadSources();
+ }, [shop, objectId]);
+
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@@ -359,7 +364,7 @@ export function GameDetailsContextProvider({
stats,
achievements,
hasNSFWContentBlocked,
- lastDownloadedOption,
+ lastDownloadedOption: null,
setHasNSFWContentBlocked,
selectGameExecutable,
updateGame,
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 9f882aed..fa4ab3d6 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
- DownloadSourceValidationResult,
- GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -210,20 +208,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise;
- updateMissingFingerprints: () => Promise;
- removeDownloadSource: (url: string, removeAll?: boolean) => Promise;
- getDownloadSources: () => Promise<
- Pick[]
- >;
- deleteDownloadSource: (id: number) => Promise;
- deleteAllDownloadSources: () => Promise;
- validateDownloadSource: (
- url: string
- ) => Promise;
- syncDownloadSources: () => Promise;
- getDownloadSourcesList: () => Promise;
- checkDownloadSourceExists: (url: string) => Promise;
- getAllRepacks: () => Promise;
+ removeDownloadSource: (
+ removeAll = false,
+ downloadSourceId?: string
+ ) => Promise;
+ getDownloadSources: () => Promise;
+ syncDownloadSources: () => Promise;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise;
diff --git a/src/renderer/src/features/download-sources-slice.ts b/src/renderer/src/features/download-sources-slice.ts
deleted file mode 100644
index 52e58d26..00000000
--- a/src/renderer/src/features/download-sources-slice.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { createSlice } from "@reduxjs/toolkit";
-
-export interface DownloadSourcesState {
- isImporting: boolean;
-}
-
-const initialState: DownloadSourcesState = {
- isImporting: false,
-};
-
-export const downloadSourcesSlice = createSlice({
- name: "downloadSources",
- initialState,
- reducers: {
- setIsImportingSources: (state, action) => {
- state.isImporting = action.payload;
- },
- },
-});
-
-export const { setIsImportingSources } = downloadSourcesSlice.actions;
diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts
index 3b602cff..a7e64e1f 100644
--- a/src/renderer/src/features/index.ts
+++ b/src/renderer/src/features/index.ts
@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
-export * from "./repacks-slice";
-export * from "./download-sources-slice";
export * from "./catalogue-search";
diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts
index 8140e0cd..73733e2b 100644
--- a/src/renderer/src/hooks/index.ts
+++ b/src/renderer/src/hooks/index.ts
@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
-export * from "./use-repacks";
export * from "./use-feature";
diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts
index 1d0aeb57..675f5013 100644
--- a/src/renderer/src/hooks/use-catalogue.ts
+++ b/src/renderer/src/hooks/use-catalogue.ts
@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
+import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState([]);
const [steamDevelopers, setSteamDevelopers] = useState([]);
+ const [downloadSources, setDownloadSources] = useState([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
@@ -37,17 +39,25 @@ export function useCatalogue() {
});
}, []);
+ const getDownloadSources = useCallback(() => {
+ window.electron.getDownloadSources().then((results) => {
+ setDownloadSources(results.filter((source) => !!source.fingerprint));
+ });
+ }, []);
+
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
+ getDownloadSources();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
+ getDownloadSources,
]);
- return { steamPublishers, steamDevelopers };
+ return { steamPublishers, downloadSources, steamDevelopers };
}
diff --git a/src/renderer/src/hooks/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts
deleted file mode 100644
index c024aaa4..00000000
--- a/src/renderer/src/hooks/use-repacks.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { setRepacks } from "@renderer/features";
-import { useCallback } from "react";
-import { RootState } from "@renderer/store";
-import { useSelector } from "react-redux";
-import { useAppDispatch } from "./redux";
-
-export function useRepacks() {
- const dispatch = useAppDispatch();
- const repacks = useSelector((state: RootState) => state.repacks.value);
-
- const getRepacksForObjectId = useCallback(
- (objectId: string) => {
- return repacks.filter((repack) => repack.objectIds.includes(objectId));
- },
- [repacks]
- );
-
- const updateRepacks = useCallback(async () => {
- const repacks = await window.electron.getAllRepacks();
- dispatch(
- setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
- );
- }, [dispatch]);
-
- return { getRepacksForObjectId, updateRepacks };
-}
diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx
index 07bcf3ff..bbeda906 100644
--- a/src/renderer/src/pages/catalogue/catalogue.tsx
+++ b/src/renderer/src/pages/catalogue/catalogue.tsx
@@ -1,4 +1,8 @@
-import type { CatalogueSearchResult, DownloadSource } from "@types";
+import type {
+ CatalogueSearchResult,
+ CatalogueSearchPayload,
+ DownloadSource,
+} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef(null);
const cataloguePageRef = useRef(null);
- const { steamDevelopers, steamPublishers } = useCatalogue();
+ const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
(state) => state.catalogueSearch
);
- const [downloadSources, setDownloadSources] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState([]);
@@ -51,24 +54,41 @@ export default function Catalogue() {
const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef(
- debounce(async (filters, pageSize, offset) => {
- const abortController = new AbortController();
- abortControllerRef.current = abortController;
+ debounce(
+ async (
+ filters: CatalogueSearchPayload,
+ downloadSources: DownloadSource[],
+ pageSize: number,
+ offset: number
+ ) => {
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
- const response = await window.electron.hydraApi.post<{
- edges: CatalogueSearchResult[];
- count: number;
- }>("/catalogue/search", {
- data: { ...filters, take: pageSize, skip: offset },
- needsAuth: false,
- });
+ const requestData = {
+ ...filters,
+ take: pageSize,
+ skip: offset,
+ downloadSourceIds: downloadSources.map(
+ (downloadSource) => downloadSource.id
+ ),
+ };
- if (abortController.signal.aborted) return;
+ const response = await window.electron.hydraApi.post<{
+ edges: CatalogueSearchResult[];
+ count: number;
+ }>("/catalogue/search", {
+ data: requestData,
+ needsAuth: false,
+ });
- setResults(response.edges);
- setItemsCount(response.count);
- setIsLoading(false);
- }, 500)
+ if (abortController.signal.aborted) return;
+
+ setResults(response.edges);
+ setItemsCount(response.count);
+ setIsLoading(false);
+ },
+ 500
+ )
).current;
const decodeHTML = (s: string) =>
@@ -79,18 +99,17 @@ export default function Catalogue() {
setIsLoading(true);
abortControllerRef.current?.abort();
- debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
+ debouncedSearch(
+ filters,
+ downloadSources,
+ PAGE_SIZE,
+ (page - 1) * PAGE_SIZE
+ );
return () => {
debouncedSearch.cancel();
};
- }, [filters, page, debouncedSearch]);
-
- useEffect(() => {
- window.electron.getDownloadSourcesList().then((sources) => {
- setDownloadSources(sources.filter((source) => !!source.fingerprint));
- });
- }, []);
+ }, [filters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -168,7 +187,7 @@ export default function Catalogue() {
value: publisher,
})),
];
- }, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
+ }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => {
return [
diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx
index ecfe0f73..4583afd3 100644
--- a/src/renderer/src/pages/catalogue/game-item.tsx
+++ b/src/renderer/src/pages/catalogue/game-item.tsx
@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
-import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
+import { useAppSelector, useLibrary } from "@renderer/hooks";
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
- const { getRepacksForObjectId } = useRepacks();
-
- const repacks = getRepacksForObjectId(game.objectId);
-
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
}
};
- const uniqueRepackers = useMemo(() => {
- return Array.from(new Set(repacks.map((repack) => repack.repacker)));
- }, [repacks]);
-
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
{genres.join(", ")}
- {uniqueRepackers.map((repacker) => (
- {repacker}
+ {game.downloadSources.map((sourceName) => (
+ {sourceName}
))}
diff --git a/src/renderer/src/pages/catalogue/pagination.scss b/src/renderer/src/pages/catalogue/pagination.scss
index 141dfe54..cac10211 100644
--- a/src/renderer/src/pages/catalogue/pagination.scss
+++ b/src/renderer/src/pages/catalogue/pagination.scss
@@ -1,3 +1,5 @@
+@use "../../scss/globals.scss";
+
.pagination {
display: flex;
gap: 4px;
@@ -18,4 +20,31 @@
font-size: 16px;
}
}
+
+ &__page-input {
+ box-sizing: border-box;
+ width: 40px;
+ min-width: 40px;
+ max-width: 40px;
+ min-height: 40px;
+ border-radius: 8px;
+ border: solid 1px globals.$border-color;
+ background-color: transparent;
+ color: globals.$muted-color;
+ text-align: center;
+ font-size: 12px;
+ padding: 0 6px;
+ outline: none;
+ }
+
+ &__double-chevron {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0; // remove whitespace node width between SVGs
+ }
+
+ &__double-chevron > svg + svg {
+ margin-left: -8px; // pull the second chevron closer
+ }
}
diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx
index dfae6164..9febc8f8 100644
--- a/src/renderer/src/pages/catalogue/pagination.tsx
+++ b/src/renderer/src/pages/catalogue/pagination.tsx
@@ -1,8 +1,51 @@
import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format";
+import { useEffect, useRef, useState } from "react";
+import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import "./pagination.scss";
+interface JumpControlProps {
+ isOpen: boolean;
+ value: string;
+ totalPages: number;
+ inputRef: RefObject;
+ onOpen: () => void;
+ onClose: () => void;
+ onChange: (e: ChangeEvent) => void;
+ onKeyDown: (e: KeyboardEvent) => void;
+}
+
+function JumpControl({
+ isOpen,
+ value,
+ totalPages,
+ inputRef,
+ onOpen,
+ onClose,
+ onChange,
+ onKeyDown,
+}: JumpControlProps) {
+ return isOpen ? (
+
+ ) : (
+
+ );
+}
+
interface PaginationProps {
page: number;
totalPages: number;
@@ -16,20 +59,82 @@ export function Pagination({
}: PaginationProps) {
const { formatNumber } = useFormat();
+ const [isJumpOpen, setIsJumpOpen] = useState(false);
+ const [jumpValue, setJumpValue] = useState("");
+ const jumpInputRef = useRef(null);
+
+ useEffect(() => {
+ if (isJumpOpen) {
+ setJumpValue("");
+ setTimeout(() => jumpInputRef.current?.focus(), 0);
+ }
+ }, [isJumpOpen, page]);
+
if (totalPages <= 1) return null;
const visiblePages = 3;
+ const isLastThree = totalPages > 3 && page >= totalPages - 2;
let startPage = Math.max(1, page - 1);
let endPage = startPage + visiblePages - 1;
- if (endPage > totalPages) {
+ if (isLastThree) {
+ startPage = Math.max(1, totalPages - 2);
+ endPage = totalPages;
+ } else if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1);
}
+ const onJumpChange = (e: ChangeEvent) => {
+ const val = e.target.value;
+ if (val === "") {
+ setJumpValue("");
+ return;
+ }
+ const num = Number(val);
+ if (Number.isNaN(num)) {
+ return;
+ }
+ if (num < 1) {
+ setJumpValue("1");
+ return;
+ }
+ if (num > totalPages) {
+ setJumpValue(String(totalPages));
+ return;
+ }
+ setJumpValue(val);
+ };
+
+ const onJumpKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ if (jumpValue.trim() === "") return;
+ const parsed = Number(jumpValue);
+ if (Number.isNaN(parsed)) return;
+ const target = Math.max(1, Math.min(totalPages, parsed));
+ onPageChange(target);
+ setIsJumpOpen(false);
+ } else if (e.key === "Escape") {
+ setIsJumpOpen(false);
+ }
+ };
+
return (
+ {startPage > 1 && (
+
+ )}
+
- {page > 2 && (
+ {isLastThree && startPage > 1 && (
<>
-
-
- ...
-
+
setIsJumpOpen(true)}
+ onClose={() => setIsJumpOpen(false)}
+ onChange={onJumpChange}
+ onKeyDown={onJumpKeyDown}
+ />
>
)}
@@ -70,11 +180,18 @@ export function Pagination({
))}
- {page < totalPages - 1 && (
+ {!isLastThree && page < totalPages - 1 && (
<>
-
- ...
-
+ setIsJumpOpen(true)}
+ onClose={() => setIsJumpOpen(false)}
+ onChange={onJumpChange}
+ onKeyDown={onJumpKeyDown}
+ />
+
+ {endPage < totalPages && (
+
+ )}
);
}
diff --git a/src/renderer/src/pages/game-details/description-header/description-header.scss b/src/renderer/src/pages/game-details/description-header/description-header.scss
index a29caa34..74126fd5 100644
--- a/src/renderer/src/pages/game-details/description-header/description-header.scss
+++ b/src/renderer/src/pages/game-details/description-header/description-header.scss
@@ -11,7 +11,6 @@
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.5);
&__info {
display: flex;
diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
index 6f9e753c..f9da431d 100644
--- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
+++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
@@ -2,7 +2,7 @@
.gallery-slider {
&__container {
- padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
+ padding: calc(globals.$spacing-unit * 1.5) 0;
width: 100%;
display: flex;
flex-direction: column;
diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx
index 4bf8dc48..c9658636 100644
--- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx
+++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx
@@ -7,11 +7,16 @@ import {
} from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
+import { useAppSelector } from "@renderer/hooks";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
+ const userPreferences = useAppSelector(
+ (state) => state.userPreferences.value
+ );
+ const autoplayEnabled = userPreferences?.autoplayGameTrailers !== false;
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
@@ -164,7 +169,7 @@ export function GallerySlider() {
poster={item.poster}
loop
muted
- autoPlay
+ autoPlay={autoplayEnabled}
tabIndex={-1}
>
diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx
index edf314c7..63c4c974 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -1,4 +1,4 @@
-import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useContext, useEffect, useMemo, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
+import "./hero.scss";
const processMediaElements = (document: Document) => {
const $images = Array.from(document.querySelectorAll("img"));
@@ -53,8 +54,6 @@ const getImageWithCustomPriority = (
};
export function GameDetailsContent() {
- const heroRef = useRef(null);
-
const { t } = useTranslation("game_details");
const {
@@ -152,18 +151,12 @@ export function GameDetailsContent() {
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
-
+

-
+
+
+
+
-
-
@@ -233,7 +228,7 @@ export function GameDetailsContent() {
)}
- {game?.shop !== "custom" && shop && objectId && (
+ {shop !== "custom" && shop && objectId && (
- {game?.shop !== "custom" && }
+ {shop !== "custom" && }
diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
index 750f92e1..7fc2a176 100644
--- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx
+++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
@@ -1,63 +1,170 @@
import Skeleton from "react-loading-skeleton";
-import { Button } from "@renderer/components";
-import { useTranslation } from "react-i18next";
-import "./game-details.scss";
+import "react-loading-skeleton/dist/skeleton.css";
export function GameDetailsSkeleton() {
- const { t } = useTranslation("game_details");
-
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
- {Array.from({ length: 3 }).map((_, index) => (
-
- ))}
-
- {Array.from({ length: 2 }).map((_, index) => (
-
- ))}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, index) => (
-
- ))}
-
-
-
+
);
}
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index 56022b07..f5f77a86 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -1,19 +1,5 @@
@use "../../scss/globals.scss";
-$hero-height: 300px;
-
-@keyframes slide-in {
- 0% {
- transform: translateY(calc(40px + globals.$spacing-unit * 2));
- opacity: 0;
- }
-
- 100% {
- transform: translateY(0);
- opacity: 1;
- }
-}
-
.game-details {
&__wrapper {
display: flex;
@@ -27,617 +13,6 @@ $hero-height: 300px;
}
}
- &__review-form {
- display: flex;
- flex-direction: column;
- gap: 16px;
- margin-bottom: 24px;
- }
-
- &__review-form-controls {
- display: flex;
- gap: calc(globals.$spacing-unit * 2);
- align-items: flex-end;
- flex-wrap: wrap;
-
- @media (max-width: 768px) {
- flex-direction: column;
- align-items: stretch;
- gap: calc(globals.$spacing-unit * 1.5);
- }
- }
-
- &__review-form-bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 16px;
- flex-wrap: wrap;
- }
-
- &__review-message {
- padding: calc(globals.$spacing-unit * 1);
- border-radius: 4px;
- font-size: globals.$small-font-size;
- font-weight: 500;
- margin-top: calc(globals.$spacing-unit * 1);
- border: 1px solid;
-
- &--success {
- background: rgba(34, 197, 94, 0.1);
- color: #86efac;
- border-color: rgba(34, 197, 94, 0.3);
- }
-
- &--error {
- background: rgba(239, 68, 68, 0.1);
- color: #fca5a5;
- border-color: rgba(239, 68, 68, 0.3);
- }
- }
-
- &__review-score-container {
- display: flex;
- align-items: center;
- gap: 4px;
- }
-
- &__review-score-label {
- font-size: 14px;
- color: #ffffff;
- font-weight: 500;
- }
-
- &__review-score-select {
- background-color: #2a2a2a;
- border: 1px solid #3a3a3a;
- border-radius: 4px;
- color: #ffffff;
- padding: 6px 12px;
- font-size: 14px;
- cursor: pointer;
- transition:
- border-color 0.2s ease,
- background-color 0.2s ease;
-
- &:focus {
- outline: none;
- }
-
- &--red {
- border-color: #e74c3c;
- background-color: rgba(231, 76, 60, 0.1);
- }
-
- &--yellow {
- border-color: #f39c12;
- background-color: rgba(243, 156, 18, 0.1);
- }
-
- &--green {
- border-color: #27ae60;
- background-color: rgba(39, 174, 96, 0.1);
- }
-
- option {
- background-color: #2a2a2a;
- color: #ffffff;
- }
- }
-
- &__star-rating {
- display: flex;
- align-items: center;
- gap: 2px;
- }
-
- &__star {
- background: none;
- border: none;
- color: #666666;
- cursor: pointer;
- padding: 2px;
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s ease;
-
- &:hover {
- color: #ffffff;
- background-color: rgba(255, 255, 255, 0.1);
- transform: scale(1.1);
- }
-
- &--filled {
- color: #ffffff;
-
- &.game-details__review-score-select--red {
- color: #e74c3c;
- }
-
- &.game-details__review-score-select--yellow {
- color: #f39c12;
- }
-
- &.game-details__review-score-select--green {
- color: #27ae60;
- }
- }
-
- &--empty {
- color: #666666;
-
- &:hover {
- color: #ffffff;
- }
- }
-
- svg {
- fill: currentColor;
- }
- }
-
- &__reviews-sort {
- display: flex;
- flex-direction: column;
- gap: calc(globals.$spacing-unit * 0.75);
- min-width: 150px;
- }
-
- &__reviews-sort-label {
- display: block;
- font-size: globals.$body-font-size;
- color: globals.$body-color;
- }
-
- &__reviews-sort-select {
- background-color: rgba(255, 255, 255, 0.05);
- border: 1px solid globals.$border-color;
- border-radius: 4px;
- padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
- color: globals.$body-color;
- font-size: globals.$body-font-size;
- font-family: inherit;
- cursor: pointer;
- transition:
- border-color 0.2s ease,
- background-color 0.2s ease;
-
- &:focus {
- outline: none;
- background-color: rgba(255, 255, 255, 0.08);
- border-color: globals.$brand-teal;
- }
-
- &:hover {
- border-color: rgba(255, 255, 255, 0.15);
- }
-
- option {
- background-color: globals.$dark-background-color;
- color: globals.$body-color;
- }
- }
-
- &__reviews-list {
- margin-top: calc(globals.$spacing-unit * 3);
- }
-
- &__reviews-container {
- display: flex;
- flex-direction: column;
- gap: calc(globals.$spacing-unit * 4);
- }
-
- &__reviews-separator {
- height: 1px;
- background: rgba(255, 255, 255, 0.1);
- margin: calc(globals.$spacing-unit * 3) 0;
- width: 100%;
- }
-
- &__reviews-list-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-bottom: calc(globals.$spacing-unit * 1);
- }
-
- &__reviews-empty {
- text-align: center;
- padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
- margin-bottom: calc(globals.$spacing-unit * 2);
- }
-
- &__reviews-empty-icon {
- font-size: 48px;
- margin-bottom: calc(globals.$spacing-unit * 2);
- color: rgba(255, 255, 255, 0.6);
- }
-
- &__reviews-empty-title {
- color: rgba(255, 255, 255, 0.9);
- font-weight: 600;
- margin: 0 0 calc(globals.$spacing-unit * 1) 0;
- }
-
- &__reviews-empty-message {
- color: rgba(255, 255, 255, 0.6);
- font-size: globals.$small-font-size;
- margin: 0;
- line-height: 1.4;
- }
-
- &__review-item {
- overflow: hidden;
- word-wrap: break-word;
- }
-
- &__review-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: calc(globals.$spacing-unit * 1.5);
- }
-
- &__review-user {
- display: flex;
- align-items: center;
- gap: calc(globals.$spacing-unit * 1);
- }
-
- &__review-user-info {
- display: flex;
- flex-direction: column;
- gap: calc(globals.$spacing-unit * 0.25);
- }
-
- &__review-display-name {
- color: rgba(255, 255, 255, 0.9);
- font-size: globals.$small-font-size;
- font-weight: 600;
- display: inline-flex;
-
- &--clickable {
- cursor: pointer;
- transition: color 0.2s ease;
-
- &:hover {
- text-decoration: underline;
- }
- }
- }
-
- &__review-actions {
- margin-top: 12px;
- padding-top: 8px;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
-
- &__review-votes {
- display: flex;
- gap: 12px;
- }
-
- &__vote-button {
- display: flex;
- align-items: center;
- gap: 6px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 6px;
- padding: 6px 12px;
- color: #ccc;
- font-size: 14px;
- cursor: pointer;
- transition: all 0.2s ease;
-
- &:hover {
- background: rgba(255, 255, 255, 0.1);
- border-color: rgba(255, 255, 255, 0.2);
- color: #ffffff;
- }
-
- &--active {
- &.game-details__vote-button--upvote {
- svg {
- fill: white;
- }
- }
-
- &.game-details__vote-button--downvote {
- svg {
- fill: white;
- }
- }
- }
-
- span {
- font-weight: 500;
- display: inline-block;
- min-width: 1ch;
- overflow: hidden;
- }
- }
-
- &__delete-review-button {
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(244, 67, 54, 0.1);
- border: 1px solid rgba(244, 67, 54, 0.3);
- border-radius: 6px;
- padding: 6px;
- color: #f44336;
- cursor: pointer;
- transition: all 0.2s ease;
- gap: 6px;
-
- &:hover {
- background: rgba(244, 67, 54, 0.2);
- border-color: #f44336;
- color: #ff5722;
- }
- }
-
- &__blocked-review-simple {
- color: rgba(255, 255, 255, 0.6);
- font-size: globals.$small-font-size;
- display: flex;
- align-items: center;
- gap: calc(globals.$spacing-unit * 0.5);
- }
-
- &__blocked-review-show-link {
- background: none;
- border: none;
- color: #ffc107;
- font-size: globals.$small-font-size;
- cursor: pointer;
- text-decoration: underline;
- padding: 0;
- transition: color 0.2s ease;
-
- &:hover {
- color: #ffeb3b;
- }
- }
-
- &__blocked-review-hide-link {
- background: none;
- border: none;
- color: rgba(255, 255, 255, 0.5);
- font-size: globals.$small-font-size;
- cursor: pointer;
- text-decoration: underline;
- padding: 0;
- transition: color 0.2s ease;
-
- &:hover {
- color: rgba(255, 255, 255, 0.8);
- }
- }
-
- &__review-score-stars {
- display: flex;
- align-items: center;
- gap: 2px;
- }
-
- &__review-star {
- color: #666666;
- transition: color 0.2s ease;
- cursor: default;
-
- &--filled {
- color: #ffffff;
-
- &.game-details__review-score--red {
- color: #fca5a5;
- }
-
- &.game-details__review-score--yellow {
- color: #fcd34d;
- }
-
- &.game-details__review-score--green {
- color: #86efac;
- }
- }
-
- &--empty {
- color: #666666;
- }
-
- svg {
- fill: currentColor;
- }
- }
-
- &__review-date {
- display: flex;
- align-items: center;
- gap: 4px;
- color: rgba(255, 255, 255, 0.6);
- font-size: globals.$small-font-size;
- }
-
- &__review-content {
- color: globals.$body-color;
- line-height: 1.5;
- word-wrap: break-word;
- word-break: break-word;
- overflow-wrap: break-word;
- white-space: pre-wrap;
- max-width: 100%;
- }
-
- &__reviews-loading {
- text-align: center;
- color: rgba(255, 255, 255, 0.6);
- padding: calc(globals.$spacing-unit * 2);
- }
-
- &__load-more-reviews {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid globals.$border-color;
- color: globals.$body-color;
- padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
- border-radius: 4px;
- cursor: pointer;
- font-size: globals.$body-font-size;
- font-family: inherit;
- transition: all 0.2s ease;
- width: 100%;
- margin-top: calc(globals.$spacing-unit * 2);
-
- &:hover {
- background-color: rgba(255, 255, 255, 0.1);
- border-color: globals.$brand-teal;
- }
- }
-
- &__hero {
- width: 100%;
- height: $hero-height;
- min-height: $hero-height;
- display: flex;
- flex-direction: column;
- position: relative;
- transition: all ease 0.2s;
-
- @media (min-width: 1250px) {
- height: 350px;
- min-height: 350px;
- }
- }
-
- &__hero-content {
- padding: calc(globals.$spacing-unit * 1.5);
- height: 100%;
- width: 100%;
- display: flex;
- justify-content: space-between;
- align-items: flex-end;
-
- @media (min-width: 768px) {
- padding: calc(globals.$spacing-unit * 2);
- }
- }
-
- &__hero-buttons {
- display: flex;
- gap: globals.$spacing-unit;
- align-items: center;
-
- &--right {
- margin-left: auto;
- }
- }
-
- &__edit-custom-game-button {
- padding: calc(globals.$spacing-unit * 1.5);
- background-color: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(20px);
- border-radius: 8px;
- transition: all ease 0.2s;
- cursor: pointer;
- min-height: 40px;
- min-width: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: globals.$muted-color;
- border: solid 1px globals.$border-color;
- box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
- animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
-
- &:active {
- opacity: 0.9;
- }
-
- &:hover {
- background-color: rgba(0, 0, 0, 0.5);
- color: globals.$body-color;
- }
- }
-
- &__hero-logo-backdrop {
- width: 100%;
- height: 100%;
- background: linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%);
- position: absolute;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- }
-
- &__hero-image {
- width: 100%;
- height: calc($hero-height + 72px);
- min-height: calc($hero-height + 72px);
- object-fit: cover;
- object-position: top;
- transition: all ease 0.2s;
- position: absolute;
- z-index: 0;
-
- @media (min-width: 1250px) {
- object-position: center;
- height: calc(350px + 72px);
- min-height: calc(350px + 72px);
- }
- }
-
- &__game-logo {
- width: 200px;
- align-self: flex-end;
-
- @media (min-width: 768px) {
- width: 250px;
- }
-
- @media (min-width: 1024px) {
- width: 300px;
- }
- }
-
- &__game-logo-text {
- width: 200px;
- align-self: flex-end;
- font-size: 1.8rem;
- font-weight: bold;
- color: #ffffff;
- text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
- text-align: left;
- line-height: 1.2;
- word-wrap: break-word;
- overflow-wrap: break-word;
- hyphens: auto;
-
- @media (min-width: 768px) {
- width: 250px;
- font-size: 2.2rem;
- }
-
- @media (min-width: 1024px) {
- width: 300px;
- font-size: 2.5rem;
- }
- }
-
- &__hero-image-skeleton {
- height: 300px;
-
- @media (min-width: 1250px) {
- height: 350px;
- }
- }
-
&__container {
width: 100%;
height: 100%;
@@ -646,6 +21,7 @@ $hero-height: 300px;
z-index: 1;
}
+ // Description Section Styles
&__description-container {
display: flex;
width: 100%;
@@ -754,322 +130,51 @@ $hero-height: 300px;
}
}
- &__description-skeleton {
- display: flex;
- flex-direction: column;
- gap: globals.$spacing-unit;
- padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
- width: 100%;
- margin-left: auto;
- margin-right: auto;
-
- @media (min-width: 768px) {
- padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
+ // Skeleton-specific styles
+ &__skeleton {
+ .react-loading-skeleton {
+ background: linear-gradient(90deg, #1c1c1c 25%, #2a2a2a 50%, #1c1c1c 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
}
- @media (min-width: 1024px) {
- padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
- width: 80%;
+ // Ensure skeleton elements maintain proper spacing
+ .description-header {
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
}
- @media (min-width: 1280px) {
- width: 60%;
- line-height: 22px;
+ .content-sidebar {
+ min-width: 300px;
+ max-width: 300px;
}
- @media (min-width: 1536px) {
- width: 50%;
- }
- }
-
- &__randomizer-button {
- animation: slide-in 0.2s;
- position: fixed;
- bottom: calc(globals.$spacing-unit * 3);
- right: calc(9px + globals.$spacing-unit * 2);
- box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 10px 1px;
- border: solid 2px globals.$border-color;
- z-index: 1;
- background-color: globals.$background-color;
-
- &:hover {
- background-color: globals.$background-color;
- box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 15px 5px;
- opacity: 1;
- }
-
- &:active {
- transform: scale(0.98);
- }
-
- &:disabled {
- box-shadow: none;
- transform: none;
- opacity: 0.8;
- background-color: globals.$background-color;
- }
- }
-
- &__hero-panel-skeleton {
- width: 100%;
- padding: calc(globals.$spacing-unit * 2);
- display: flex;
- align-items: center;
- background-color: globals.$background-color;
- height: 72px;
- border-bottom: solid 1px globals.$border-color;
- }
-
- &__cloud-sync-button {
- padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
- background-color: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(20px);
- border-radius: 8px;
- transition: all ease 0.2s;
- cursor: pointer;
- min-height: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: globals.$spacing-unit;
- color: globals.$muted-color;
- font-size: globals.$small-font-size;
- border: solid 1px globals.$border-color;
- box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
- animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
-
- &:active {
- opacity: 0.9;
- }
-
- &:disabled {
- opacity: globals.$disabled-opacity;
- cursor: not-allowed;
- }
-
- &:hover {
- background-color: rgba(0, 0, 0, 0.5);
- }
- }
-
- &__stars-icon-container {
- width: 16px;
- height: 16px;
- position: relative;
- }
-
- &__stars-icon {
- width: 70px;
- position: absolute;
- top: -28px;
- left: -27px;
- }
-
- &__cloud-icon-container {
- width: 20px;
- height: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
- }
-
- &__cloud-icon {
- width: 26px;
- position: absolute;
- top: -3px;
- }
-
- &__hero-backdrop {
- flex: 1;
- transition: opacity 0.2s ease;
- }
-
- &__reviews-section {
- margin-top: calc(globals.$spacing-unit * 3);
- padding-top: calc(globals.$spacing-unit * 3);
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- width: 100%;
- margin-left: auto;
- margin-right: auto;
-
- @media (min-width: 1280px) {
- width: 60%;
- }
-
- @media (min-width: 1536px) {
- width: 50%;
- }
- }
-
- &__reviews-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: calc(globals.$spacing-unit * 2);
-
- @media (max-width: 768px) {
+ // Hero panel skeleton spacing
+ .hero-panel__content {
+ display: flex;
flex-direction: column;
- align-items: flex-start;
- gap: calc(globals.$spacing-unit * 1.5);
- }
- }
-
- &__reviews-title {
- font-size: 1.25rem;
- font-weight: 600;
- color: globals.$muted-color;
- margin: 0;
- }
-
- &__reviews-title-group {
- display: flex;
- align-items: center;
- gap: calc(globals.$spacing-unit);
- flex: 1;
- }
-
- &__reviews-badge {
- background-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.7);
- padding: 4px 8px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 600;
- min-width: 24px;
- text-align: center;
- flex-shrink: 0;
- }
-
- &__leave-review-cta {
- display: flex;
- align-items: center;
- gap: calc(globals.$spacing-unit * 0.5);
- padding: calc(globals.$spacing-unit * 0.75)
- calc(globals.$spacing-unit * 1.5);
- background: linear-gradient(
- 135deg,
- globals.$brand-teal,
- globals.$brand-blue
- );
- color: white;
- border: none;
- border-radius: 8px;
- font-size: 0.9rem;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s ease;
- margin-bottom: calc(globals.$spacing-unit);
-
- &:hover {
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
+ gap: calc(globals.$spacing-unit * 0.5);
}
- &:active {
- transform: translateY(0);
+ // Review items skeleton spacing
+ .review-item-skeleton {
+ border: 1px solid globals.$border-color;
+ border-radius: 8px;
+ padding: calc(globals.$spacing-unit * 1);
+ margin-bottom: calc(globals.$spacing-unit * 1);
}
- svg {
- flex-shrink: 0;
- }
- }
-
- &__review-input-container {
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- background-color: globals.$dark-background-color;
- overflow: hidden;
- }
-
- &__review-input-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 12px;
- background-color: globals.$background-color;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- }
-
- &__review-editor-toolbar {
- display: flex;
- gap: 4px;
- }
-
- &__editor-button {
- background: none;
- border: 1px solid rgba(255, 255, 255, 0.15);
- border-radius: 4px;
- color: #ffffff;
- padding: 4px 8px;
- cursor: pointer;
- font-size: 12px;
- transition: all 0.2s ease;
-
- &:hover {
- background-color: rgba(255, 255, 255, 0.08);
- border-color: rgba(255, 255, 255, 0.2);
- }
-
- &.is-active {
- background-color: globals.$brand-blue;
- border-color: globals.$brand-blue;
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- }
-
- &__review-char-counter {
- font-size: 12px;
- color: #888888;
-
- .over-limit {
- color: #ff6b6b;
- }
- }
-
- &__review-input {
- min-height: 120px;
- padding: 12px;
- cursor: text;
-
- .ProseMirror {
- outline: none;
- color: #ffffff;
- font-size: 14px;
- line-height: 1.5;
- min-height: 96px; // 120px - 24px padding
- width: 100%;
- cursor: text;
-
- &:focus {
- outline: none;
- }
-
- p {
- margin: 0 0 8px 0;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-
- strong {
- font-weight: bold;
- }
-
- em {
- font-style: italic;
- }
-
- u {
- text-decoration: underline;
- }
+ // Sidebar section spacing
+ .sidebar-section-skeleton {
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
}
}
}
+
+@keyframes skeleton-loading {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx
index f0778494..6bc28c10 100644
--- a/src/renderer/src/pages/game-details/game-details.tsx
+++ b/src/renderer/src/pages/game-details/game-details.tsx
@@ -25,6 +25,7 @@ import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
import "./game-details.scss";
+import "./hero.scss";
export default function GameDetails() {
const [randomGame, setRandomGame] = useState
(null);
@@ -102,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
- repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,
diff --git a/src/renderer/src/pages/game-details/game-reviews.scss b/src/renderer/src/pages/game-details/game-reviews.scss
new file mode 100644
index 00000000..e3a187b6
--- /dev/null
+++ b/src/renderer/src/pages/game-details/game-reviews.scss
@@ -0,0 +1,116 @@
+@use "../../scss/globals.scss";
+
+.game-details {
+ &__reviews-section {
+ margin-top: calc(globals.$spacing-unit * 3);
+ padding-top: calc(globals.$spacing-unit * 3);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+
+ @media (min-width: 1280px) {
+ width: 60%;
+ }
+
+ @media (min-width: 1536px) {
+ width: 50%;
+ }
+ }
+
+ &__reviews-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: globals.$muted-color;
+ margin: 0;
+ }
+
+ &__reviews-title-group {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit);
+ flex: 1;
+ }
+
+ &__reviews-badge {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.7);
+ padding: 4px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 24px;
+ text-align: center;
+ flex-shrink: 0;
+ }
+
+ &__reviews-container {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 4);
+ }
+
+ &__reviews-separator {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+ margin: calc(globals.$spacing-unit * 3) 0;
+ width: 100%;
+ }
+
+ &__reviews-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: calc(globals.$spacing-unit * 1);
+ }
+
+ &__reviews-empty {
+ text-align: center;
+ padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ }
+
+ &__reviews-empty-icon {
+ font-size: 48px;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ color: rgba(255, 255, 255, 0.6);
+ }
+
+ &__reviews-empty-title {
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 600;
+ margin: 0 0 calc(globals.$spacing-unit * 1) 0;
+ }
+
+ &__reviews-empty-message {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ margin: 0;
+ line-height: 1.4;
+ }
+
+ &__reviews-loading {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.6);
+ padding: calc(globals.$spacing-unit * 2);
+ }
+
+ &__load-more-reviews {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ color: globals.$body-color;
+ padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: globals.$body-font-size;
+ font-family: inherit;
+ transition: all 0.2s ease;
+ width: 100%;
+ margin-top: calc(globals.$spacing-unit * 2);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-color: globals.$brand-teal;
+ }
+ }
+}
diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx
index f8117f43..1a6fc675 100644
--- a/src/renderer/src/pages/game-details/game-reviews.tsx
+++ b/src/renderer/src/pages/game-details/game-reviews.tsx
@@ -9,6 +9,7 @@ import { ReviewForm } from "./review-form";
import { ReviewItem } from "./review-item";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
+import "./game-reviews.scss";
import { useToast } from "@renderer/hooks";
type ReviewSortOption =
@@ -116,7 +117,7 @@ export function GameReviews({
});
const checkUserReview = useCallback(async () => {
- if (!objectId || !userDetailsId) return;
+ if (!objectId || !userDetailsId || shop === "custom") return;
try {
const response = await window.electron.hydraApi.get<{
@@ -144,11 +145,9 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
- console.log("reviews", reviews);
-
const loadReviews = useCallback(
async (reset = false) => {
- if (!objectId) return;
+ if (!objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -440,8 +439,6 @@ export function GameReviews({
});
}, [reviews]);
- console.log("reviews", reviews);
-
return (
{showReviewPrompt &&
@@ -469,84 +466,82 @@ export function GameReviews({
>
)}
-
-
-
-
{t("reviews")}
-
- {totalReviewCount}
-
-
+
+
+
{t("reviews")}
+
+ {totalReviewCount}
+
-
-
- {reviewsLoading && reviews.length === 0 && (
-
- {t("loading_reviews")}
-
- )}
-
- {!reviewsLoading && reviews.length === 0 && (
-
-
-
-
-
- {t("no_reviews_yet")}
-
-
- {t("be_first_to_review")}
-
-
- )}
-
-
0 ? 0.5 : 1,
- transition: "opacity 0.2s ease",
- }}
- >
- {reviews.map((review) => (
-
- ))}
-
-
- {hasMoreReviews && !reviewsLoading && (
-
- )}
-
- {reviewsLoading && reviews.length > 0 && (
-
- {t("loading_more_reviews")}
-
- )}
+
+
+ {reviewsLoading && reviews.length === 0 && (
+
+ {t("loading_reviews")}
+
+ )}
+
+ {!reviewsLoading && reviews.length === 0 && (
+
+
+
+
+
+ {t("no_reviews_yet")}
+
+
+ {t("be_first_to_review")}
+
+
+ )}
+
+
0 ? 0.5 : 1,
+ transition: "opacity 0.2s ease",
+ }}
+ >
+ {reviews.map((review) => (
+
+ ))}
+
+
+ {hasMoreReviews && !reviewsLoading && (
+
+ )}
+
+ {reviewsLoading && reviews.length > 0 && (
+
+ {t("loading_more_reviews")}
+
+ )}
);
}
diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss
new file mode 100644
index 00000000..41264fe4
--- /dev/null
+++ b/src/renderer/src/pages/game-details/hero.scss
@@ -0,0 +1,274 @@
+@use "../../scss/globals.scss";
+
+$hero-height: 350px;
+
+@keyframes slide-in {
+ 0% {
+ transform: translateY(calc(40px + globals.$spacing-unit * 2));
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.game-details {
+ &__hero-panel {
+ padding: globals.$spacing-unit;
+ }
+
+ &__hero {
+ width: 100%;
+ height: $hero-height;
+ min-height: $hero-height;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ transition: all ease 0.2s;
+
+ @media (min-width: 1250px) {
+ height: 350px;
+ min-height: 350px;
+ }
+ }
+
+ &__hero-content {
+ padding: calc(globals.$spacing-unit * 1.5);
+ height: 100%;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+
+ @media (min-width: 768px) {
+ padding: calc(globals.$spacing-unit * 2);
+ }
+ }
+
+ &__hero-buttons {
+ display: flex;
+ gap: globals.$spacing-unit;
+ align-items: center;
+
+ &--right {
+ margin-left: auto;
+ }
+ }
+
+ &__edit-custom-game-button {
+ padding: calc(globals.$spacing-unit * 1.5);
+ background-color: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(20px);
+ border-radius: 8px;
+ transition: all ease 0.2s;
+ cursor: pointer;
+ min-height: 40px;
+ min-width: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: globals.$muted-color;
+ border: solid 1px globals.$border-color;
+ box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
+ animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
+
+ &:active {
+ opacity: 0.9;
+ }
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.5);
+ color: globals.$body-color;
+ }
+ }
+
+ &__hero-logo-backdrop {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+
+ &__hero-image-wrapper {
+ position: absolute;
+ width: 100%;
+ height: 384px;
+ max-height: 384px;
+ overflow: hidden;
+ border-radius: 0px 0px 8px 8px;
+ z-index: 0;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.3) 60%,
+ transparent 100%
+ );
+ z-index: 1;
+ pointer-events: none;
+ border-radius: inherit;
+ }
+
+ @media (min-width: 1250px) {
+ height: calc(350px + 82px);
+ min-height: calc(350px + 84px);
+ }
+ }
+
+ &__hero-image {
+ width: 100%;
+ height: $hero-height;
+ min-height: $hero-height;
+ object-fit: cover;
+ object-position: top;
+ transition: all ease 0.2s;
+ position: absolute;
+ z-index: 0;
+ border-radius: 0px 0px 8px 8px;
+
+ @media (min-width: 1250px) {
+ object-position: center;
+ height: $hero-height;
+ min-height: $hero-height;
+ }
+ }
+
+ &__game-logo {
+ width: 200px;
+ align-self: flex-end;
+ object-fit: contain;
+ object-position: left bottom;
+
+ @media (min-width: 768px) {
+ width: 250px;
+ }
+
+ @media (min-width: 1024px) {
+ width: 300px;
+ max-height: 150px;
+ }
+ }
+
+ &__game-logo-text {
+ width: 200px;
+ align-self: flex-end;
+ font-size: 1.8rem;
+ font-weight: bold;
+ color: #ffffff;
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
+ text-align: left;
+ line-height: 1.2;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ hyphens: auto;
+
+ @media (min-width: 768px) {
+ width: 250px;
+ font-size: 2.2rem;
+ }
+
+ @media (min-width: 1024px) {
+ width: 300px;
+ font-size: 2.5rem;
+ }
+ }
+
+ &__cloud-sync-button {
+ padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
+ background-color: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(20px);
+ border-radius: 8px;
+ transition: all ease 0.2s;
+ cursor: pointer;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: globals.$spacing-unit;
+ color: globals.$muted-color;
+ font-size: globals.$small-font-size;
+ border: solid 1px globals.$border-color;
+ box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
+ animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
+
+ &:active {
+ opacity: 0.9;
+ }
+
+ &:disabled {
+ opacity: globals.$disabled-opacity;
+ cursor: not-allowed;
+ }
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.5);
+ }
+ }
+
+ &__cloud-icon-container {
+ width: 20px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ }
+
+ &__cloud-icon {
+ width: 26px;
+ position: absolute;
+ top: -3px;
+ }
+
+ &__randomizer-button {
+ padding: calc(globals.$spacing-unit * 1.5);
+ background-color: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(20px);
+ border-radius: 8px;
+ transition: all ease 0.2s;
+ cursor: pointer;
+ min-height: 40px;
+ min-width: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: globals.$muted-color;
+ border: solid 1px globals.$border-color;
+ box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
+ animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
+
+ &:active {
+ opacity: 0.9;
+ }
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.5);
+ color: globals.$body-color;
+ }
+ }
+
+ &__stars-icon-container {
+ width: 20px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ }
+
+ &__stars-icon {
+ width: 26px;
+ position: absolute;
+ top: -3px;
+ }
+}
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss
index 4dd1cc22..c91e685c 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.scss
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss
@@ -18,6 +18,7 @@
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
&--stuck {
background: rgba(0, 0, 0, 0.7);
@@ -29,7 +30,18 @@
&__content {
display: flex;
flex-direction: column;
- gap: globals.$spacing-unit;
+ gap: calc(globals.$spacing-unit * 0.5);
+
+ p {
+ font-size: globals.$small-font-size;
+ color: globals.$muted-color;
+ font-weight: 400;
+ margin: 0;
+
+ &:first-child {
+ font-weight: 600;
+ }
+ }
}
&__actions {
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
index 7f8de0b0..799f2c36 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx
+++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx
@@ -50,25 +50,29 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
-
-
{getInfo()}
-
-
-
+
+
+
{getInfo()}
+
+
+
- {showProgressBar && (
-
- )}
+ {showProgressBar && (
+
+ )}
+
);
}
diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
index 7551a31e..306e8647 100644
--- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
@@ -54,7 +54,7 @@ export function RepacksModal({
{}
);
- const { repacks, game } = useContext(gameDetailsContext);
+ const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
});
}, [repacks, isFeatureEnabled, Feature]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources);
+ };
+
+ fetchDownloadSources();
+ }, []);
+
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
- useEffect(() => {
- window.electron.getDownloadSourcesList().then((sources) => {
- const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
- const filteredSources = sources.filter(
- (s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
- );
- setDownloadSources(filteredSources);
- });
- }, [sortedRepacks]);
-
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
- const lowerRepacker = repack.repacker.toLowerCase();
+ const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
- src.name === repack.repacker
+ src.name === repack.downloadSourceName
);
});
@@ -281,7 +280,7 @@ export function RepacksModal({
)}
- {repack.fileSize} - {repack.repacker} -{" "}
+ {repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
diff --git a/src/renderer/src/pages/game-details/review-form.scss b/src/renderer/src/pages/game-details/review-form.scss
new file mode 100644
index 00000000..7ec1d922
--- /dev/null
+++ b/src/renderer/src/pages/game-details/review-form.scss
@@ -0,0 +1,232 @@
+@use "../../scss/globals.scss";
+
+.game-details {
+ &__reviews-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(globals.$spacing-unit * 1.5);
+ }
+ }
+
+ &__reviews-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: globals.$muted-color;
+ margin: 0;
+ }
+
+ &__review-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 24px;
+ }
+
+ &__review-form-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ }
+
+ &__review-score-container {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &__review-score-select {
+ background-color: #2a2a2a;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ color: #ffffff;
+ padding: 6px 12px;
+ font-size: 14px;
+ cursor: pointer;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
+
+ &:focus {
+ outline: none;
+ }
+
+ &--red {
+ border-color: #e74c3c;
+ background-color: rgba(231, 76, 60, 0.1);
+ }
+
+ &--yellow {
+ border-color: #f39c12;
+ background-color: rgba(243, 156, 18, 0.1);
+ }
+
+ &--green {
+ border-color: #27ae60;
+ background-color: rgba(39, 174, 96, 0.1);
+ }
+
+ option {
+ background-color: #2a2a2a;
+ color: #ffffff;
+ }
+ }
+
+ &__star-rating {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ &__star {
+ background: none;
+ border: none;
+ color: #666666;
+ cursor: pointer;
+ padding: 2px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: #ffffff;
+ background-color: rgba(255, 255, 255, 0.1);
+ transform: scale(1.1);
+ }
+
+ &--filled {
+ color: #ffffff;
+
+ &.game-details__review-score-select--red {
+ color: #e74c3c;
+ }
+
+ &.game-details__review-score-select--yellow {
+ color: #f39c12;
+ }
+
+ &.game-details__review-score-select--green {
+ color: #27ae60;
+ }
+ }
+
+ &--empty {
+ color: #666666;
+
+ &:hover {
+ color: #ffffff;
+ }
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ &__review-input-container {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ background-color: globals.$dark-background-color;
+ overflow: hidden;
+ }
+
+ &__review-input-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background-color: globals.$background-color;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ &__review-editor-toolbar {
+ display: flex;
+ gap: 4px;
+ }
+
+ &__editor-button {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 4px;
+ color: #ffffff;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 12px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ &.is-active {
+ background-color: globals.$brand-blue;
+ border-color: globals.$brand-blue;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ &__review-char-counter {
+ font-size: 12px;
+ color: #888888;
+
+ .over-limit {
+ color: #ff6b6b;
+ }
+ }
+
+ &__review-input {
+ min-height: 120px;
+ padding: 12px;
+ cursor: text;
+
+ .ProseMirror {
+ outline: none;
+ color: #ffffff;
+ font-size: 14px;
+ line-height: 1.5;
+ min-height: 96px; // 120px - 24px padding
+ width: 100%;
+ cursor: text;
+
+ &:focus {
+ outline: none;
+ }
+
+ p {
+ margin: 0 0 8px 0;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ strong {
+ font-weight: bold;
+ }
+
+ em {
+ font-style: italic;
+ }
+
+ u {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/src/renderer/src/pages/game-details/review-form.tsx b/src/renderer/src/pages/game-details/review-form.tsx
index 2cc83a19..ffcad4e3 100644
--- a/src/renderer/src/pages/game-details/review-form.tsx
+++ b/src/renderer/src/pages/game-details/review-form.tsx
@@ -2,6 +2,7 @@ import { Star } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EditorContent, Editor } from "@tiptap/react";
import { Button } from "@renderer/components";
+import "./review-form.scss";
interface ReviewFormProps {
editor: Editor | null;
diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss
index e1651ef5..d4f2d38c 100644
--- a/src/renderer/src/pages/game-details/review-item.scss
+++ b/src/renderer/src/pages/game-details/review-item.scss
@@ -1,6 +1,213 @@
@use "../../scss/globals.scss";
.game-details {
+ &__review-item {
+ overflow: hidden;
+ word-wrap: break-word;
+ }
+
+ &__review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
+ }
+
+ &__review-user {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 1);
+ }
+
+ &__review-user-info {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 0.25);
+ }
+
+ &__review-display-name {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: globals.$small-font-size;
+ font-weight: 600;
+ display: inline-flex;
+
+ &--clickable {
+ cursor: pointer;
+ transition: color 0.2s ease;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ &__review-actions {
+ margin-top: 12px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &__review-votes {
+ display: flex;
+ gap: 12px;
+ }
+
+ &__vote-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ padding: 6px 12px;
+ color: #ccc;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+ }
+
+ &--active {
+ &.game-details__vote-button--upvote {
+ svg {
+ fill: white;
+ }
+ }
+
+ &.game-details__vote-button--downvote {
+ svg {
+ fill: white;
+ }
+ }
+ }
+
+ span {
+ font-weight: 500;
+ display: inline-block;
+ min-width: 1ch;
+ overflow: hidden;
+ }
+ }
+
+ &__delete-review-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ border-radius: 6px;
+ padding: 6px;
+ color: #f44336;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ gap: 6px;
+
+ &:hover {
+ background: rgba(244, 67, 54, 0.2);
+ border-color: #f44336;
+ color: #ff5722;
+ }
+ }
+
+ &__blocked-review-simple {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.5);
+ }
+
+ &__blocked-review-show-link {
+ background: none;
+ border: none;
+ color: #ffc107;
+ font-size: globals.$small-font-size;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: #ffeb3b;
+ }
+ }
+
+ &__blocked-review-hide-link {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: globals.$small-font-size;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: rgba(255, 255, 255, 0.8);
+ }
+ }
+
+ &__review-score-stars {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ &__review-star {
+ color: #666666;
+ transition: color 0.2s ease;
+ cursor: default;
+
+ &--filled {
+ color: #ffffff;
+
+ &.game-details__review-score--red {
+ color: #fca5a5;
+ }
+
+ &.game-details__review-score--yellow {
+ color: #fcd34d;
+ }
+
+ &.game-details__review-score--green {
+ color: #86efac;
+ }
+ }
+
+ &--empty {
+ color: #666666;
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ &__review-date {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ }
+
+ &__review-content {
+ color: globals.$body-color;
+ line-height: 1.5;
+ word-wrap: break-word;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+ max-width: 100%;
+ }
+
&__review-translation-toggle {
display: inline-flex;
align-items: center;
diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx
index 40bf181d..b8f632a6 100644
--- a/src/renderer/src/pages/home/home.tsx
+++ b/src/renderer/src/pages/home/home.tsx
@@ -40,14 +40,20 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
- const params = new URLSearchParams({
- take: "12",
- skip: "0",
- });
+ const downloadSources = await window.electron.getDownloadSources();
+
+ const params = {
+ take: 12,
+ skip: 0,
+ downloadSourceIds: downloadSources.map((source) => source.id),
+ };
const catalogue = await window.electron.hydraApi.get
(
- `/catalogue/${category}?${params.toString()}`,
- { needsAuth: false }
+ `/catalogue/${category}`,
+ {
+ params,
+ needsAuth: false,
+ }
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
diff --git a/src/renderer/src/pages/settings/add-download-source-modal.scss b/src/renderer/src/pages/settings/add-download-source-modal.scss
index ea92ca71..d938f7f0 100644
--- a/src/renderer/src/pages/settings/add-download-source-modal.scss
+++ b/src/renderer/src/pages/settings/add-download-source-modal.scss
@@ -38,4 +38,11 @@
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: globals.$spacing-unit;
+ margin-top: calc(globals.$spacing-unit * 2);
+ }
}
diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx
index c2b47513..af6f8b4d 100644
--- a/src/renderer/src/pages/settings/add-download-source-modal.tsx
+++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx
@@ -1,15 +1,13 @@
-import { useCallback, useContext, useEffect, useState } from "react";
+import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
-import { useAppDispatch } from "@renderer/hooks";
+import { logger } from "@renderer/logger";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
-import type { DownloadSourceValidationResult } from "@types";
-import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
@@ -28,7 +26,6 @@ export function AddDownloadSourceModal({
onClose,
onAddDownloadSource,
}: Readonly) {
- const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
@@ -48,77 +45,43 @@ export function AddDownloadSourceModal({
resolver: yupResolver(schema),
});
- const [validationResult, setValidationResult] =
- useState(null);
-
const { sourceUrl } = useContext(settingsContext);
- const dispatch = useAppDispatch();
+ const onSubmit = async (values: FormValues) => {
+ setIsLoading(true);
- const onSubmit = useCallback(
- async (values: FormValues) => {
- const exists = await window.electron.checkDownloadSourceExists(
- values.url
- );
+ try {
+ await window.electron.addDownloadSource(values.url);
- if (exists) {
- setError("url", {
- type: "server",
- message: t("source_already_exists"),
- });
+ onClose();
+ onAddDownloadSource();
+ } catch (error) {
+ logger.error("Failed to add download source:", error);
+ const errorMessage =
+ error instanceof Error && error.message.includes("already exists")
+ ? t("download_source_already_exists")
+ : t("failed_add_download_source");
- return;
- }
-
- const validationResult = await window.electron.validateDownloadSource(
- values.url
- );
-
- setValidationResult(validationResult);
- setUrl(values.url);
- },
- [setError, t]
- );
+ setError("url", {
+ type: "server",
+ message: errorMessage,
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
useEffect(() => {
setValue("url", "");
clearErrors();
setIsLoading(false);
- setValidationResult(null);
if (sourceUrl) {
setValue("url", sourceUrl);
- handleSubmit(onSubmit)();
}
- }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
-
- const handleAddDownloadSource = async () => {
- if (validationResult) {
- setIsLoading(true);
- dispatch(setIsImportingSources(true));
-
- try {
- // Single call that handles: import → API sync → fingerprint
- await window.electron.addDownloadSource(url);
-
- // Close modal and update UI
- onClose();
- onAddDownloadSource();
- } catch (error) {
- console.error("Failed to add download source:", error);
- setError("url", {
- type: "server",
- message: "Failed to import source. Please try again.",
- });
- } finally {
- setIsLoading(false);
- dispatch(setIsImportingSources(false));
- }
- }
- };
+ }, [visible, clearErrors, setValue, sourceUrl]);
const handleClose = () => {
- // Prevent closing while importing
if (isLoading) return;
onClose();
};
@@ -132,49 +95,32 @@ export function AddDownloadSourceModal({
clickOutsideToClose={!isLoading}
>
-
+
+
+
- }
- />
-
- {validationResult && (
-
-
-
{validationResult?.name}
-
- {t("found_download_option", {
- count: validationResult?.downloadCount,
- countFormatted:
- validationResult?.downloadCount.toLocaleString(),
- })}
-
-
-
-
+
+
- )}
+
);
diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx
index 9cf35541..f2825cca 100644
--- a/src/renderer/src/pages/settings/settings-account.tsx
+++ b/src/renderer/src/pages/settings/settings-account.tsx
@@ -201,7 +201,7 @@ export function SettingsAccount() {
- Hydra Cloud
+ {t("hydra_cloud")}
{getHydraCloudSectionContent().description}
diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx
index 64df52d7..c5698ef7 100644
--- a/src/renderer/src/pages/settings/settings-behavior.tsx
+++ b/src/renderer/src/pages/settings/settings-behavior.tsx
@@ -27,6 +27,8 @@ export function SettingsBehavior() {
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
enableSteamAchievements: false,
+ autoplayGameTrailers: true,
+ hideToTrayOnGameStart: false,
});
const { t } = useTranslation("settings");
@@ -49,6 +51,8 @@ export function SettingsBehavior() {
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
+ autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
+ hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
});
}
}, [userPreferences]);
@@ -76,6 +80,16 @@ export function SettingsBehavior() {
}
/>
+
+ handleChange({
+ hideToTrayOnGameStart: !form.hideToTrayOnGameStart,
+ })
+ }
+ />
+
{showRunAtStartup && (
)}
+
+ handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers })
+ }
+ />
+
{
- await window.electron
- .getDownloadSourcesList()
- .then((sources) => {
- setDownloadSources(sources);
- })
- .finally(() => {
- setIsFetchingSources(false);
- });
- };
-
- useEffect(() => {
- getDownloadSources();
- }, []);
-
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
+ useEffect(() => {
+ const fetchDownloadSources = async () => {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources);
+ };
+
+ fetchDownloadSources();
+ }, []);
+
+ useEffect(() => {
+ const hasPendingOrMatchingSource = downloadSources.some(
+ (source) =>
+ source.status === DownloadSourceStatus.PendingMatching ||
+ source.status === DownloadSourceStatus.Matching
+ );
+
+ if (!hasPendingOrMatchingSource || !downloadSources.length) {
+ return;
+ }
+
+ const intervalId = setInterval(async () => {
+ try {
+ await window.electron.syncDownloadSources();
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources);
+ } catch (error) {
+ logger.error("Failed to fetch download sources:", error);
+ }
+ }, 5000);
+
+ return () => clearInterval(intervalId);
+ }, [downloadSources]);
+
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
try {
- await window.electron.deleteDownloadSource(downloadSource.id);
- await window.electron.removeDownloadSource(downloadSource.url);
-
+ await window.electron.removeDownloadSource(false, downloadSource.id);
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
showSuccessToast(t("removed_download_source"));
- await getDownloadSources();
- updateRepacks();
+ } catch (error) {
+ logger.error("Failed to remove download source:", error);
} finally {
setIsRemovingDownloadSource(false);
}
@@ -86,53 +102,47 @@ export function SettingsDownloadSources() {
setIsRemovingDownloadSource(true);
try {
- await window.electron.deleteAllDownloadSources();
- await window.electron.removeDownloadSource("", true);
-
- showSuccessToast(t("removed_download_sources"));
- await getDownloadSources();
- setShowConfirmationDeleteAllSourcesModal(false);
- updateRepacks();
+ await window.electron.removeDownloadSource(true);
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
+ showSuccessToast(t("removed_all_download_sources"));
+ } catch (error) {
+ logger.error("Failed to remove all download sources:", error);
} finally {
setIsRemovingDownloadSource(false);
+ setShowConfirmationDeleteAllSourcesModal(false);
}
};
const handleAddDownloadSource = async () => {
- // Refresh sources list and repacks after import completes
- await getDownloadSources();
-
- // Force repacks update to ensure UI reflects new data
- await updateRepacks();
-
- showSuccessToast(t("added_download_source"));
+ try {
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
+ } catch (error) {
+ logger.error("Failed to refresh download sources:", error);
+ }
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
-
try {
- // Sync local sources (check for updates)
await window.electron.syncDownloadSources();
+ const sources = await window.electron.getDownloadSources();
+ setDownloadSources(sources as DownloadSource[]);
- // Refresh sources and repacks AFTER sync completes
- await getDownloadSources();
- await updateRepacks();
-
- showSuccessToast(t("download_sources_synced"));
- } catch (error) {
- console.error("Error syncing download sources:", error);
- // Still refresh the UI even if sync fails
- await getDownloadSources();
- await updateRepacks();
+ showSuccessToast(t("download_sources_synced_successfully"));
} finally {
setIsSyncingDownloadSources(false);
}
};
const statusTitle = {
- [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
- [DownloadSourceStatus.Errored]: t("download_source_errored"),
+ [DownloadSourceStatus.PendingMatching]: t(
+ "download_source_pending_matching"
+ ),
+ [DownloadSourceStatus.Matched]: t("download_source_matched"),
+ [DownloadSourceStatus.Matching]: t("download_source_matching"),
+ [DownloadSourceStatus.Failed]: t("download_source_failed"),
};
const handleModalClose = () => {
@@ -142,7 +152,7 @@ export function SettingsDownloadSources() {
const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
- console.error("Cannot navigate: fingerprint is undefined");
+ logger.error("Cannot navigate: fingerprint is undefined");
return;
}
@@ -180,8 +190,7 @@ export function SettingsDownloadSources() {
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
- isRemovingDownloadSource ||
- isFetchingSources
+ isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
@@ -197,8 +206,7 @@ export function SettingsDownloadSources() {
disabled={
isRemovingDownloadSource ||
isSyncingDownloadSources ||
- !downloadSources.length ||
- isFetchingSources
+ !downloadSources.length
}
>
@@ -209,11 +217,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
- disabled={
- isSyncingDownloadSources ||
- isFetchingSources ||
- isRemovingDownloadSource
- }
+ disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
{t("add_download_source")}
@@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
{downloadSources.map((downloadSource) => {
+ const isPendingOrMatching =
+ downloadSource.status === DownloadSourceStatus.PendingMatching ||
+ downloadSource.status === DownloadSourceStatus.Matching;
+
return (
-
{downloadSource.name}
- {statusTitle[downloadSource.status]}
+
+ {isPendingOrMatching && (
+
+ )}
+ {statusTitle[downloadSource.status]}
+
diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx
index 42ba6ad9..db3a29a3 100644
--- a/src/renderer/src/pages/settings/settings-real-debrid.tsx
+++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx
@@ -133,7 +133,7 @@ export function SettingsRealDebrid() {
{t("save_changes")}
}
- placeholder="API Token"
+ placeholder={t("api_token")}
hint={
diff --git a/src/renderer/src/pages/settings/settings-torbox.tsx b/src/renderer/src/pages/settings/settings-torbox.tsx
index 610dc942..46c8e2f9 100644
--- a/src/renderer/src/pages/settings/settings-torbox.tsx
+++ b/src/renderer/src/pages/settings/settings-torbox.tsx
@@ -116,7 +116,7 @@ export function SettingsTorBox() {
onChange={(event) =>
setForm({ ...form, torBoxApiToken: event.target.value })
}
- placeholder="API Token"
+ placeholder={t("api_token")}
rightContent={