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á", + "debrid_api_token_hint": "Podés obtener el token de tu API <0>acá", "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ījumus", + "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>šeit", + "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ījumiem.", + "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 ( )} - {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" : ""}`} >
-
+
{game?.title} -
+ +
+ +
- -
@@ -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={