diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 00000000..5015ab7e
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,37 @@
+# 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
+
+## Comments
+
+- Keep comments concise and purposeful; avoid verbose explanations.
+- Focus on the "why" or non-obvious context, not restating the code.
+- Prefer self-explanatory naming and structure over excessive comments.
+- Do not comment every line or obvious behavior; remove stale comments.
+- Use docblocks only where they add value (public APIs, complex logic).
diff --git a/.env.example b/.env.example
index 3f914eb3..051d8aa3 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
+MAIN_VITE_LAUNCHER_SUBDOMAIN=
diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml
index 6aefac43..34f7d303 100644
--- a/.github/workflows/build-renderer.yml
+++ b/.github/workflows/build-renderer.yml
@@ -6,23 +6,37 @@ concurrency:
on:
push:
- branches: main
+ branches:
+ - release/**
jobs:
build:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ env:
+ NODE_OPTIONS: --max-old-space-size=4096
+ BRANCH_NAME: ${{ github.ref_name }}
+
steps:
- name: Check out Git repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- - name: Install Node.js
+ - name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.18.0
+ node-version: 22.21.0
+ cache: "yarn"
+
+ - name: Enable Corepack (Yarn)
+ run: corepack enable
- name: Install dependencies
- run: yarn --frozen-lockfile --ignore-scripts
+ run: yarn install --frozen-lockfile --ignore-scripts
- name: Build Renderer
run: yarn build
@@ -36,5 +50,5 @@ jobs:
run: |
npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \
- --commit-dirty=true \
- --branch="main"
+ --branch "$BRANCH_NAME" \
+ --commit-dirty
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..df01b358 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,7 +6,8 @@ concurrency:
on:
push:
- branches: [main]
+ branches:
+ - release/**
jobs:
build:
@@ -23,7 +24,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 +40,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 }}
@@ -57,7 +62,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
- MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
+ MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Build Windows
if: matrix.os == 'windows-2022'
@@ -74,7 +79,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
- MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
+ MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Create artifact
uses: actions/upload-artifact@v4
@@ -90,7 +95,6 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- dist/*.pacman
- name: Upload build
env:
@@ -119,6 +123,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..fa12b500
--- /dev/null
+++ b/.github/workflows/update-aur.yml
@@ -0,0 +1,161 @@
+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: |
+ # sleeps for 1 minute to be sure GH updated the release info
+ sleep 60
+ # Update pkgver in PKGBUILD
+ cd hydra-launcher-bin
+ NEW_VERSION="${{ steps.get-version.outputs.version }}"
+ NEW_VERSION="${NEW_VERSION#v}"
+
+ 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"
+
+ export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
+
+ 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..ee039574 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
- "version": "3.7.1",
+ "version": "3.7.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -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 97c1e42a..f470f549 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -225,6 +225,7 @@
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
+ "review_played_for": "Played for",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
@@ -363,7 +364,10 @@
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
- "hide_original": "Hide original"
+ "hide_original": "Hide original",
+ "review_from_blocked_user": "Review from blocked user",
+ "show": "Show",
+ "hide": "Hide"
},
"activation": {
"title": "Activate Hydra",
@@ -430,6 +434,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",
@@ -437,9 +444,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",
@@ -470,6 +484,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",
@@ -543,7 +558,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..adf25e33 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,9 +303,68 @@
"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"
+ "audio": "Audio",
+ "review_from_blocked_user": "Reseña de usuario bloqueado",
+ "show": "Mostrar",
+ "hide": "Ocultar"
},
"activation": {
"title": "Activar Hydra",
@@ -345,7 +422,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 +434,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 +486,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 +520,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,7 +542,11 @@
"hidden": "Oculto",
"test_notification": "Probar notificación",
"notification_preview": "Probar notificación de logro",
- "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
+ "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",
+ "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
+ "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -492,6 +573,7 @@
"game_card": {
"available_one": "Disponible",
"available_other": "Disponibles",
+ "calculating": "Calculando",
"no_downloads": "Sin descargas disponibles"
},
"binary_not_found_modal": {
@@ -593,6 +675,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/hu/translation.json b/src/locales/hu/translation.json
index 7d868aee..8aea356b 100644
--- a/src/locales/hu/translation.json
+++ b/src/locales/hu/translation.json
@@ -8,7 +8,7 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
- "weekly": "📅 A hét felkapott játékai",
+ "weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
@@ -26,7 +26,7 @@
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"need_help": "Elakadtál?",
- "favorites": "Kedvenc játékok",
+ "favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása",
@@ -224,7 +224,7 @@
"show_less": "Mutass kevesebbet",
"reviews": "Vélemények",
"leave_a_review": "Hagyd itt a véleményed",
- "write_review_placeholder": "Oszd meg a gondolataid a játékról...",
+ "write_review_placeholder": "Oszd meg gondolatod a játékról...",
"sort_newest": "Legújabb",
"no_reviews_yet": "Még nem lett vélemény megosztva",
"be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!",
@@ -252,7 +252,7 @@
"you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot",
"would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?",
"yes": "Igen",
- "maybe_later": "Talán Később",
+ "maybe_later": "Talán később",
"cloud_save": "Mentés felhőben",
"cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön",
"backups": "Biztonsági másolatok",
@@ -356,13 +356,18 @@
"delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?",
"delete_review_modal_description": "Ez a lépés nem vonható vissza.",
"delete_review_modal_delete_button": "Törlés",
- "delete_review_modal_cancel_button": "Mégse"
+ "delete_review_modal_cancel_button": "Mégse",
+ "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.",
+ "show_original": "Eredeti megjelenítése",
+ "show_translation": "Fordítás megjelenítése",
+ "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
+ "hide_original": "Eredeti elrejtése"
},
"activation": {
"title": "Hydra Aktiválása",
"installation_id": "Telepítési Azonosító:",
"enter_activation_code": "Írd be az aktiválási kódod",
- "message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.",
+ "message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Töltés…"
},
@@ -386,7 +391,7 @@
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
- "queued": "Várakozási sorban",
+ "queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
"checking_files": "Fájlok ellenőrzése…",
@@ -419,20 +424,30 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve",
- "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
+ "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
+ "adding": "Hozzáadás…",
+ "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.",
+ "download_source_already_exists": "Ez a letöltési forrás URL már létezik.",
"download_count_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{countFormatted}} letöltési opció",
- "download_source_url": "URL forrás:",
+ "download_source_url": "URL Forrás:",
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt",
+ "download_source_pending_matching": "Frissítés hamarosan",
+ "download_source_matched": "Naprakész",
+ "download_source_matching": "Frissítés..",
+ "download_source_failed": "Hiba",
+ "download_source_no_information": "Nincs elérhető információ",
"sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források eltávolítva",
+ "removed_all_download_sources": "Összes letöltési forrás eltávolítva",
+ "download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva",
"cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
@@ -445,6 +460,7 @@
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás",
+ "importing": "Importálás...",
"public": "Publikus",
"private": "Privát",
"friends_only": "Csak barátok",
@@ -462,6 +478,7 @@
"seed_after_download_complete": "Letöltés utáni seedelés",
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók",
+ "hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése",
@@ -498,14 +515,14 @@
"cancel": "Mégsem",
"appearance": "Megjelenés",
"debrid": "Debrid",
- "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.",
+ "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, és csak az internet sebességed szab határt.",
"enable_torbox": "TorBox bekapcsolása",
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
"torbox_account_linked": "TorBox fiók összekapcsolva",
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
- "name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
+ "name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen",
"import_theme": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
@@ -535,7 +552,9 @@
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
- "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
+ "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
+ "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
+ "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -563,10 +582,10 @@
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés",
- "calculating": "Feldolgozás"
+ "calculating": "Számítás alatt.."
},
"binary_not_found_modal": {
- "title": "A programok nincsenek telepítve",
+ "title": "Hiányzó programok",
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
"instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson"
},
@@ -585,6 +604,7 @@
"activity": "Legutóbbi tevékenység",
"library": "Könyvtár",
"pinned": "Kitűzve",
+ "sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
@@ -654,7 +674,7 @@
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
- "achievements": "achievementek",
+ "achievements": "achievement",
"games": "Játékok",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.",
@@ -669,7 +689,7 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
- "karma_description": "Pozitív értékelésekre kapott pontok alapján"
+ "karma_description": "Pozitív értékelésekkel szerzett pontok"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",
@@ -678,7 +698,7 @@
"unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
- "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
+ "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement",
"achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}",
"hidden_achievement_tooltip": "Ez egy rejtett achievement",
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",
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..42743a64 100755
--- a/src/locales/pt-BR/translation.json
+++ b/src/locales/pt-BR/translation.json
@@ -349,7 +349,10 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
- "rating_count": "Avaliação"
+ "rating_count": "Avaliação",
+ "review_from_blocked_user": "Avaliação de usuário bloqueado",
+ "show": "Mostrar",
+ "hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -416,6 +419,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 +429,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",
@@ -529,7 +541,9 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
- "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
+ "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
+ "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
+ "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",
diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json
index 962504d4..6c1963cc 100644
--- a/src/locales/pt-PT/translation.json
+++ b/src/locales/pt-PT/translation.json
@@ -180,7 +180,10 @@
"download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
- "create_start_menu_shortcut": "Criar atalho no Menu Iniciar"
+ "create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
+ "review_from_blocked_user": "Avaliação de utilizador bloqueado",
+ "show": "Mostrar",
+ "hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -252,7 +255,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/locales/ru/translation.json b/src/locales/ru/translation.json
index 886c7d07..6f4d4b92 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -212,6 +212,7 @@
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
+ "rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -252,17 +253,6 @@
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Возможно позже",
- "rating_count": "Оценка",
- "delete_review": "Удалить отзыв",
- "remove_review": "Удалить отзыв",
- "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
- "delete_review_modal_description": "Это действие нельзя отменить.",
- "delete_review_modal_delete_button": "Удалить",
- "delete_review_modal_cancel_button": "Отмена",
- "show_original": "Показать оригинал",
- "show_translation": "Показать перевод",
- "show_original_translated_from": "Показать оригинал (переведено с {{language}})",
- "hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -360,7 +350,21 @@
"caption": "Субтитры",
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
- "no_repacks_found": "Источники для этой игры не найдены"
+ "no_repacks_found": "Источники для этой игры не найдены",
+ "show": "Показать",
+ "hide": "Скрыть",
+ "delete_review": "Удалить отзыв",
+ "remove_review": "Удалить отзыв",
+ "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
+ "delete_review_modal_description": "Это действие нельзя отменить.",
+ "delete_review_modal_delete_button": "Удалить",
+ "delete_review_modal_cancel_button": "Отмена",
+ "vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.",
+ "show_original": "Показать оригинал",
+ "show_translation": "Показать перевод",
+ "show_original_translated_from": "Показать оригинал (переведено с {{language}})",
+ "hide_original": "Скрыть оригинал",
+ "review_from_blocked_user": "Отзыв от заблокированного пользователя"
},
"activation": {
"title": "Активировать Hydra",
@@ -427,6 +431,9 @@
"validate_download_source": "Проверить",
"remove_download_source": "Удалить",
"add_download_source": "Добавить источник",
+ "adding": "Добавление…",
+ "failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.",
+ "download_source_already_exists": "Этот URL источника уже существует.",
"download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке",
@@ -434,9 +441,16 @@
"add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка",
+ "download_source_pending_matching": "Скоро обновится",
+ "download_source_matched": "Обновлен",
+ "download_source_matching": "Обновление",
+ "download_source_failed": "Ошибка",
+ "download_source_no_information": "Информация отсутствует",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены",
+ "removed_all_download_sources": "Все источники удалены",
+ "download_sources_synced_successfully": "Все источники синхронизированы",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"title_confirmation_delete_all_sources": "Удалить все источники",
@@ -467,6 +481,7 @@
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт",
+ "hydra_cloud": "Hydra Cloud",
"no_users_blocked": "У вас нет заблокированных пользователей",
"subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}",
"manage_subscription": "Управлять подпиской",
@@ -540,7 +555,9 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
- "enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
+ "enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
+ "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
+ "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -590,6 +607,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
+ "sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",
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 a533de1a..aaac89dd 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -64,14 +64,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/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts
index b862abbe..dd65165a 100644
--- a/src/main/services/achievements/achievement-watcher-manager.ts
+++ b/src/main/services/achievements/achievement-watcher-manager.ts
@@ -167,6 +167,8 @@ export class AchievementWatcherManager {
shop: GameShop,
objectId: string
) {
+ if (shop === "custom") return;
+
const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return;
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/create-game.ts b/src/main/services/library-sync/create-game.ts
index a346d3b4..e9ec9612 100644
--- a/src/main/services/library-sync/create-game.ts
+++ b/src/main/services/library-sync/create-game.ts
@@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
+ if (game.shop === "custom") {
+ return;
+ }
+
return HydraApi.post(`/profile/games`, {
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),
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 33d3e3b5..92cd66d8 100644
--- a/src/main/services/library-sync/merge-with-remote-games.ts
+++ b/src/main/services/library-sync/merge-with-remote-games.ts
@@ -79,6 +79,7 @@ export const mergeWithRemoteGames = async () => {
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
+ downloadSources: game.downloadSources,
});
}
})
diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts
index 3689b302..a669a363 100644
--- a/src/main/services/library-sync/update-game-playtime.ts
+++ b/src/main/services/library-sync/update-game-playtime.ts
@@ -1,12 +1,16 @@
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
-export const updateGamePlaytime = async (
+export const trackGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
- return HydraApi.put(`/profile/games/${game.remoteId}`, {
+ if (game.shop === "custom") {
+ return;
+ }
+
+ return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});
diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts
index 06f5f7d8..db5bbee1 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 { createGame, trackGamePlaytime } from "./library-sync";
+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";
@@ -198,19 +198,32 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) {
const now = performance.now();
- AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
- game.shop,
- game.objectId
- );
-
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now,
firstTick: now,
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.shop === "custom") return;
+
+ AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
+ game.shop,
+ game.objectId
+ );
+
if (game.remoteId) {
- updateGamePlaytime(
+ trackGamePlaytime(
game,
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
new Date()
@@ -244,43 +257,46 @@ function onTickGame(game: Game) {
const delta = now - gamePlaytime.lastTick;
- gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+ const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
- });
+ };
+
+ gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
});
- if (currentTick % TICKS_TO_UPDATE_API === 0) {
+ if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") {
const deltaToSync =
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
const gamePromise = game.remoteId
- ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
+ ? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
: createGame(game);
gamePromise
.then(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
})
.finally(() => {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
+ lastTick: now,
lastSyncTick: now,
});
});
@@ -288,11 +304,24 @@ function onTickGame(game: Game) {
}
const onCloseGame = (game: Game) => {
+ const now = performance.now();
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
+ const delta = now - gamePlaytime.lastTick;
+
+ const updatedGame: Game = {
+ ...game,
+ playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
+ lastTimePlayed: new Date(),
+ };
+
+ gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
+
+ if (game.shop === "custom") return;
+
if (game.remoteId) {
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
@@ -304,20 +333,20 @@ const onCloseGame = (game: Game) => {
}
const deltaToSync =
- performance.now() -
+ now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
- return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
+ return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
.then(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
});
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..b11b4a9b 100644
--- a/src/main/services/window-manager.ts
+++ b/src/main/services/window-manager.ts
@@ -25,6 +25,7 @@ import type {
} from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants";
+import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
@@ -54,21 +55,25 @@ export class WindowManager {
show: false,
};
+ private static formatVersionNumber(version: string) {
+ return version.replaceAll(".", "-");
+ }
+
private static async loadWindowURL(window: BrowserWindow, hash: string = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
- } else if (import.meta.env.MAIN_VITE_RENDERER_URL) {
+ } else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) {
// Try to load from remote URL in production
try {
await window.loadURL(
- `${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
+ `https://release-v${this.formatVersionNumber(app.getVersion())}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}`
);
} catch (error) {
// Fall back to local file if remote URL fails
- console.error(
- "Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:",
+ logger.error(
+ "Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:",
error
);
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
@@ -284,12 +289,6 @@ export class WindowManager {
}
}
- private static loadNotificationWindowURL() {
- if (this.notificationWindow) {
- this.loadWindowURL(this.notificationWindow, "achievement-notification");
- }
- }
-
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
@@ -297,46 +296,58 @@ export class WindowManager {
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
- const { width, height } = display.workAreaSize;
+ const {
+ x: displayX,
+ y: displayY,
+ width: displayWidth,
+ height: displayHeight,
+ } = display.bounds;
if (position === "bottom-left") {
return {
- x: 0,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-center") {
return {
- x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
- x: width - this.NOTIFICATION_WINDOW_WIDTH,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
+ };
+ }
+
+ if (position === "top-left") {
+ return {
+ x: displayX,
+ y: displayY,
};
}
if (position === "top-center") {
return {
- x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
- y: 0,
+ x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
+ y: displayY,
};
}
if (position === "top-right") {
return {
- x: width - this.NOTIFICATION_WINDOW_WIDTH,
- y: 0,
+ x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
+ y: displayY,
};
}
return {
- x: 0,
- y: 0,
+ x: displayX,
+ y: displayY,
};
}
@@ -382,7 +393,7 @@ export class WindowManager {
this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
- this.loadNotificationWindowURL();
+ this.loadWindowURL(this.notificationWindow, "achievement-notification");
if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools();
@@ -462,6 +473,7 @@ export class WindowManager {
editorWindow.once("ready-to-show", () => {
editorWindow.show();
+ this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools();
}
@@ -469,11 +481,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/main/vite-env.d.ts b/src/main/vite-env.d.ts
index c9b006d5..7b0ed536 100644
--- a/src/main/vite-env.d.ts
+++ b/src/main/vite-env.d.ts
@@ -7,7 +7,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
- readonly MAIN_VITE_RENDERER_URL: string;
+ readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 2b8816c9..fc588a30 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/components/header/header.tsx b/src/renderer/src/components/header/header.tsx
index 328d7f64..0f452bf2 100644
--- a/src/renderer/src/components/header/header.tsx
+++ b/src/renderer/src/components/header/header.tsx
@@ -63,7 +63,7 @@ export function Header() {
};
const handleSearch = (value: string) => {
- dispatch(setFilters({ title: value }));
+ dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx
index 1c4d54af..76759126 100644
--- a/src/renderer/src/components/text-field/text-field.tsx
+++ b/src/renderer/src/components/text-field/text-field.tsx
@@ -1,9 +1,7 @@
-import React, { useId, useMemo, useState } from "react";
+import React, { useId, useState } from "react";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
-
import cn from "classnames";
-
import "./text-field.scss";
export interface TextFieldProps
@@ -42,44 +40,30 @@ export const TextField = React.forwardRef(
) => {
const id = useId();
const [isFocused, setIsFocused] = useState(false);
-
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
-
const { t } = useTranslation("forms");
-
const showPasswordToggleButton = props.type === "password";
-
- const inputType = useMemo(() => {
- if (props.type === "password" && isPasswordVisible) return "text";
- return props.type ?? "text";
- }, [props.type, isPasswordVisible]);
-
- const hintContent = useMemo(() => {
- if (error)
- return (
- {error}
- );
-
- if (hint) return {hint};
- return null;
- }, [hint, error]);
-
+ const inputType =
+ props.type === "password" && isPasswordVisible
+ ? "text"
+ : (props.type ?? "text");
+ const hintContent = error ? (
+ {error}
+ ) : hint ? (
+ {hint}
+ ) : null;
const handleFocus: React.FocusEventHandler = (event) => {
setIsFocused(true);
- if (props.onFocus) props.onFocus(event);
+ props.onFocus?.(event);
};
-
const handleBlur: React.FocusEventHandler = (event) => {
setIsFocused(false);
- if (props.onBlur) props.onBlur(event);
+ props.onBlur?.(event);
};
-
const hasError = !!error;
-
return (
{label &&
}
-
(
onBlur={handleBlur}
type={inputType}
/>
-
{showPasswordToggleButton && (
)}
-
{rightContent}
-
{hintContent}
);
}
);
-
TextField.displayName = "TextField";
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..bc1a6351 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,36 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
+ useEffect(() => {
+ if (shop === "custom") return;
+
+ 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 +366,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 fc5a4b97..65f2ce9e 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";
@@ -211,20 +209,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..b9eb3c24 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(
+ const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch
);
- const [downloadSources, setDownloadSources] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState([]);
@@ -44,31 +47,46 @@ export default function Catalogue() {
const { formatNumber } = useFormat();
- const { filters, page } = useAppSelector((state) => state.catalogueSearch);
-
const dispatch = useAppDispatch();
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 +97,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 +185,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..ecc2afe3 100644
--- a/src/renderer/src/pages/catalogue/pagination.tsx
+++ b/src/renderer/src/pages/catalogue/pagination.tsx
@@ -1,8 +1,53 @@
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;
@@ -13,23 +58,104 @@ export function Pagination({
page,
totalPages,
onPageChange,
-}: PaginationProps) {
+}: Readonly) {
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 raw = e.target.value;
+ const digitsOnly = raw.replaceAll(/\D+/g, "");
+ if (digitsOnly === "") {
+ setJumpValue("");
+ return;
+ }
+ const num = Number.parseInt(digitsOnly, 10);
+ if (Number.isNaN(num)) {
+ setJumpValue("");
+ return;
+ }
+ if (num < 1) {
+ setJumpValue("1");
+ return;
+ }
+ if (num > totalPages) {
+ setJumpValue(String(totalPages));
+ return;
+ }
+ setJumpValue(String(num));
+ };
+
+ const onJumpKeyDown = (e: KeyboardEvent) => {
+ const controlKeys = [
+ "Backspace",
+ "Delete",
+ "Tab",
+ "ArrowLeft",
+ "ArrowRight",
+ "Home",
+ "End",
+ ];
+
+ if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
+ return;
+ }
+
+ if (e.key === "Enter") {
+ const sanitized = jumpValue.replaceAll(/\D+/g, "");
+ if (sanitized.trim() === "") return;
+ const parsed = Number.parseInt(sanitized, 10);
+ 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);
+ } else if (!/^\d$/.test(e.key)) {
+ e.preventDefault();
+ }
+ };
+
return (
+ {startPage > 1 && (
+
+ )}
+
- {page > 2 && (
+ {isLastThree && startPage > 1 && (
<>
-
-
- ...
-
+
setIsJumpOpen(true)}
+ onClose={() => setIsJumpOpen(false)}
+ onChange={onJumpChange}
+ onKeyDown={onJumpKeyDown}
+ />
>
)}
@@ -70,11 +201,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..2dfd8864 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();
@@ -164,7 +163,6 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
- language: i18n.language,
});
const response = await window.electron.hydraApi.get(
@@ -440,8 +438,6 @@ export function GameReviews({
});
}, [reviews]);
- console.log("reviews", reviews);
-
return (
{showReviewPrompt &&
@@ -469,84 +465,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..b3577a75 100644
--- a/src/renderer/src/pages/game-details/review-item.scss
+++ b/src/renderer/src/pages/game-details/review-item.scss
@@ -1,6 +1,237 @@
@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.45);
+ }
+
+ &__review-meta-row {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.75);
+ }
+
+ &__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: 4px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 2px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ font-size: 11px;
+ font-weight: 500;
+ }
+
+ &__review-right {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ }
+
+ &__review-star {
+ color: rgba(255, 255, 255, 0.7);
+ transition: color 0.2s ease;
+ cursor: default;
+
+ &--filled {
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ &--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-playtime {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+ font-weight: 500;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 2px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ margin-top: 0;
+
+ svg {
+ color: rgba(255, 255, 255, 0.6);
+ }
+ }
+
+ &__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/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx
index f5e3528a..6b03e7ea 100644
--- a/src/renderer/src/pages/game-details/review-item.tsx
+++ b/src/renderer/src/pages/game-details/review-item.tsx
@@ -7,9 +7,10 @@ import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
-import { useDate } from "@renderer/hooks";
+import { useDate, useFormat } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
+import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./review-item.scss";
@@ -29,13 +30,6 @@ interface ReviewItemProps {
) => void;
}
-const getScoreColorClass = (score: number): string => {
- if (score >= 1 && score <= 2) return "game-details__review-score--red";
- if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
- if (score >= 4 && score <= 5) return "game-details__review-score--green";
- return "";
-};
-
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
@@ -68,28 +62,22 @@ export function ReviewItem({
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
+ const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false);
- // Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id;
- // Helper to get base language code (e.g., "pt" from "pt-BR")
- const getBaseLanguage = (lang: string) => lang.split("-")[0];
+ const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
- // Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
- // Check if translation is available and needed (but not for own reviews)
const needsTranslation =
- !isOwnReview &&
- isDifferentLanguage &&
- review.translations &&
- review.translations[i18n.language];
+ !isOwnReview && isDifferentLanguage && review.translations[i18n.language];
- // Get the full language name using Intl.DisplayNames
- const getLanguageName = (languageCode: string) => {
+ const getLanguageName = (languageCode: string | null) => {
+ if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
@@ -100,6 +88,20 @@ export function ReviewItem({
}
};
+ // Format playtime similar to hero panel
+ const formatPlayTime = (playTimeInSeconds: number) => {
+ const minutes = playTimeInSeconds / 60;
+
+ if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
+ return t("amount_minutes", {
+ amount: minutes.toFixed(0),
+ });
+ }
+
+ const hours = minutes / 60;
+ return t("amount_hours", { amount: numberFormatter.format(hours) });
+ };
+
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation
? review.translations[i18n.language]
@@ -109,12 +111,12 @@ export function ReviewItem({
return (
- Review from blocked user —{" "}
+ {t("review_from_blocked_user")}
@@ -144,34 +146,40 @@ export function ReviewItem({
>
{review.user.displayName || "Anonymous"}
-
-
- {formatDistance(new Date(review.createdAt), new Date(), {
- addSuffix: true,
- })}
+
+
+
+
+ {review.score}/5
+
+
+ {Boolean(
+ review.playTimeInSeconds && review.playTimeInSeconds > 0
+ ) && (
+
+
+
+ {t("review_played_for")}{" "}
+ {formatPlayTime(review.playTimeInSeconds || 0)}
+
+
+ )}
-
- {[1, 2, 3, 4, 5].map((starValue) => (
-
- ))}
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), {
+ addSuffix: true,
+ })}
+
@@ -323,7 +331,7 @@ export function ReviewItem({
className="game-details__blocked-review-hide-link"
onClick={() => onToggleVisibility(review.id)}
>
- Hide
+ {t("hide")}
)}
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);
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);
+ 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);
+ } 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);
- // 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={