diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..0b0c009c --- /dev/null +++ b/.cursorrules @@ -0,0 +1,29 @@ +# Hydra Project Rules + +## Logging + +- **Always use `logger` instead of `console` for logging** in both main and renderer processes +- In main process: `import { logger } from "@main/services";` +- In renderer process: `import { logger } from "@renderer/logger";` +- Replace all instances of: + - `console.log()` → `logger.log()` + - `console.error()` → `logger.error()` + - `console.warn()` → `logger.warn()` + - `console.info()` → `logger.info()` + - `console.debug()` → `logger.debug()` +- Do not use `console` for any logging purposes + +## Internationalization (i18n) + +- All user-facing strings must be translated using i18next +- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");` +- Add new translation keys to `src/locales/en/translation.json` +- Never hardcode English strings in the UI code +- Placeholder text in form fields must also be translated + +## Code Style + +- Use ESLint and Prettier for code formatting +- Follow TypeScript strict mode conventions +- Use async/await instead of promises when possible +- Prefer named exports over default exports for utilities and services diff --git a/.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 new file mode 100644 index 00000000..34f7d303 --- /dev/null +++ b/.github/workflows/build-renderer.yml @@ -0,0 +1,54 @@ +name: Build Renderer + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + 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: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.21.0 + cache: "yarn" + + - name: Enable Corepack (Yarn) + run: corepack enable + + - name: Install dependencies + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Build Renderer + run: yarn build + env: + RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} + + - name: Deploy to Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx --yes wrangler@3 pages deploy out/renderer \ + --project-name="hydra" \ + --branch "$BRANCH_NAME" \ + --commit-dirty diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f3e0a66..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.0 + 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 0a20e469..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.0 + 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 babfb565..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.0 + 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,6 +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_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Build Windows if: matrix.os == 'windows-2022' @@ -73,6 +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_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Create artifact uses: actions/upload-artifact@v4 @@ -88,7 +95,6 @@ jobs: dist/*.tar.gz dist/*.yml dist/*.blockmap - dist/*.pacman - name: Upload build env: @@ -117,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..52fe907e --- /dev/null +++ b/.github/workflows/update-aur.yml @@ -0,0 +1,159 @@ +name: Update AUR Package + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + update-aur: + runs-on: ubuntu-latest + container: + image: archlinux:latest + + steps: + - name: Install dependencies + run: | + pacman -Syu --noconfirm + pacman -S --noconfirm nodejs npm git base-devel openssh jq pacman-contrib + + - name: Create builder user + run: | + # Create builder user with home directory + useradd -m -s /bin/bash builder + + # Add builder to wheel group for sudo access + usermod -aG wheel builder + + # Configure sudo for builder user (no password required) + echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers + + - name: Setup SSH for AUR + run: | + mkdir -p ~/.ssh + echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + chmod 700 ~/.ssh + + # Add AUR host key to known_hosts + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + + # Configure SSH to use the key + cat > ~/.ssh/config << EOF + Host aur.archlinux.org + IdentityFile ~/.ssh/id_rsa + IdentitiesOnly yes + User aur + UserKnownHostsFile ~/.ssh/known_hosts + StrictHostKeyChecking no + EOF + + # Start SSH agent and add key + eval "$(ssh-agent -s)" + ssh-add ~/.ssh/id_rsa + + export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts" + + git clone ssh://aur@aur.archlinux.org/hydra-launcher-bin.git + + # Give builder user ownership of the repository + chown -R builder:builder hydra-launcher-bin + + - name: Get version to update + id: get-version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "source=release" >> $GITHUB_OUTPUT + else + echo "Getting latest release version" + VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "source=latest" >> $GITHUB_OUTPUT + fi + + echo "Version to update: $VERSION" + + - name: Check if update is needed + id: check-update + run: | + CURRENT_VERSION=$(grep '^pkgver=' hydra-launcher-bin/PKGBUILD | cut -d'=' -f2) + NEW_VERSION="${{ steps.get-version.outputs.version }}" + + echo "Current AUR version: $CURRENT_VERSION" + echo "New version: $NEW_VERSION" + + if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then + echo "update_needed=false" >> $GITHUB_OUTPUT + echo "No update needed - versions are the same" + else + echo "update_needed=true" >> $GITHUB_OUTPUT + echo "Update needed" + fi + + - name: Update PKGBUILD and .SRCINFO + if: steps.check-update.outputs.update_needed == 'true' + run: | + # Update pkgver in PKGBUILD + cd hydra-launcher-bin + NEW_VERSION="${{ steps.get-version.outputs.version }}" + 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 e21c962a..5d84e763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.6.8", + "version": "3.7.2", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -32,17 +32,24 @@ "protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto" }, "dependencies": { - "@electron-toolkit/preload": "^3.0.0", - "@electron-toolkit/utils": "^3.0.0", - "@fontsource/noto-sans": "^5.1.0", - "@hookform/resolvers": "^3.9.1", + "@electron-toolkit/preload": "^3.0.2", + "@electron-toolkit/utils": "^4.0.0", + "@fontsource/noto-sans": "^5.2.10", + "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.6.0", "@primer/octicons-react": "^19.9.0", - "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@reduxjs/toolkit": "^2.2.3", + "@tiptap/extension-bold": "^3.6.2", + "@tiptap/extension-italic": "^3.6.2", + "@tiptap/extension-link": "^3.6.2", + "@tiptap/extension-underline": "^3.6.2", + "@tiptap/react": "^3.6.2", + "@tiptap/starter-kit": "^3.6.2", "auto-launch": "^5.0.6", - "axios": "^1.7.9", + "axios": "^1.12.2", "axios-cookiejar-support": "^5.0.5", + "check-disk-space": "^3.4.0", "classic-level": "^2.0.0", "classnames": "^2.5.1", "color": "^4.2.3", @@ -50,9 +57,7 @@ "crc": "^4.3.2", "create-desktop-shortcuts": "^1.11.1", "date-fns": "^3.6.0", - "dexie": "^4.0.10", - "diskusage": "^1.2.0", - "electron-log": "^5.2.4", + "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", @@ -63,6 +68,7 @@ "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", "lodash-es": "^4.17.21", + "lucide-react": "^0.544.0", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", "react-dnd": "^16.0.1", @@ -80,11 +86,11 @@ "tar": "^7.4.3", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", + "uuid": "^13.0.0", "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", @@ -109,9 +115,9 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^32.3.3", + "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", @@ -123,8 +129,8 @@ "sass-embedded": "^1.80.6", "ts-node": "^10.9.2", "typescript": "^5.3.3", - "vite": "^5.0.12", - "vite-plugin-svgr": "^4.2.0" + "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 0c1ed914..668f1547 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -76,7 +76,19 @@ "edit_game_modal_drop_hero_image_here": "Drop hero image here", "edit_game_modal_drop_to_replace_icon": "Drop to replace icon", "edit_game_modal_drop_to_replace_logo": "Drop to replace logo", - "edit_game_modal_drop_to_replace_hero": "Drop to replace hero" + "edit_game_modal_drop_to_replace_hero": "Drop to replace hero", + "install_decky_plugin": "Install Decky Plugin", + "update_decky_plugin": "Update Decky Plugin", + "decky_plugin_installed_version": "Decky Plugin (v{{version}})", + "install_decky_plugin_title": "Install Hydra Decky Plugin", + "install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?", + "update_decky_plugin_title": "Update Hydra Decky Plugin", + "update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?", + "decky_plugin_installed": "Decky plugin v{{version}} installed successfully", + "decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}", + "decky_plugin_installation_error": "Error installing Decky plugin: {{error}}", + "confirm": "Confirm", + "cancel": "Cancel" }, "header": { "search": "Search games", @@ -200,6 +212,7 @@ "stats": "Stats", "download_count": "Downloads", "player_count": "Active players", + "rating_count": "Rating", "download_error": "This download option is not available", "download": "Download", "executable_path_in_use": "Executable already in use by \"{{game}}\"", @@ -207,6 +220,39 @@ "hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.", "achievements": "Achievements", "achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}", + "show_more": "Show more", + "show_less": "Show less", + "reviews": "Reviews", + "leave_a_review": "Leave a Review", + "write_review_placeholder": "Share your thoughts about this game...", + "sort_newest": "Newest", + "no_reviews_yet": "No reviews yet", + "be_first_to_review": "Be the first to share your thoughts about this game!", + "sort_oldest": "Oldest", + "sort_highest_score": "Highest Score", + "sort_lowest_score": "Lowest Score", + "sort_most_voted": "Most Voted", + "rating": "Rating", + "rating_stats": "Rating", + "rating_very_negative": "Very Negative", + "rating_negative": "Negative", + "rating_neutral": "Neutral", + "rating_positive": "Positive", + "rating_very_positive": "Very Positive", + "submit_review": "Submit", + "submitting": "Submitting...", + "review_submitted_successfully": "Review submitted successfully!", + "review_submission_failed": "Failed to submit review. Please try again.", + "review_cannot_be_empty": "Review text field cannot be empty.", + "review_deleted_successfully": "Review deleted successfully.", + "review_deletion_failed": "Failed to delete review. Please try again.", + "loading_reviews": "Loading reviews...", + "loading_more_reviews": "Loading more reviews...", + "load_more_reviews": "Load more reviews", + "you_seemed_to_enjoy_this_game": "You've seemed to enjoy this game", + "would_you_recommend_this_game": "Would you like to leave a review to this game?", + "yes": "Yes", + "maybe_later": "Maybe later", "cloud_save": "Cloud save", "cloud_save_description": "Save your progress in the cloud and continue playing on any device", "backups": "Backups", @@ -219,6 +265,7 @@ "uploading_backup": "Uploading backup…", "no_backups": "You haven't created any backups for this game yet", "backup_uploaded": "Backup uploaded", + "backup_failed": "Backup failed", "backup_deleted": "Backup deleted", "backup_restored": "Backup restored", "see_all_achievements": "See all achievements", @@ -303,7 +350,18 @@ "caption": "Caption", "audio": "Audio", "filter_by_source": "Filter by source", - "no_repacks_found": "No sources found for this game" + "no_repacks_found": "No sources found for this game", + "delete_review": "Delete review", + "remove_review": "Remove Review", + "delete_review_modal_title": "Are you sure you want to delete your review?", + "delete_review_modal_description": "This action cannot be undone.", + "delete_review_modal_delete_button": "Delete", + "delete_review_modal_cancel_button": "Cancel", + "vote_failed": "Failed to register your vote. Please try again.", + "show_original": "Show original", + "show_translation": "Show translation", + "show_original_translated_from": "Show original (translated from {{language}})", + "hide_original": "Hide original" }, "activation": { "title": "Activate Hydra", @@ -341,7 +399,6 @@ "stop_seeding": "Stop seeding", "resume_seeding": "Resume seeding", "options": "Manage", - "alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet", "extract": "Extract files", "extracting": "Extracting files…" }, @@ -371,6 +428,9 @@ "validate_download_source": "Validate", "remove_download_source": "Remove", "add_download_source": "Add source", + "adding": "Adding…", + "failed_add_download_source": "Failed to add download source. Please try again.", + "download_source_already_exists": "This download source URL already exists.", "download_count_zero": "No download options", "download_count_one": "{{countFormatted}} download option", "download_count_other": "{{countFormatted}} download options", @@ -378,9 +438,16 @@ "add_download_source_description": "Insert the URL of the .json file", "download_source_up_to_date": "Up-to-date", "download_source_errored": "Errored", + "download_source_pending_matching": "Updating soon", + "download_source_matched": "Up to date", + "download_source_matching": "Updating", + "download_source_failed": "Error", + "download_source_no_information": "No information available", "sync_download_sources": "Sync sources", "removed_download_source": "Download source removed", "removed_download_sources": "Download sources removed", + "removed_all_download_sources": "All download sources removed", + "download_sources_synced_successfully": "All download sources are synced", "cancel_button_confirmation_delete_all_sources": "No", "confirm_button_confirmation_delete_all_sources": "Yes, delete everything", "title_confirmation_delete_all_sources": "Delete all download sources", @@ -393,6 +460,7 @@ "found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_other": "Found {{countFormatted}} download options", "import": "Import", + "importing": "Importing...", "public": "Public", "private": "Private", "friends_only": "Friends only", @@ -410,6 +478,7 @@ "seed_after_download_complete": "Seed after download complete", "show_hidden_achievement_description": "Show hidden achievements description before unlocking them", "account": "Account", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "You have no blocked users", "subscription_active_until": "Your Hydra Cloud is active until {{date}}", "manage_subscription": "Manage subscription", @@ -445,23 +514,14 @@ "delete_theme_description": "This will delete the theme {{theme}}", "cancel": "Cancel", "appearance": "Appearance", + "debrid": "Debrid", + "debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.", "enable_torbox": "Enable TorBox", "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.", "torbox_account_linked": "TorBox account linked", "create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet", "create_torbox_account": "Click here if you don't have a TorBox account yet", "real_debrid_account_linked": "Real-Debrid account linked", - "enable_all_debrid": "Enable All-Debrid", - "all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.", - "all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid", - "all_debrid_account_linked": "All-Debrid account linked successfully", - "alldebrid_missing_key": "Please provide an API key", - "alldebrid_invalid_key": "Invalid API key", - "alldebrid_blocked": "Your API key is geo-blocked or IP-blocked", - "alldebrid_banned": "This account has been banned", - "alldebrid_unknown_error": "An unknown error occurred", - "alldebrid_invalid_response": "Invalid response from All-Debrid", - "alldebrid_network_error": "Network error. Please check your connection", "name_min_length": "Theme name must be at least 3 characters long", "import_theme": "Import theme", "import_theme_description": "You will import {{theme}} from the theme store", @@ -492,7 +552,9 @@ "hidden": "Hidden", "test_notification": "Test notification", "notification_preview": "Achievement Notification Preview", - "enable_friend_start_game_notifications": "When a friend starts playing a game" + "enable_friend_start_game_notifications": "When a friend starts playing a game", + "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", + "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" }, "notifications": { "download_complete": "Download complete", @@ -519,7 +581,8 @@ "game_card": { "available_one": "Available", "available_other": "Available", - "no_downloads": "No downloads available" + "no_downloads": "No downloads available", + "calculating": "Calculating" }, "binary_not_found_modal": { "title": "Programs not installed", @@ -541,6 +604,7 @@ "activity": "Recent Activity", "library": "Library", "pinned": "Pinned", + "sort_by": "Sort by:", "achievements_earned": "Achievements earned", "played_recently": "Played recently", "playtime": "Playtime", @@ -622,7 +686,10 @@ "error_adding_friend": "Could not send friend request. Please check friend code", "friend_code_length_error": "Friend code must have 8 characters", "game_removed_from_pinned": "Game removed from pinned", - "game_added_to_pinned": "Game added to pinned" + "game_added_to_pinned": "Game added to pinned", + "karma": "Karma", + "karma_count": "karma", + "karma_description": "Earned from positive likes on reviews" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 6f0fc9f1..863b8332 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": { @@ -204,6 +222,7 @@ "uploading_backup": "Subiendo copia de seguridad…", "no_backups": "No has creado ninguna copia de seguridad para este juego todavía", "backup_uploaded": "Copia de seguridad subida", + "backup_failed": "Copia de seguridad fallida", "backup_deleted": "Copia de seguridad eliminada", "backup_restored": "Copia de seguridad restaurada", "see_all_achievements": "Ver todos los logros", @@ -284,6 +303,62 @@ "keyshop_price": "Precio de tiendas de terceros", "historical_retail": "Precio de tiendas", "historical_keyshop": "Precio de tiendas de terceros", + "add_to_favorites": "Añadir a favoritos", + "be_first_to_review": "¡Sé la primera persona en compartir lo que pensas de este juego!", + "create_shortcut_simple": "Crear atajo", + "delete_review": "Eliminar reseña", + "delete_review_modal_cancel_button": "Cancelar", + "delete_review_modal_delete_button": "Eliminar", + "delete_review_modal_description": "Esta acción no se puede deshacer.", + "delete_review_modal_title": "¿De verdad querés eliminar esta reseña?", + "failed_remove_files": "Error al eliminar los archivos", + "failed_remove_from_library": "Error al eliminar de la librería", + "failed_update_favorites": "Error al actualizar favoritos", + "files_removed_success": "Archivos eliminados correctamente", + "filter_by_source": "Filtrar por fuente", + "game_removed_from_library": "Juego eliminado de la librería", + "hide_original": "Ocultar original", + "leave_a_review": "Crear una reseña", + "load_more_reviews": "Cargar más reseñas", + "loading_more_reviews": "Cargando más reseñas...", + "loading_reviews": "Cargando reseñas...", + "maybe_later": "Tal vez después", + "no_repacks_found": "Sin fuentes encontradas para este juego", + "no_reviews_yet": "Sin reseñas aún", + "properties": "Propiedades", + "rating": "Calificación", + "rating_count": "Calificación", + "rating_negative": "Negativa", + "rating_neutral": "Neutral", + "rating_positive": "Positiva", + "rating_stats": "Calificación", + "rating_very_negative": "Muy Negativa", + "rating_very_positive": "Muy Positiva", + "remove_from_favorites": "Eliminar de favoritos", + "remove_review": "Eliminar reseña", + "review_cannot_be_empty": "El campo de la reseña no puede estar vacío.", + "review_deleted_successfully": "Reseña eliminada exitosamente.", + "review_deletion_failed": "Error al eliminar reseña. Por favor intentá de nuevo.", + "review_submission_failed": "Error al subir reseña. Por favor intentá de nuevo.", + "review_submitted_successfully": "¡Reseña eliminada exitosamente!", + "reviews": "Reseñas", + "show_less": "Ver menos", + "show_more": "Ver más", + "show_original": "Ver original", + "show_original_translated_from": "Ver original (traducido del {{language}})", + "show_translation": "Ver traducción", + "sort_highest_score": "Puntuación más alta", + "sort_lowest_score": "Puntuación más baja", + "sort_most_voted": "Más votads", + "sort_newest": "Más nuevos", + "sort_oldest": "Más viejos", + "submit_review": "Enviar", + "submitting": "Subiendo...", + "vote_failed": "Error al registrar tu voto. Por favor intentá de nuevo.", + "would_you_recommend_this_game": "¿Querés escribir una reseña para este juego?", + "write_review_placeholder": "Compartí tus pensamientos sobre este juego...", + "yes": "Si", + "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego", "language": "Idioma", "caption": "Subtítulo", "audio": "Audio" @@ -344,7 +419,7 @@ "enable_real_debrid": "Habilitar Real-Debrid", "real_debrid_description": "Real-Debrid es un descargador que te permite descargar archivos más rápidos, solo límitado por la velocidad de tu internet.", "debrid_invalid_token": "Token API inválido", - "debrid_api_token_hint": "Podés obtener la el token de tu API <0>acá", + "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", @@ -356,7 +431,7 @@ "download_count_zero": "Sin opciones de descarga", "download_count_one": "{{countFormatted}} opción de descarga", "download_count_other": "{{countFormatted}} opciones de descarga", - "download_source_url": "Descargar fuente URL", + "download_source_url": "Añadir URL de una fuente", "add_download_source_description": "Introducí la URL del archivo .json", "download_source_up_to_date": "Actualizado", "download_source_errored": "Error", @@ -375,6 +450,7 @@ "found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga", "found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas", "import": "Importar", + "importing": "Importando...", "public": "Público", "private": "Privado", "friends_only": "Sólo amigos", @@ -407,7 +483,7 @@ "subscription_renew_cancelled": "Renovación automática desactivada", "subscription_renews_on": "Tu suscripción se renueva el {{date}}", "bill_sent_until": "Tu próxima factura se enviará este día", - "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupés, presiona acá para hacer tu primera obra maestra.", + "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para hacer tu primera obra maestra.", "editor_tab_code": "Código", "editor_tab_info": "Info", "editor_tab_save": "Guardar", @@ -441,7 +517,7 @@ "enable_friend_request_notifications": "Cuando recibís una solicitud de amistad", "enable_auto_install": "Descargar actualizaciones automáticamente", "common_redist": "Common redistributables", - "common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.", + "common_redist_description": "Los common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.", "install_common_redist": "Instalar", "installing_common_redist": "Instalando…", "show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo", @@ -463,7 +539,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", @@ -490,6 +570,7 @@ "game_card": { "available_one": "Disponible", "available_other": "Disponibles", + "calculating": "Calculando", "no_downloads": "Sin descargas disponibles" }, "binary_not_found_modal": { @@ -591,6 +672,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/fi/translation.json b/src/locales/fi/translation.json new file mode 100644 index 00000000..fee3ff22 --- /dev/null +++ b/src/locales/fi/translation.json @@ -0,0 +1,708 @@ +{ + "language_name": "Suomi", + "app": { + "successfully_signed_in": "Kirjautuminen onnistui" + }, + "home": { + "surprise_me": "Yllätä minut", + "no_results": "Ei tuloksia", + "start_typing": "Aloitan kirjoittamisen...", + "hot": "Suosittua nyt", + "weekly": "📅 Viikon parhaat pelit", + "achievements": "🏆 Pelit saavutuksilla" + }, + "sidebar": { + "catalogue": "Katalogi", + "downloads": "Lataukset", + "settings": "Asetukset", + "my_library": "Kirjasto", + "downloading_metadata": "{{title}} (Metatietojen lataus…)", + "paused": "{{title}} (Keskeytetty)", + "downloading": "{{title}} ({{percentage}} - Lataa…)", + "filter": "Hae", + "home": "Koti", + "queued": "{{title}} (Jonossa)", + "game_has_no_executable": "Pelin käynnistystiedostoa ei ole valittu", + "sign_in": "Kirjaudu sisään", + "friends": "Kaverit", + "need_help": "Tarvitsetko apua?", + "favorites": "Suosikit", + "playable_button_title": "Näytä vain asennetut pelit.", + "add_custom_game_tooltip": "Lisää mukautettu peli", + "show_playable_only_tooltip": "Näytä vain pelattavissa olevat", + "custom_game_modal": "Lisää mukautettu peli", + "custom_game_modal_description": "Lisää mukautettu peli kirjastoon valitsemalla suoritettava tiedosto", + "custom_game_modal_executable_path": "Suoritettavan tiedoston polku", + "custom_game_modal_select_executable": "Valitse suoritettava tiedosto", + "custom_game_modal_title": "Pelin nimi", + "custom_game_modal_enter_title": "Syötä pelin nimi", + "custom_game_modal_browse": "Selaa", + "custom_game_modal_cancel": "Peruuta", + "custom_game_modal_add": "Lisää peli", + "custom_game_modal_adding": "Lisätään peliä...", + "custom_game_modal_success": "Mukautettu peli lisätty onnistuneesti", + "custom_game_modal_failed": "Mukautetun pelin lisääminen epäonnistui", + "custom_game_modal_executable": "Suoritettava tiedosto", + "edit_game_modal": "Mukauta resursseja", + "edit_game_modal_description": "Mukauta pelin resursseja ja tietoja", + "edit_game_modal_title": "Nimi", + "edit_game_modal_enter_title": "Syötä nimi", + "edit_game_modal_image": "Kuva", + "edit_game_modal_select_image": "Valitse kuva", + "edit_game_modal_browse": "Selaa", + "edit_game_modal_image_preview": "Kuvan esikatselu", + "edit_game_modal_icon": "Kuvake", + "edit_game_modal_select_icon": "Valitse kuvake", + "edit_game_modal_icon_preview": "Kuvakkeen esikatselu", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Valitse logo", + "edit_game_modal_logo_preview": "Logon esikatselu", + "edit_game_modal_hero": "Pelin kansikuva", + "edit_game_modal_select_hero": "Valitse pelin kansikuva", + "edit_game_modal_hero_preview": "Kansikuvan esikatselu", + "edit_game_modal_cancel": "Peruuta", + "edit_game_modal_update": "Päivitä", + "edit_game_modal_updating": "Päivitetään...", + "edit_game_modal_fill_required": "Täytä kaikki pakolliset kentät", + "edit_game_modal_success": "Resurssit päivitetty onnistuneesti", + "edit_game_modal_failed": "Resurssien päivitys epäonnistui", + "edit_game_modal_image_filter": "Kuva", + "edit_game_modal_icon_resolution": "Suositeltu resoluutio: 256x256px", + "edit_game_modal_logo_resolution": "Suositeltu resoluutio: 640x360px", + "edit_game_modal_hero_resolution": "Suositeltu resoluutio: 1920x620px", + "edit_game_modal_assets": "Resurssit", + "edit_game_modal_drop_icon_image_here": "Pudota kuvakkeen kuva tähän", + "edit_game_modal_drop_logo_image_here": "Pudota logon kuva tähän", + "edit_game_modal_drop_hero_image_here": "Pudota kansikuvan kuva tähän", + "edit_game_modal_drop_to_replace_icon": "Pudota korvataksesi kuvake", + "edit_game_modal_drop_to_replace_logo": "Pudota korvataksesi logo", + "edit_game_modal_drop_to_replace_hero": "Pudota korvataksesi kansikuva", + "install_decky_plugin": "Asenna Decky-lisäosa", + "update_decky_plugin": "Päivitä Decky-lisäosa", + "decky_plugin_installed_version": "Decky-lisäosa (v{{version}})", + "install_decky_plugin_title": "Asenna Hydra Decky -lisäosa", + "install_decky_plugin_message": "Tämä lataa ja asentaa Hydra-lisäosan Decky Loaderiin. Saattaa vaatia korotetut oikeudet. Jatketaanko?", + "update_decky_plugin_title": "Päivitä Hydra Decky -lisäosa", + "update_decky_plugin_message": "Uusi Hydra Decky -lisäosan versio on saatavilla. Haluatko päivittää sen nyt?", + "decky_plugin_installed": "Decky-lisäosa v{{version}} asennettu onnistuneesti", + "decky_plugin_installation_failed": "Decky-lisäosan asennus epäonnistui: {{error}}", + "decky_plugin_installation_error": "Decky-lisäosan asennusvirhe: {{error}}", + "confirm": "Vahvista", + "cancel": "Peruuta" + }, + "header": { + "search": "Hae", + "home": "Koti", + "catalogue": "Katalogi", + "downloads": "Lataukset", + "search_results": "Hakutulokset", + "settings": "Asetukset", + "version_available_install": "Versio {{version}} saatavilla. Asentaaksesi napsauta tästä.", + "version_available_download": "Versio {{version}} saatavilla. Ladataaksesi napsauta tästä." + }, + "bottom_panel": { + "no_downloads_in_progress": "Ei meneillään olevia latauksia", + "downloading_metadata": "Ladataan metatietoja {{title}}…", + "downloading": "Ladataan {{title}}… ({{percentage}} valmis) - Lopetus {{eta}} - {{speed}}", + "calculating_eta": "Ladataan {{title}}… ({{percentage}} valmis) - Lasketaan jäljellä olevaa aikaa…", + "checking_files": "Tarkistetaan tiedostoja {{title}}… ({{percentage}} valmis)", + "installing_common_redist": "{{log}}…", + "installation_complete": "Asennus valmis", + "installation_complete_message": "Kirjastot asennettu onnistuneesti" + }, + "catalogue": { + "search": "Suodatin…", + "developers": "Kehittäjät", + "genres": "Genret", + "tags": "Tagit", + "publishers": "Julkaisijat", + "download_sources": "Latauslähteet", + "result_count": "{{resultCount}} tulosta", + "filter_count": "{{filterCount}} saatavilla", + "clear_filters": "Tyhjennä {{filterCount}} valittua" + }, + "game_details": { + "open_download_options": "Avaa lähteet", + "download_options_zero": "Ei lähteitä", + "download_options_one": "{{count}} lähde", + "download_options_other": "{{count}} lähdettä", + "updated_at": "Päivitetty {{updated_at}}", + "install": "Asenna", + "resume": "Jatka", + "pause": "Keskeytä", + "cancel": "Peruuta", + "remove": "Poista", + "space_left_on_disk": "{{space}} vapaana levyltä", + "eta": "Lopetus {{eta}}", + "calculating_eta": "Lasketaan jäljellä olevaa aikaa…", + "downloading_metadata": "Ladataan metatietoja…", + "filter": "Hae repackeja", + "requirements": "Järjestelmävaatimukset", + "minimum": "Minimi", + "recommended": "Suositeltu", + "paused": "Keskeytetty", + "release_date": "Julkaistu {{date}}", + "publisher": "Julkaisija {{publisher}}", + "hours": "tuntia", + "minutes": "minuuttia", + "amount_hours": "{{amount}} tuntia", + "amount_minutes": "{{amount}} minuuttia", + "accuracy": "tarkkuus {{accuracy}}%", + "add_to_library": "Lisää kirjastoon", + "already_in_library": "Jo kirjastossa", + "remove_from_library": "Poista kirjastosta", + "no_downloads": "Ei saatavilla olevia lähteitä", + "play_time": "Pelattu {{amount}}", + "last_time_played": "Viimeksi pelattu {{period}}", + "not_played_yet": "Et ole vielä pelannut {{title}}", + "next_suggestion": "Seuraava ehdotus", + "play": "Pelaa", + "deleting": "Poistetaan asennustiedostoa…", + "close": "Sulje", + "playing_now": "Käynnissä", + "change": "Vaihda", + "repacks_modal_description": "Valitse repack ladattavaksi", + "select_folder_hint": "Vaihtaaksesi oletuslatauskansiota, avaa <0>Asetukset", + "download_now": "Lataa nyt", + "no_shop_details": "Kuvausta ei saatu", + "download_options": "Lähteet", + "download_path": "Latauspolku", + "previous_screenshot": "Edellinen kuvakaappaus", + "next_screenshot": "Seuraava kuvakaappaus", + "screenshot": "Kuvakaappaus {{number}}", + "open_screenshot": "Avaa kuvakaappaus {{number}}", + "download_settings": "Latausasetukset", + "downloader": "Lataaja", + "select_executable": "Valitse", + "no_executable_selected": "Tiedostoa ei valittu", + "open_folder": "Avaa kansio", + "open_download_location": "Selaa latauskansio", + "create_shortcut": "Luo työpöydän pikakuvake", + "create_shortcut_simple": "Luo pikakuvake", + "clear": "Tyhjennä", + "remove_files": "Poista tiedostot", + "remove_from_library_title": "Oletko varma?", + "remove_from_library_description": "{{game}} poistetaan kirjastostasi.", + "options": "Asetukset", + "properties": "Ominaisuudet", + "executable_section_title": "Tiedosto", + "executable_section_description": "Polku tiedostoon, joka käynnistetään kun painat \"Pelaa\"", + "downloads_section_title": "Lataukset", + "downloads_section_description": "Tarkista päivitysten tai muiden peliversioiden saatavuus", + "danger_zone_section_title": "Vaaravyöhyke", + "danger_zone_section_description": "Voit poistaa tämän pelin kirjastostasi tai Hydrasta ladatut tiedostot", + "download_in_progress": "Lataus käynnissä", + "download_paused": "Lataus keskeytetty", + "last_downloaded_option": "Viimeisin latausvaihtoehto", + "create_steam_shortcut": "Luo Steam-pikakuvake", + "create_shortcut_success": "Pikakuvake luotu", + "you_might_need_to_restart_steam": "Saattaa olla, että sinun on käynnistettävä Steam uudelleen nähdäksesi muutokset", + "create_shortcut_error": "Pikakuvakkeen luonti epäonnistui", + "add_to_favorites": "Lisää suosikkeihin", + "remove_from_favorites": "Poista suosikeista", + "failed_update_favorites": "Suosikkien päivitys epäonnistui", + "game_removed_from_library": "Peli poistettu kirjastosta", + "failed_remove_from_library": "Poistaminen kirjastosta epäonnistui", + "files_removed_success": "Tiedostot poistettu onnistuneesti", + "failed_remove_files": "Tiedostojen poisto epäonnistui", + "nsfw_content_title": "Tämä peli sisältää sopimatonta sisältöä", + "nsfw_content_description": "{{title}} sisältää sisältöä, joka ei välttämättä sovellu kaikenikäisille. \nOletko varma, että haluat jatkaa?", + "allow_nsfw_content": "Jatka", + "refuse_nsfw_content": "Takaisin", + "stats": "Tilastot", + "download_count": "Lataukset", + "player_count": "Aktiiviset pelaajat", + "download_error": "Tämä latausvaihtoehto ei ole saatavilla", + "download": "Lataa", + "executable_path_in_use": "Suoritettavaa tiedostoa käyttää jo \"{{game}}\"", + "warning": "Varoitus:", + "hydra_needs_to_remain_open": "Tämän latauksen aikana Hydran on pysyttävä auki, kunnes se on valmis. Jos Hydra sulkeutuu ennen valmistumista, menetät edistymisen.", + "achievements": "Saavutukset", + "achievements_count": "Saavutukset {{unlockedCount}}/{{achievementsCount}}", + "show_more": "Näytä enemmän", + "show_less": "Näytä vähemmän", + "reviews": "Arvostelut", + "leave_a_review": "Jätä arvostelu", + "write_review_placeholder": "Jaa ajatuksesi tästä pelistä...", + "sort_newest": "Uusimmat ensin", + "no_reviews_yet": "Ei vielä arvosteluja", + "be_first_to_review": "Ole ensimmäinen, joka jakaa ajatuksensa tästä pelistä!", + "sort_oldest": "Vanhimmat ensin", + "sort_highest_score": "Korkein pistemäärä", + "sort_lowest_score": "Matalin pistemäärä", + "sort_most_voted": "Eniten äänestetyt", + "rating": "Arvio", + "rating_stats": "Arvio", + "rating_very_negative": "Erittäin negatiivinen", + "rating_negative": "Negatiivinen", + "rating_neutral": "Neutraali", + "rating_positive": "Positiivinen", + "rating_very_positive": "Erittäin positiivinen", + "submit_review": "Lähetä", + "submitting": "Lähetetään...", + "review_submitted_successfully": "Arvostelu lähetetty onnistuneesti!", + "review_submission_failed": "Arvostelun lähettäminen epäonnistui. Yritä uudelleen.", + "review_cannot_be_empty": "Arvostelun tekstikenttä ei voi olla tyhjä.", + "review_deleted_successfully": "Arvostelu poistettu onnistuneesti.", + "review_deletion_failed": "Arvostelun poisto epäonnistui. Yritä uudelleen.", + "loading_reviews": "Ladataan arvosteluja...", + "loading_more_reviews": "Ladataan lisää arvosteluja...", + "load_more_reviews": "Lataa lisää arvosteluja", + "you_seemed_to_enjoy_this_game": "Näyttää siltä, että nautit tästä pelistä", + "would_you_recommend_this_game": "Haluatko jättää arvion tästä pelistä?", + "yes": "Kyllä", + "maybe_later": "Ehkä myöhemmin", + "rating_count": "Arvio", + "delete_review": "Poista arvostelu", + "remove_review": "Poista arvostelu", + "delete_review_modal_title": "Haluatko varmasti poistaa arvostelusi?", + "delete_review_modal_description": "Tätä toimintoa ei voi peruuttaa.", + "delete_review_modal_delete_button": "Poista", + "delete_review_modal_cancel_button": "Peruuta", + "show_original": "Näytä alkuperäinen", + "show_translation": "Näytä käännös", + "show_original_translated_from": "Näytä alkuperäinen (käännös kielestä {{language}})", + "hide_original": "Piilota alkuperäinen", + "cloud_save": "Pilvitallennus", + "cloud_save_description": "Tallenna edistymisesi pilveen ja jatka pelaamista millä tahansa laitteella", + "backups": "Varmuuskopiot", + "install_backup": "Asenna", + "delete_backup": "Poista", + "create_backup": "Luo uusi varmuuskopio", + "last_backup_date": "Viimeisin varmuuskopio {{date}}", + "no_backup_preview": "Tallennuksia ei löytynyt tälle otsikolle", + "restoring_backup": "Palautetaan varmuuskopiota ({{progress}} valmis)…", + "uploading_backup": "Ladataan varmuuskopiota…", + "no_backups": "Et ole vielä luonut varmuuskopioita tästä pelistä", + "backup_uploaded": "Varmuuskopio ladattu", + "backup_failed": "Varmuuskopiointi epäonnistui", + "backup_deleted": "Varmuuskopio poistettu", + "backup_restored": "Varmuuskopio palautettu", + "see_all_achievements": "Näytä kaikki saavutukset", + "sign_in_to_see_achievements": "Kirjaudu sisään nähdäksesi saavutukset", + "mapping_method_automatic": "Automaattinen", + "mapping_method_manual": "Manuaalinen", + "mapping_method_label": "Kartoitusmenetelmä", + "files_automatically_mapped": "Tiedostot kartoitetu automaattisesti", + "no_backups_created": "Tälle pelille ei ole luotu varmuuskopioita", + "manage_files": "Hallitse tiedostoja", + "loading_save_preview": "Etsitään tallennuksia…", + "wine_prefix": "Wine-etuliite", + "wine_prefix_description": "Tässä pelissä käytettävä Wine-etuliite", + "launch_options": "Käynnistysvalinnat", + "launch_options_description": "Edistyneet käyttäjät voivat tehdä muutoksia käynnistysvalintoihin", + "launch_options_placeholder": "Valintaa ei määritetty", + "no_download_option_info": "Tietoja ei saatavilla", + "backup_deletion_failed": "Varmuuskopion poisto epäonnistui", + "max_number_of_artifacts_reached": "Tämän pelin enimmäismäärä varmuuskopioita saavutettu", + "achievements_not_sync": "Saavutuksesi eivät ole synkronoidut", + "manage_files_description": "Hallitse tallennettavia ja palautettavia tiedostoja", + "select_folder": "Valitse kansio", + "backup_from": "Varmuuskopio {{date}}", + "automatic_backup_from": "Automaattinen varmuuskopio {{date}}", + "enable_automatic_cloud_sync": "Ota automaattinen pilvisynkronointi käyttöön", + "custom_backup_location_set": "Mukautettu varmuuskopiosijainti asetettu", + "no_directory_selected": "Hakemistoa ei valittu", + "no_write_permission": "Ei voi ladata tähän hakemistoon. Napsauta tästä saadaksesi lisätietoja.", + "reset_achievements": "Nollaa saavutukset", + "reset_achievements_description": "Tämä nollaa kaikki saavutukset pelille {{game}}", + "reset_achievements_title": "Oletko varma?", + "reset_achievements_success": "Saavutukset nollattu onnistuneesti", + "reset_achievements_error": "Saavutusten nollaus epäonnistui", + "download_error_gofile_quota_exceeded": "Olet ylittänyt Gofilen kuukausikiintiön. Odota, kunnes kiintiö palautuu.", + "download_error_real_debrid_account_not_authorized": "Real-Debrid -tilisi ei ole valtuutettu suorittamaan uusia latauksia. Tarkista tilin asetukset ja yritä uudelleen.", + "download_error_not_cached_on_real_debrid": "Tämä lataus ei ole saatavilla Real-Debridissä, eikä lataustilan hakeminen Real-Debridistä ole toistaiseksi mahdollista.", + "update_playtime_title": "Päivitä peliaika", + "update_playtime_description": "Päivitä pelin {{game}} peliaika manuaalisesti", + "update_playtime": "Päivitä peliaika", + "update_playtime_success": "Peliaika päivitetty onnistuneesti", + "update_playtime_error": "Peliajan päivitys epäonnistui", + "update_game_playtime": "Päivitä peliaika", + "manual_playtime_warning": "Pelituntisi merkitään manuaalisesti päivitetyiksi. Tätä toimintoa ei voi peruuttaa.", + "manual_playtime_tooltip": "Tämä peliaika on päivitetty manuaalisesti", + "download_error_not_cached_on_torbox": "Tämä lataus ei ole saatavilla TorBoxissa, eikä lataustilan hakeminen TorBoxista ole toistaiseksi mahdollista.", + "download_error_not_cached_on_hydra": "Tämä lataus ei ole saatavilla Nimbuksessa.", + "game_removed_from_favorites": "Peli poistettu suosikeista", + "game_added_to_favorites": "Peli lisätty suosikkeihin", + "game_removed_from_pinned": "Peli poistettu kiinnitetyistä", + "game_added_to_pinned": "Peli lisätty kiinnitettyihin", + "automatically_extract_downloaded_files": "Pura ladatut tiedostot automaattisesti", + "create_start_menu_shortcut": "Luo Käynnistä-valikon pikakuvake", + "invalid_wine_prefix_path": "Virheellinen Wine-etuliitteen polku", + "invalid_wine_prefix_path_description": "Wine-etuliitteen polku on virheellinen. Tarkista polku ja yritä uudelleen.", + "missing_wine_prefix": "Wine-etuliite vaaditaan varmuuskopiointiin Linuxissa", + "artifact_renamed": "Varmuuskopio nimettiin uudelleen onnistuneesti", + "rename_artifact": "Nimeä varmuuskopio uudelleen", + "rename_artifact_description": "Anna varmuuskopiolle kuvaavampi nimi.", + "artifact_name_label": "Varmuuskopion nimi", + "artifact_name_placeholder": "Syötä nimi varmuuskopiolle", + "save_changes": "Tallenna muutokset", + "required_field": "Tämä kenttä on pakollinen", + "max_length_field": "Tämän kentän on oltava alle {{length}} merkkiä", + "freeze_backup": "Kiinnitä, jotta sitä ei ylikirjoiteta automaattisilla varmuuskopioilla", + "unfreeze_backup": "Poista kiinnitys", + "backup_frozen": "Varmuuskopio kiinnitetty", + "backup_unfrozen": "Varmuuskopion kiinnitys poistettu", + "backup_freeze_failed": "Varmuuskopion kiinnitys epäonnistui", + "backup_freeze_failed_description": "Sinun on jätettävä vähintään yksi paikka vapaaksi automaattisille varmuuskopioille", + "edit_game_modal_button": "Muokkaa pelin tietoja", + "game_details": "Pelin tiedot", + "currency_symbol": "€", + "currency_country": "fi", + "prices": "Hinnat", + "no_prices_found": "Hintoja ei löytynyt", + "view_all_prices": "Napsauta nähdäksesi kaikki hinnat", + "retail_price": "Vähittäishinta", + "keyshop_price": "Keyshop-hinta", + "historical_retail": "Historialliset vähittäishinnat", + "historical_keyshop": "Historialliset keyshop-hinnat", + "language": "Kieli", + "caption": "Tekstitys", + "audio": "Ääni", + "filter_by_source": "Suodata lähteen mukaan", + "no_repacks_found": "Tämän pelin lähteitä ei löytynyt" + }, + "activation": { + "title": "Aktivoi Hydra", + "installation_id": "Asennustunnus:", + "enter_activation_code": "Syötä aktivointikoodisi", + "message": "Jos et tiedä mistä sitä pyytää, sinun ei pitäisi sitä olla.", + "activate": "Aktivoi", + "loading": "Ladataan…" + }, + "downloads": { + "resume": "Jatka", + "pause": "Keskeytä", + "eta": "Lopetus {{eta}}", + "paused": "Keskeytetty", + "verifying": "Tarkistetaan…", + "completed": "Valmis", + "removed": "Ei ladattu", + "cancel": "Peruuta", + "filter": "Hae ladattuja pelejä", + "remove": "Poista", + "downloading_metadata": "Ladataan metatietoja…", + "deleting": "Poistetaan asennustiedostoa…", + "delete": "Poista asennustiedosto", + "delete_modal_title": "Oletko varma?", + "delete_modal_description": "Tämä poistaa kaikki asennustiedostot tietokoneeltasi", + "install": "Asenna", + "download_in_progress": "Käynnissä", + "queued_downloads": "Jonossa olevat lataukset", + "downloads_completed": "Valmiit", + "queued": "Jonossa", + "no_downloads_title": "Täällä on niin tyhjää...", + "no_downloads_description": "Et ole vielä ladannut mitään Hydran kautta, mutta ei ole koskaan liian myöhäistä aloittaa.", + "checking_files": "Tarkistetaan tiedostoja…", + "seeding": "Jakaminen", + "stop_seeding": "Lopeta jakaminen", + "resume_seeding": "Jatka jakamista", + "options": "Hallinnoi", + "extract": "Pura tiedostot", + "extracting": "Puretaan tiedostoja…" + }, + "settings": { + "downloads_path": "Latausten polku", + "change": "Vaihda", + "notifications": "Ilmoitukset", + "enable_download_notifications": "Latauksen valmistuessa", + "enable_repack_list_notifications": "Kun uusi repack lisätään", + "real_debrid_api_token_label": "Real-Debrid API-tunnus", + "quit_app_instead_hiding": "Sovellus sulkeutuu system tray -alueelle sijasta", + "launch_with_system": "Käynnistä Hydra järjestelmän mukana", + "general": "Yleiset", + "behavior": "Käyttäytyminen", + "download_sources": "Latauslähteet", + "language": "Kieli", + "api_token": "API-avain", + "enable_real_debrid": "Ota Real-Debrid käyttöön", + "real_debrid_description": "Real-Debrid on rajoittamaton lataaja, jonka avulla voit ladata nopeasti verkossa olevia tiedostoja tai striimata ne välittömästi soittimeen yksityisen verkon kautta, joka kiertää kaikki estot.", + "debrid_invalid_token": "Virheellinen API-avain", + "debrid_api_token_hint": "API-avain voidaan hankkia <0>täältä", + "real_debrid_free_account_error": "Tili \"{{username}}\" - ei ole tilaus. Ota Real-Debrid-tilaus", + "debrid_linked_message": "Tili \"{{username}}\" linkitetty", + "save_changes": "Tallenna muutokset", + "changes_saved": "Muutokset tallennettu onnistuneesti", + "download_sources_description": "Hydra hakee latauslinkit näistä lähteistä. URL-osoitteen on sisällettävä suora linkki .json-tiedostoon, joka sisältää latauslinkit.", + "validate_download_source": "Vahvista", + "remove_download_source": "Poista", + "add_download_source": "Lisää lähde", + "download_count_zero": "Ei latauksia listassa", + "download_count_one": "{{countFormatted}} lataus listassa", + "download_count_other": "{{countFormatted}} latausta listassa", + "download_source_url": "Lähteen URL-osoite", + "add_download_source_description": "Liitä linkki .json-tiedostoon", + "download_source_up_to_date": "Ajan tasalla", + "download_source_errored": "Virhe", + "sync_download_sources": "Päivitä lähteet", + "removed_download_source": "Lähde poistettu", + "removed_download_sources": "Lähteet poistettu", + "cancel_button_confirmation_delete_all_sources": "Ei", + "confirm_button_confirmation_delete_all_sources": "Kyllä, poista kaikki", + "title_confirmation_delete_all_sources": "Poista kaikki lähteet", + "description_confirmation_delete_all_sources": "Poistat kaikki lähteet", + "button_delete_all_sources": "Poista kaikki lähteet", + "added_download_source": "Lähde lisätty", + "download_sources_synced": "Kaikki lähteet päivitetty", + "insert_valid_json_url": "Liitä kelvollinen JSON-tiedoston URL-osoite", + "found_download_option_zero": "Ei latausvaihtoehtoja löytynyt", + "found_download_option_one": "Löytyi {{countFormatted}} latausvaihtoehto", + "found_download_option_other": "Löytyi {{countFormatted}} latausvaihtoehtoa", + "import": "Tuo", + "importing": "Tuodaan...", + "public": "Julkinen", + "private": "Yksityinen", + "friends_only": "Vain kavereille", + "privacy": "Yksityisyys", + "profile_visibility": "Profiilin näkyvyys", + "profile_visibility_description": "Valitse, kuka voi nähdä profiilisi ja kirjastosi", + "required_field": "Tämä kenttä on pakollinen", + "source_already_exists": "Tämä lähde on jo lisätty", + "must_be_valid_url": "Lähteen on oltava kelvollinen URL-osoite", + "blocked_users": "Estetyt käyttäjät", + "user_unblocked": "Käyttäjä estäminen poistettu", + "enable_achievement_notifications": "Kun saavutus avataan", + "launch_minimized": "Käynnistä Hydra pienennettynä", + "disable_nsfw_alert": "Poista sopimattoman sisällön varoitus käytöstä", + "seed_after_download_complete": "Jaa latauksen valmistumisen jälkeen", + "show_hidden_achievement_description": "Näytä piilotettujen saavutusten kuvaukset ennen niiden ansaitsemista", + "account": "Tili", + "no_users_blocked": "Sinulla ei ole estettyjä käyttäjiä", + "subscription_active_until": "Hydra Cloud -tilisi on voimassa {{date}} asti", + "manage_subscription": "Hallinnoi tilausta", + "update_email": "Päivitä sähköposti", + "update_password": "Päivitä salasana", + "current_email": "Nykyinen sähköposti:", + "no_email_account": "Et ole vielä asettanut sähköpostiosoitetta", + "account_data_updated_successfully": "Tilitiedot päivitetty onnistuneesti", + "renew_subscription": "Uusi Hydra Cloud -tilaus", + "subscription_expired_at": "Tilauksesi vanheni {{date}}", + "no_subscription": "Nauti Hydrasta täysin rinnoin", + "become_subscriber": "Tule Hydra Cloud -tilaajaksi", + "subscription_renew_cancelled": "Automaattinen uusinta peruutettu", + "subscription_renews_on": "Tilauksesi uusiutuu {{date}}", + "bill_sent_until": "Seuraava laskusi lähetetään ennen tätä päivää", + "no_themes": "Näyttää siltä, että sinulla ei vielä ole teemoja, mutta älä huoli, napsauta tästä luodaksesi ensimmäisen mestariteoksesi", + "editor_tab_code": "Koodi", + "editor_tab_info": "Tiedot", + "editor_tab_save": "Tallenna", + "web_store": "Verkkokauppa", + "clear_themes": "Tyhjennä", + "create_theme": "Luo", + "create_theme_modal_title": "Luo mukautettu teema", + "create_theme_modal_description": "Luo uusi teema Hydran ulkoasun mukauttamiseksi", + "theme_name": "Nimi", + "insert_theme_name": "Syötä teeman nimi", + "set_theme": "Aseta teema", + "unset_theme": "Poista teema", + "delete_theme": "Poista teema", + "edit_theme": "Muokkaa teemaa", + "delete_all_themes": "Poista kaikki teemat", + "delete_all_themes_description": "Tämä poistaa kaikki mukautetut teemasi", + "delete_theme_description": "Tämä poistaa teeman {{theme}}", + "cancel": "Peruuta", + "appearance": "Ulkoasu", + "debrid": "Debrid", + "debrid_description": "Debrid-palvelut ovat premium-lataajia ilman rajoituksia, joiden avulla voit ladata tiedostoja nopeasti useista tiedostonjakopalveluista, vain internet-yhteytesi nopeuden rajoittamina.", + "enable_torbox": "Ota TorBox käyttöön", + "torbox_description": "TorBox on premium-palvelusi, joka kilpailee jopa parhaimpien markkinoiden palvelimien kanssa.", + "torbox_account_linked": "TorBox-tili linkitetty", + "create_real_debrid_account": "Napsauta tästä, jos sinulla ei vielä ole Real-Debrid-tiliä", + "create_torbox_account": "Napsauta tästä, jos sinulla ei vielä ole TorBox-tiliä", + "real_debrid_account_linked": "Real-Debrid-tili linkitetty", + "name_min_length": "Teeman nimen on oltava vähintään 3 merkkiä", + "import_theme": "Tuo teema", + "import_theme_description": "Tuot teeman {{theme}} teemakaupasta", + "error_importing_theme": "Virhe teemaa tuotaessa", + "theme_imported": "Teema tuotu onnistuneesti", + "enable_friend_request_notifications": "Kun kaveripyyntö vastaanotetaan", + "enable_auto_install": "Lataa päivitykset automaattisesti", + "common_redist": "Kirjastot", + "common_redist_description": "Joidenkin pelien käyttö vaatii kirjastoja. Ongelmien välttämiseksi on suositeltavaa asentaa ne.", + "install_common_redist": "Asenna", + "installing_common_redist": "Asennetaan…", + "show_download_speed_in_megabytes": "Näytä latausnopeus megatavuina sekunnissa", + "extract_files_by_default": "Pura tiedostot oletusarvoisesti latauksen jälkeen", + "enable_steam_achievements": "Ota Steam-saavutusten haku käyttöön", + "achievement_custom_notification_position": "Saavutusilmoitusten sijainti", + "top-left": "Vasemmalla ylhäällä", + "top-center": "Yläkeskellä", + "top-right": "Oikealla ylhäällä", + "bottom-left": "Vasemmalla alhaalla", + "bottom-center": "Alakeskellä", + "bottom-right": "Oikealla alhaalla", + "enable_achievement_custom_notifications": "Ota saavutusilmoitukset käyttöön", + "alignment": "Tasaus", + "variation": "Muunnelma", + "default": "Oletus", + "rare": "Harvinainen", + "platinum": "Platina", + "hidden": "Piilotettu", + "test_notification": "Testi-ilmoitus", + "notification_preview": "Saavutusilmoituksen esikatselu", + "enable_friend_start_game_notifications": "Kun kaveri aloittaa pelin pelaamisen" + }, + "notifications": { + "download_complete": "Lataus valmis", + "game_ready_to_install": "{{title}} valmis asennettavaksi", + "repack_list_updated": "Repack-lista päivitetty", + "repack_count_one": "{{count}} repack lisätty", + "repack_count_other": "{{count}} repackia lisätty", + "new_update_available": "Uusi versio {{version}} saatavilla", + "restart_to_install_update": "Käynnistä Hydra uudelleen asentaaksesi päivityksen", + "notification_achievement_unlocked_title": "Saavutus avattu pelille {{game}}", + "notification_achievement_unlocked_body": "{{achievement}} ja muut {{count}} avattiin", + "new_friend_request_description": "{{displayName}} lähetti sinulle kaveripyynnön", + "new_friend_request_title": "Uusi kaveripyyntö", + "extraction_complete": "Purkaminen valmis", + "game_extracted": "{{title}} purettu onnistuneesti", + "friend_started_playing_game": "{{displayName}} aloitti pelin pelaamisen", + "test_achievement_notification_title": "Tämä on testi-ilmoitus", + "test_achievement_notification_description": "Aika siistiä, eikö?" + }, + "system_tray": { + "open": "Avaa Hydra", + "quit": "Lopeta" + }, + "game_card": { + "available_one": "Saatavilla", + "available_other": "Saatavilla", + "no_downloads": "Ei saatavilla olevia lähteitä", + "calculating": "Lasketaan" + }, + "binary_not_found_modal": { + "title": "Ohjelmia ei asennettu", + "description": "Wine tai Lutris ei löytynyt", + "instructions": "Opi oikea tapa asentaa kumpi tahansa Linux-jakelullesi, jotta peli toimii kunnolla" + }, + "modal": { + "close": "Sulje" + }, + "forms": { + "toggle_password_visibility": "Näytä salasana" + }, + "user_profile": { + "amount_hours": "{{amount}} tuntia", + "amount_minutes": "{{amount}} minuuttia", + "amount_hours_short": "{{amount}}t", + "amount_minutes_short": "{{amount}}min", + "last_time_played": "Viimeisin peli {{period}}", + "activity": "Viimeisin toiminta", + "library": "Kirjasto", + "pinned": "Kiinnitetyt", + "achievements_earned": "Ansaittu saavutukset", + "played_recently": "Äskettäin pelatut", + "playtime": "Peliaika", + "total_play_time": "Yhteensä pelattu", + "manual_playtime_tooltip": "Peliaika on päivitetty manuaalisesti", + "no_recent_activity_title": "Hmm... Täällä ei ole mitään", + "no_recent_activity_description": "Et ole pelannut mitään vähään aikaan. On aika muuttaa se!", + "display_name": "Näyttönimi", + "saving": "Tallennetaan", + "save": "Tallenna", + "edit_profile": "Muokkaa profiilia", + "saved_successfully": "Tallennettu onnistuneesti", + "try_again": "Yritä uudelleen", + "sign_out_modal_title": "Oletko varma?", + "cancel": "Peruuta", + "successfully_signed_out": "Kirjauduttu ulos onnistuneesti", + "sign_out": "Kirjaudu ulos", + "playing_for": "Pelattu {{amount}}", + "sign_out_modal_text": "Kirjastosi on linkitetty nykyiseen tiliisi. Kirjautumalla ulos kirjastosi ei ole käytettävissä, eikä edistymistä tallenneta. Kirjaudu ulos?", + "add_friends": "Lisää kavereita", + "add": "Lisää", + "friend_code": "Kaverikoodi", + "see_profile": "Näytä profiili", + "sending": "Lähetetään", + "friend_request_sent": "Kaveripyyntö lähetetty", + "friends": "Kaverit", + "friends_list": "Kaverilista", + "user_not_found": "Käyttäjää ei löytynyt", + "block_user": "Estä käyttäjä", + "add_friend": "Lisää kaveriksi", + "request_sent": "Pyyntö lähetetty", + "request_received": "Pyyntö vastaanotettu", + "accept_request": "Hyväksy pyyntö", + "ignore_request": "Ohita pyyntö", + "cancel_request": "Peruuta pyyntö", + "undo_friendship": "Poista kaveri", + "request_accepted": "Pyyntö hyväksytty", + "user_blocked_successfully": "Käyttäjä estetty onnistuneesti", + "user_block_modal_text": "{{displayName}} estetään", + "blocked_users": "Estetyt käyttäjät", + "unblock": "Poista esto", + "no_friends_added": "Et ole vielä lisännyt yhtään kaveria", + "pending": "Odottaa", + "no_pending_invites": "Sinulla ei ole vasteita odottavia pyyntöjä", + "no_blocked_users": "Et ole estänyt yhtään käyttäjää", + "friend_code_copied": "Kaverikoodi kopioitu", + "undo_friendship_modal_text": "Tämä purkaa kaverisuhteen käyttäjän {{displayName}} kanssa.", + "privacy_hint": "Määrittääksesi kuka voi nähdä tämän, siirry <0>Asetuksiin.", + "locked_profile": "Tämä profiili on yksityinen", + "image_process_failure": "Kuvan käsittely epäonnistui", + "required_field": "Tämä kenttä on pakollinen", + "displayname_min_length": "Näyttönimen on oltava vähintään 3 merkkiä.", + "displayname_max_length": "Näyttönimen on oltava enintään 50 merkkiä.", + "report_profile": "Ilmianna tämä profiili", + "report_reason": "Miksi ilmiannat tämän profiilin?", + "report_description": "Lisätietoja", + "report_description_placeholder": "Lisätietoja", + "report": "Ilmianna", + "report_reason_hate": "Vihapuhe", + "report_reason_sexual_content": "Seksuaalinen sisältö", + "report_reason_violence": "Väkivalta", + "report_reason_spam": "Roskaposti", + "report_reason_other": "Muu", + "profile_reported": "Profiili-ilmoitus lähetetty", + "your_friend_code": "Kaverikoodisi:", + "upload_banner": "Lataa banneri", + "uploading_banner": "Ladataan banneria...", + "background_image_updated": "Taustakuva päivitetty", + "stats": "Tilastot", + "achievements": "Saavutukset", + "games": "Pelit", + "top_percentile": "Top {{percentile}}%", + "ranking_updated_weekly": "Sijoitus päivitetään viikoittain", + "playing": "Pelaamassa {{game}}", + "achievements_unlocked": "Saavutukset avattu", + "earned_points": "Ansaitut pisteet:", + "show_achievements_on_profile": "Näytä saavutuksesi profiilissasi", + "show_points_on_profile": "Näytä ansaitut pisteet profiilissasi", + "error_adding_friend": "Kaveripyynnön lähettäminen epäonnistui. Tarkista kaverikoodi", + "friend_code_length_error": "Kaverikoodin on oltava 8 merkkiä", + "game_removed_from_pinned": "Peli poistettu kiinnitetyistä", + "game_added_to_pinned": "Peli lisätty kiinnitettyihin", + "karma": "Karma", + "karma_count": "karmaa", + "karma_description": "Ansittu positiivisilla arvosteluäänillä" + }, + "achievement": { + "achievement_unlocked": "Saavutus avattu", + "user_achievements": "Käyttäjän {{displayName}} saavutukset", + "your_achievements": "Sinun saavutuksesi", + "unlocked_at": "Avattu: {{date}}", + "subscription_needed": "Hydra Cloud -tilaus tarvitaan tämän sisällön katsomiseen", + "new_achievements_unlocked": "{{achievementCount}} uutta saavutusta avattu {{gameCount}} pelistä", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} saavutusta", + "achievements_unlocked_for_game": "{{achievementCount}} uutta saavutusta avattu pelille {{gameTitle}}", + "hidden_achievement_tooltip": "Tämä on piilotettu saavutus", + "achievement_earn_points": "Ansaitse {{points}} pistettä tällä saavutuksella", + "earned_points": "Ansaitut pisteet:", + "available_points": "Saatavilla olevat pisteet:", + "how_to_earn_achievements_points": "Kuinka ansaita saavutuspisteitä?" + }, + "hydra_cloud": { + "subscription_tour_title": "Hydra Cloud -tilaus", + "subscribe_now": "Tilaa nyt", + "cloud_saving": "Pilvitallennus", + "cloud_achievements": "Tallenna saavutuksesi pilveen", + "animated_profile_picture": "Animaoidut profiilikuvat", + "premium_support": "Premium-tuki", + "show_and_compare_achievements": "Näytä ja vertaile saavutuksiasi muiden käyttäjien saavutuksiin", + "animated_profile_banner": "Animoitu profiilin banneri", + "hydra_cloud": "Hydra Cloud", + "hydra_cloud_feature_found": "Olet juuri löytänyt Hydra Cloud -toiminnon!", + "learn_more": "Lue lisää", + "debrid_description": "Lataa 4 kertaa nopeammin Nimbuksella" + } +} diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index a91902dc..88039aee 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -183,7 +183,11 @@ "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", +<<<<<<< HEAD "options": "Beállítások",ä +======= + "options": "Beállítások", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", @@ -224,7 +228,11 @@ "show_less": "Mutass kevesebbet", "reviews": "Vélemények", "leave_a_review": "Hagyd itt a véleményed", +<<<<<<< HEAD "write_review_placeholder": "Oszd meg gondolatod a játékról...", +======= + "write_review_placeholder": "Oszd meg a gondolataid a játékról...", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "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 +260,11 @@ "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", +<<<<<<< HEAD "maybe_later": "Talán később", +======= + "maybe_later": "Talán Később", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "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,12 +368,16 @@ "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", +<<<<<<< HEAD "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" +======= + "delete_review_modal_cancel_button": "Mégse" +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "activation": { "title": "Hydra Aktiválása", @@ -515,14 +531,22 @@ "cancel": "Mégsem", "appearance": "Megjelenés", "debrid": "Debrid", +<<<<<<< HEAD "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.", +======= + "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.", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "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", +<<<<<<< HEAD "name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen", +======= + "name_min_length": "A téma neve legalább 3 karakter hosszú legyen", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "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", @@ -582,7 +606,11 @@ "available_one": "Elérhető", "available_other": "Elérhető", "no_downloads": "Nincs elérhető letöltés", +<<<<<<< HEAD "calculating": "Számítás alatt.." +======= + "calculating": "Feldolgozás" +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "binary_not_found_modal": { "title": "Hiányzó programok", @@ -689,7 +717,11 @@ "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", +<<<<<<< HEAD "karma_description": "Pozitív értékelésekkel szerzett pontok" +======= + "karma_description": "Pozitív értékelésekre kapott pontok alapján" +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "achievement": { "achievement_unlocked": "Achievement feloldva", @@ -698,7 +730,11 @@ "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", +<<<<<<< HEAD "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement", +======= + "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek", +>>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "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 f71e8f0e..ca9ec757 100644 --- a/src/locales/index.ts +++ b/src/locales/index.ts @@ -26,7 +26,9 @@ import nb from "./nb/translation.json"; import et from "./et/translation.json"; 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, @@ -49,6 +51,7 @@ export default { da, ar, fa, + fi, ro, ca, bg, @@ -58,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 37569701..5bfc2af3 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -27,21 +27,68 @@ "friends": "Amigos", "need_help": "Precisa de ajuda?", "favorites": "Favoritos", + "playable_button_title": "Mostrar apenas jogos que você pode jogar agora", "add_custom_game_tooltip": "Adicionar jogo personalizado", + "show_playable_only_tooltip": "Mostrar Apenas Jogáveis", "custom_game_modal": "Adicionar jogo personalizado", + "custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável", + "custom_game_modal_executable_path": "Caminho do Executável", + "custom_game_modal_select_executable": "Selecionar arquivo executável", + "custom_game_modal_title": "Título", + "custom_game_modal_enter_title": "Insira o título", "edit_game_modal_title": "Título", - "playable_button_title": "", - "custom_game_modal_add": "Adicionar Jogo", - "custom_game_modal_adding": "Adicionando...", "custom_game_modal_browse": "Buscar", "custom_game_modal_cancel": "Cancelar", - "edit_game_modal_assets": "Imagens", - "edit_game_modal_icon": "Ícone", - "edit_game_modal_browse": "Buscar", - "edit_game_modal_cancel": "Cancelar", + "custom_game_modal_add": "Adicionar Jogo", + "custom_game_modal_adding": "Adicionando...", + "custom_game_modal_success": "Jogo personalizado adicionado com sucesso", + "custom_game_modal_failed": "Falha ao adicionar jogo personalizado", + "custom_game_modal_executable": "Executável", + "edit_game_modal": "Personalizar detalhes", + "edit_game_modal_description": "Personalize os recursos e detalhes do jogo", "edit_game_modal_enter_title": "Insira o título", + "edit_game_modal_image": "Imagem", + "edit_game_modal_select_image": "Selecionar imagem", + "edit_game_modal_browse": "Buscar", + "edit_game_modal_image_preview": "Visualização da imagem", + "edit_game_modal_icon": "Ícone", + "edit_game_modal_select_icon": "Selecionar ícone", + "edit_game_modal_icon_preview": "Visualização do ícone", "edit_game_modal_logo": "Logo", - "edit_game_modal": "Personalizar detalhes" + "edit_game_modal_select_logo": "Selecionar logo", + "edit_game_modal_logo_preview": "Visualização do logo", + "edit_game_modal_hero": "Hero da Biblioteca", + "edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca", + "edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca", + "edit_game_modal_cancel": "Cancelar", + "edit_game_modal_update": "Atualizar", + "edit_game_modal_updating": "Atualizando...", + "edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios", + "edit_game_modal_success": "Recursos atualizados com sucesso", + "edit_game_modal_failed": "Falha ao atualizar recursos", + "edit_game_modal_image_filter": "Imagem", + "edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px", + "edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px", + "edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px", + "edit_game_modal_assets": "Imagens", + "edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui", + "edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui", + "edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui", + "edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone", + "edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo", + "edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero", + "install_decky_plugin": "Instalar Plugin Decky", + "update_decky_plugin": "Atualizar Plugin Decky", + "decky_plugin_installed_version": "Plugin Decky (v{{version}})", + "install_decky_plugin_title": "Instalar Plugin Hydra Decky", + "install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?", + "update_decky_plugin_title": "Atualizar Plugin Hydra Decky", + "update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?", + "decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso", + "decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}", + "decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}", + "confirm": "Confirmar", + "cancel": "Cancelar" }, "header": { "search": "Buscar jogos", @@ -165,6 +212,7 @@ "uploading_backup": "Criando backup…", "no_backups": "Você ainda não fez nenhum backup deste jogo", "backup_uploaded": "Backup criado", + "backup_failed": "Falha no backup", "backup_deleted": "Backup apagado", "backup_restored": "Backup restaurado", "see_all_achievements": "Ver todas as conquistas", @@ -256,7 +304,52 @@ "update_playtime": "Modificar tempo de jogo", "update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}", "update_playtime_error": "Falha ao atualizar tempo de jogo", - "update_playtime_title": "Atualizar tempo de jogo" + "update_playtime_title": "Atualizar tempo de jogo", + "update_playtime_success": "Tempo de jogo atualizado com sucesso", + "show_more": "Mostrar mais", + "show_less": "Mostrar menos", + "reviews": "Avaliações", + "leave_a_review": "Deixar uma Avaliação", + "write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...", + "sort_newest": "Mais Recentes", + "sort_oldest": "Mais Antigas", + "sort_highest_score": "Maior Nota", + "sort_lowest_score": "Menor Nota", + "sort_most_voted": "Mais Votadas", + "no_reviews_yet": "Ainda não há avaliações", + "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", + "rating": "Avaliação", + "rating_stats": "Avaliação", + "rating_very_negative": "Muito Negativo", + "rating_negative": "Negativo", + "rating_neutral": "Neutro", + "rating_positive": "Positivo", + "rating_very_positive": "Muito Positivo", + "submit_review": "Enviar", + "submitting": "Enviando...", + "review_submitted_successfully": "Avaliação enviada com sucesso!", + "review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.", + "review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.", + "review_deleted_successfully": "Avaliação excluída com sucesso.", + "review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.", + "loading_reviews": "Carregando avaliações...", + "loading_more_reviews": "Carregando mais avaliações...", + "load_more_reviews": "Carregar mais avaliações", + "you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo", + "would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?", + "yes": "Sim", + "maybe_later": "Talvez mais tarde", + "delete_review": "Excluir avaliação", + "remove_review": "Remover Avaliação", + "delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?", + "delete_review_modal_description": "Esta ação não pode ser desfeita.", + "delete_review_modal_delete_button": "Excluir", + "delete_review_modal_cancel_button": "Cancelar", + "show_original": "Mostrar original", + "show_translation": "Mostrar tradução", + "show_original_translated_from": "Mostrar original (traduzido do {{language}})", + "hide_original": "Ocultar original", + "rating_count": "Avaliação" }, "activation": { "title": "Ativação", @@ -323,6 +416,9 @@ "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", + "adding": "Adicionando…", + "failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.", + "download_source_already_exists": "Esta URL de fonte de download já existe.", "download_count_zero": "Sem downloads na lista", "download_count_one": "{{countFormatted}} download na lista", "download_count_other": "{{countFormatted}} downloads na lista", @@ -330,7 +426,13 @@ "add_download_source_description": "Insira a URL contendo o arquivo .json", "download_source_up_to_date": "Sincronizada", "download_source_errored": "Falhou", + "download_source_pending_matching": "Importando em breve", + "download_source_matched": "Sincronizada", + "download_source_matching": "Sincronizando", + "download_source_failed": "Erro", + "download_source_no_information": "Sem informações", "sync_download_sources": "Sincronizar", + "download_sources_synced_successfully": "Fontes de download sincronizadas", "removed_download_source": "Fonte removida", "removed_download_sources": "Fontes removidas", "cancel_button_confirmation_delete_all_sources": "Não", @@ -345,6 +447,7 @@ "found_download_option_one": "{{countFormatted}} opção de download encontrada", "found_download_option_other": "{{countFormatted}} opções de download encontradas", "import": "Importar", + "importing": "Importando...", "privacy": "Privacidade", "private": "Privado", "friends_only": "Apenas amigos", @@ -378,6 +481,8 @@ "subscription_renews_on": "Sua assinatura renova dia {{date}}", "bill_sent_until": "Sua próxima cobrança será enviada até esse dia", "no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.", + "editor_tab_code": "Código", + "editor_tab_info": "Info", "editor_tab_save": "Salvar", "web_store": "Loja de temas", "clear_themes": "Limpar", @@ -395,6 +500,8 @@ "delete_theme_description": "Isso irá deletar o tema {{theme}}", "cancel": "Cancelar", "appearance": "Aparência", + "debrid": "Debrid", + "debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.", "enable_torbox": "Habilitar TorBox", "torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.", "torbox_account_linked": "Conta do TorBox vinculada", @@ -432,7 +539,8 @@ "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", - "editor_tab_code": "Código" + "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", @@ -448,7 +556,9 @@ "game_extracted": "{{title}} extraído com sucesso", "friend_started_playing_game": "{{displayName}} começou a jogar", "test_achievement_notification_title": "Esta é uma notificação de teste", - "test_achievement_notification_description": "Bem legal, né?" + "test_achievement_notification_description": "Bem legal, né?", + "notification_achievement_unlocked_title": "Conquista desbloqueada para {{game}}", + "notification_achievement_unlocked_body": "{{achievement}} e outras {{count}} foram desbloqueadas" }, "system_tray": { "open": "Abrir Hydra", @@ -457,7 +567,8 @@ "game_card": { "available_one": "Disponível", "available_other": "Disponíveis", - "no_downloads": "Sem downloads disponíveis" + "no_downloads": "Sem downloads disponíveis", + "calculating": "Calculando" }, "binary_not_found_modal": { "title": "Programas não instalados", @@ -484,10 +595,18 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", "last_time_played": "Última sessão {{period}}", "activity": "Atividades recentes", "library": "Biblioteca", + "pinned": "Fixados", + "sort_by": "Ordenar por:", + "achievements_earned": "Conquistas obtidas", + "played_recently": "Jogados recentemente", + "playtime": "Tempo de jogo", "total_play_time": "Tempo total de jogo", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "no_recent_activity_title": "Hmmm… nada por aqui", "no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?", "display_name": "Nome de exibição", @@ -569,7 +688,12 @@ "amount_minutes_short": "{{amount}}m", "amount_hours_short": "{{amount}}h", "game_added_to_pinned": "Jogo adicionado aos fixados", - "achievements_earned": "Conquistas recebidas" + "game_removed_from_pinned": "Jogo removido dos fixados", + "achievements_earned": "Conquistas recebidas", + "karma": "Karma", + "karma_count": "karma", + "karma_description": "Ganho a partir de curtidas positivas em avaliações", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 654e94ec..2894cf65 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -142,6 +142,7 @@ "uploading_backup": "A criar backup…", "no_backups": "Ainda não fizeste nenhum backup deste jogo", "backup_uploaded": "Backup criado", + "backup_failed": "Falha ao criar backup", "backup_deleted": "Backup apagado", "backup_restored": "Backup restaurado", "see_all_achievements": "Ver todas as conquistas", @@ -251,7 +252,13 @@ "add_download_source_description": "Insere o URL que contém o ficheiro .json", "download_source_up_to_date": "Sincronizada", "download_source_errored": "Falhou", + "download_source_pending_matching": "A atualizar em breve", + "download_source_matched": "Atualizado", + "download_source_matching": "A atualizar", + "download_source_failed": "Erro", + "download_source_no_information": "Sem informações", "sync_download_sources": "Sincronizar", + "download_sources_synced_successfully": "Fontes de download sincronizadas", "removed_download_source": "Fonte removida", "cancel_button_confirmation_delete_all_sources": "Não", "confirm_button_confirmation_delete_all_sources": "Sim, apague tudo", @@ -266,6 +273,7 @@ "found_download_option_one": "{{countFormatted}} opção de transferência encontrada", "found_download_option_other": "{{countFormatted}} opções de transferência encontradas", "import": "Importar", + "importing": "A importar...", "privacy": "Privacidade", "private": "Privado", "friends_only": "Apenas amigos", @@ -375,10 +383,18 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", "last_time_played": "Última sessão {{period}}", "activity": "Atividade recente", "library": "Biblioteca", + "pinned": "Fixados", + "sort_by": "Ordenar por:", + "achievements_earned": "Conquistas obtidas", + "played_recently": "Jogados recentemente", + "playtime": "Tempo de jogo", "total_play_time": "Tempo total de jogo", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "no_recent_activity_title": "Hmmm… não há nada por aqui", "no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?", "display_name": "Nome de apresentação", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index be02c7b4..8ed6fd39 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -135,11 +135,7 @@ "real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid", "debrid_linked_message": "Contul \"{{username}}\" a fost legat", "save_changes": "Salvează modificările", - "changes_saved": "Modificările au fost salvate cu succes", - "enable_all_debrid": "Activează All-Debrid", - "all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.", - "all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid", - "all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes" + "changes_saved": "Modificările au fost salvate cu succes" }, "notifications": { "download_complete": "Descărcare completă", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 8992a4a0..15a9c9cb 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -6,8 +6,8 @@ "home": { "surprise_me": "Удиви меня", "no_results": "Ничего не найдено", - "hot": "Сейчас популярно", "start_typing": "Начинаю вводить текст...", + "hot": "Сейчас популярно", "weekly": "📅 Лучшие игры недели", "achievements": "🏆 Игры с достижениями" }, @@ -28,6 +28,8 @@ "need_help": "Нужна помощь?", "favorites": "Избранное", "playable_button_title": "Показать только установленные игры.", + "add_custom_game_tooltip": "Добавить пользовательскую игру", + "show_playable_only_tooltip": "Показать только доступные для игры", "custom_game_modal": "Добавить пользовательскую игру", "custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл", "custom_game_modal_executable_path": "Путь к исполняемому файлу", @@ -74,7 +76,19 @@ "edit_game_modal_drop_hero_image_here": "Перетащите изображение обложки сюда", "edit_game_modal_drop_to_replace_icon": "Перетащите для замены иконки", "edit_game_modal_drop_to_replace_logo": "Перетащите для замены логотипа", - "edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки" + "edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки", + "install_decky_plugin": "Установить плагин Decky", + "update_decky_plugin": "Обновить плагин Decky", + "decky_plugin_installed_version": "Плагин Decky (v{{version}})", + "install_decky_plugin_title": "Установить плагин Hydra Decky", + "install_decky_plugin_message": "Это загрузит и установит плагин Hydra для Decky Loader. Может потребоваться повышенные разрешения. Продолжить?", + "update_decky_plugin_title": "Обновить плагин Hydra Decky", + "update_decky_plugin_message": "Доступна новая версия плагина Hydra Decky. Хотите обновить его сейчас?", + "decky_plugin_installed": "Плагин Decky v{{version}} успешно установлен", + "decky_plugin_installation_failed": "Не удалось установить плагин Decky: {{error}}", + "decky_plugin_installation_error": "Ошибка установки плагина Decky: {{error}}", + "confirm": "Подтвердить", + "cancel": "Отмена" }, "header": { "search": "Поиск", @@ -135,6 +149,7 @@ "amount_minutes": "{{amount}} минут", "accuracy": "точность {{accuracy}}%", "add_to_library": "Добавить в библиотеку", + "already_in_library": "Уже в библиотеке", "remove_from_library": "Удалить из библиотеки", "no_downloads": "Нет доступных источников", "play_time": "Сыграно {{amount}}", @@ -163,11 +178,13 @@ "open_folder": "Открыть папку", "open_download_location": "Просмотреть папку загрузок", "create_shortcut": "Создать ярлык на рабочем столе", + "create_shortcut_simple": "Создать ярлык", "clear": "Очистить", "remove_files": "Удалить файлы", "remove_from_library_title": "Вы уверены?", "remove_from_library_description": "{{game}} будет удалена из вашей библиотеки.", "options": "Настройки", + "properties": "Свойства", "executable_section_title": "Файл", "executable_section_description": "Путь к файлу, который будет запущен при нажатии на \"Play\"", "downloads_section_title": "Загрузки", @@ -177,22 +194,65 @@ "download_in_progress": "Идёт загрузка", "download_paused": "Загрузка приостановлена", "last_downloaded_option": "Последний вариант загрузки", + "create_steam_shortcut": "Создать ярлык Steam", "create_shortcut_success": "Ярлык создан", + "you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения", "create_shortcut_error": "Не удалось создать ярлык", - "allow_nsfw_content": "Продолжить", - "download": "Скачать", - "download_count": "Загрузки", - "download_error": "Этот вариант загрузки недоступен", - "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", - "nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?", + "add_to_favorites": "Добавить в избранное", + "remove_from_favorites": "Удалить из избранного", + "failed_update_favorites": "Не удалось обновить избранное", + "game_removed_from_library": "Игра удалена из библиотеки", + "failed_remove_from_library": "Не удалось удалить из библиотеки", + "files_removed_success": "Файлы успешно удалены", + "failed_remove_files": "Не удалось удалить файлы", "nsfw_content_title": "Эта игра содержит неприемлемый контент", + "nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?", + "allow_nsfw_content": "Продолжить", "refuse_nsfw_content": "Назад", "stats": "Статистика", + "download_count": "Загрузки", "player_count": "Активные игроки", + "rating_count": "Оценка", + "download_error": "Этот вариант загрузки недоступен", + "download": "Скачать", + "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", "warning": "Внимание:", "hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.", "achievements": "Достижения", "achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}", + "show_more": "Показать больше", + "show_less": "Показать меньше", + "reviews": "Отзывы", + "leave_a_review": "Оставить отзыв", + "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", + "sort_newest": "Сначала новые", + "no_reviews_yet": "Пока нет отзывов", + "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", + "sort_oldest": "Сначала старые", + "sort_highest_score": "Высший балл", + "sort_lowest_score": "Низший балл", + "sort_most_voted": "Самые популярные", + "rating": "Оценка", + "rating_stats": "Оценка", + "rating_very_negative": "Очень негативно", + "rating_negative": "Негативно", + "rating_neutral": "Нейтрально", + "rating_positive": "Позитивно", + "rating_very_positive": "Очень позитивно", + "submit_review": "Отправить", + "submitting": "Отправка...", + "review_submitted_successfully": "Отзыв успешно отправлен!", + "review_submission_failed": "Не удалось отправить отзыв. Пожалуйста, попробуйте снова.", + "review_cannot_be_empty": "Текстовое поле отзыва не может быть пустым.", + "review_deleted_successfully": "Отзыв успешно удален.", + "review_deletion_failed": "Не удалось удалить отзыв. Пожалуйста, попробуйте снова.", + "loading_reviews": "Загрузка отзывов...", + "loading_more_reviews": "Загрузка дополнительных отзывов...", + "load_more_reviews": "Загрузить больше отзывов", + "you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра", + "would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?", + "yes": "Да", + "maybe_later": "Возможно позже", "cloud_save": "Облачное сохранение", "cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве", "backups": "Резервные копии", @@ -205,6 +265,7 @@ "uploading_backup": "Загрузка резервной копии…", "no_backups": "Вы еще не создали резервных копий для этой игры", "backup_uploaded": "Резервная копия загружена", + "backup_failed": "Ошибка резервного копирования", "backup_deleted": "Резервная копия удалена", "backup_restored": "Резервная копия восстановлена", "see_all_achievements": "Просмотреть все достижения", @@ -244,26 +305,29 @@ "update_playtime_title": "Обновить время игры", "update_playtime_description": "Вручную обновите время игры для {{game}}", "update_playtime": "Обновить время игры", + "update_playtime_success": "Время игры успешно обновлено", + "update_playtime_error": "Не удалось обновить время игры", "update_game_playtime": "Обновить время игры", + "manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.", + "manual_playtime_tooltip": "Это время игры было обновлено вручную", "download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.", - "game_added_to_favorites": "Игра добавлена в избранное", + "download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.", "game_removed_from_favorites": "Игра удалена из избранного", + "game_added_to_favorites": "Игра добавлена в избранное", + "game_removed_from_pinned": "Игра удалена из закрепленных", + "game_added_to_pinned": "Игра добавлена в закрепленные", "automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов", - "create_steam_shortcut": "Создать ярлык Steam", - "you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения", "create_start_menu_shortcut": "Создать ярлык в меню «Пуск»", "invalid_wine_prefix_path": "Недопустимый путь префикса Wine", "invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.", "missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux", - "download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.", - "update_playtime_success": "Время игры успешно обновлено", - "update_playtime_error": "Не удалось обновить время игры", - "manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.", "artifact_renamed": "Резервная копия успешно переименована", "rename_artifact": "Переименовать резервную копию", "rename_artifact_description": "Переименуйте резервную копию, присвоив ей более описательное имя.", "artifact_name_label": "Название резервной копии", "artifact_name_placeholder": "Введите название для резервной копии", + "save_changes": "Сохранить изменения", + "required_field": "Это поле обязательно к заполнению", "max_length_field": "Это поле должно содержать менее {{length}} символов", "freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями", "unfreeze_backup": "Открепить", @@ -271,7 +335,33 @@ "backup_unfrozen": "Резервная копия откреплена", "backup_freeze_failed": "Не удалось закрепить резервную копию", "backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий", - "manual_playtime_tooltip": "Это время игры было обновлено вручную" + "edit_game_modal_button": "Изменить детали игры", + "game_details": "Детали игры", + "currency_symbol": "₽", + "currency_country": "ru", + "prices": "Цены", + "no_prices_found": "Цены не найдены", + "view_all_prices": "Нажмите, чтобы посмотреть все цены", + "retail_price": "Розничная цена", + "keyshop_price": "Цена в магазине ключей", + "historical_retail": "Исторические розничные цены", + "historical_keyshop": "Исторические цены в магазинах ключей", + "language": "Язык", + "caption": "Субтитры", + "audio": "Аудио", + "filter_by_source": "Фильтр по источнику", + "no_repacks_found": "Источники для этой игры не найдены", + "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": "Скрыть оригинал" }, "activation": { "title": "Активировать Hydra", @@ -317,13 +407,10 @@ "change": "Изменить", "notifications": "Уведомления", "enable_download_notifications": "По завершении загрузки", - "enable_achievement_notifications": "Когда достижение разблокировано", "enable_repack_list_notifications": "При добавлении нового репака", "real_debrid_api_token_label": "Real-Debrid API-токен", "quit_app_instead_hiding": "Закрывать приложение вместо сворачивания в трей", "launch_with_system": "Запускать Hydra вместе с системой", - "launch_minimized": "Запустить Hydra в свернутом виде", - "disable_nsfw_alert": "Отключить предупреждение о непристойном контенте", "general": "Основные", "behavior": "Поведение", "download_sources": "Источники загрузки", @@ -341,6 +428,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}} загрузок в списке", @@ -348,13 +438,20 @@ "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": "Да, удалить все", - "description_confirmation_delete_all_sources": "Вы удалите все источники", "title_confirmation_delete_all_sources": "Удалить все источники", - "removed_download_sources": "Источники удалены", + "description_confirmation_delete_all_sources": "Вы удалите все источники", "button_delete_all_sources": "Удалить все источники", "added_download_source": "Источник добавлен", "download_sources_synced": "Все источники обновлены", @@ -363,20 +460,25 @@ "found_download_option_one": "Найден {{countFormatted}} вариант загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", "import": "Импортировать", - "blocked_users": "Заблокированные пользователи", - "friends_only": "Только для друзей", - "must_be_valid_url": "У источника должен быть правильный URL", - "privacy": "Конфиденциальность", + "importing": "Импортируется...", + "public": "Публичный", "private": "Частный", + "friends_only": "Только для друзей", + "privacy": "Конфиденциальность", "profile_visibility": "Видимость профиля", "profile_visibility_description": "Выберите, кто может видеть ваш профиль и библиотеку", - "public": "Публичный", "required_field": "Это поле обязательно к заполнению", "source_already_exists": "Этот источник уже добавлен", + "must_be_valid_url": "У источника должен быть правильный URL", + "blocked_users": "Заблокированные пользователи", "user_unblocked": "Пользователь разблокирован", + "enable_achievement_notifications": "Когда достижение разблокировано", + "launch_minimized": "Запускать Hydra в свернутом виде", + "disable_nsfw_alert": "Отключить предупреждение о непристойном контенте", "seed_after_download_complete": "Раздавать после завершения загрузки", "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", "account": "Аккаунт", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "У вас нет заблокированных пользователей", "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", "manage_subscription": "Управлять подпиской", @@ -412,12 +514,14 @@ "delete_theme_description": "Это приведет к удалению темы {{theme}}", "cancel": "Отменить", "appearance": "Внешний вид", + "debrid": "Debrid", + "debrid_description": "Сервисы Debrid - это премиум-загрузчики без ограничений, которые позволяют быстро скачивать файлы с различных файлообменников, ограничиваясь только скоростью вашего интернета.", "enable_torbox": "Включить TorBox", "torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.", "torbox_account_linked": "Аккаунт TorBox привязан", - "real_debrid_account_linked": "Аккаунт Real-Debrid привязан", "create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid", "create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox", + "real_debrid_account_linked": "Аккаунт Real-Debrid привязан", "name_min_length": "Название темы должно содержать не менее 3 символов", "import_theme": "Импортировать тему", "import_theme_description": "Вы импортируете {{theme}} из магазина тем", @@ -431,6 +535,7 @@ "installing_common_redist": "Установка…", "show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду", "extract_files_by_default": "Извлекать файлы по умолчанию после загрузки", + "enable_steam_achievements": "Включить поиск достижений Steam", "achievement_custom_notification_position": "Позиция уведомлений достижений", "top-left": "Верхний левый угол", "top-center": "Верхний центр", @@ -448,7 +553,8 @@ "test_notification": "Тестовое уведомление", "notification_preview": "Предварительный просмотр уведомления о достижении", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", - "enable_steam_achievements": "Включить поиск достижений Steam" + "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", + "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры" }, "notifications": { "download_complete": "Загрузка завершена", @@ -460,13 +566,13 @@ "restart_to_install_update": "Перезапустите Hydra для установки обновления", "notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}", "notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}", + "new_friend_request_description": "{{displayName}} отправил вам запрос в друзья", "new_friend_request_title": "Новый запрос на добавление в друзья", "extraction_complete": "Распаковка завершена", "game_extracted": "{{title}} успешно распакован", "friend_started_playing_game": "{{displayName}} начал играть в игру", "test_achievement_notification_title": "Это тестовое уведомление", - "test_achievement_notification_description": "Довольно круто, да?", - "new_friend_request_description": "{{displayName}} отправил вам запрос в друзья" + "test_achievement_notification_description": "Довольно круто, да?" }, "system_tray": { "open": "Открыть Hydra", @@ -475,7 +581,8 @@ "game_card": { "available_one": "Доступный", "available_other": "Доступный", - "no_downloads": "Нет доступных источников" + "no_downloads": "Нет доступных источников", + "calculating": "Вычисление" }, "binary_not_found_modal": { "title": "Программы не установлены", @@ -496,6 +603,11 @@ "last_time_played": "Последняя игра {{period}}", "activity": "Недавняя активность", "library": "Библиотека", + "pinned": "Закрепленные", + "sort_by": "Сортировать по:", + "achievements_earned": "Заработанные достижения", + "played_recently": "Недавно сыгранные", + "playtime": "Время игры", "total_play_time": "Всего сыграно", "manual_playtime_tooltip": "Время игры было обновлено вручную", "no_recent_activity_title": "Хммм... Тут ничего нет", @@ -539,24 +651,24 @@ "no_pending_invites": "У вас нет запросов ожидающих ответа", "no_blocked_users": "Вы не заблокировали ни одного пользователя", "friend_code_copied": "Код друга скопирован", - "displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.", - "displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.", - "image_process_failure": "Сбой при обработке изображения", - "locked_profile": "Этот профиль является частным", + "undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.", "privacy_hint": "Чтобы указать, кто может это видеть, перейдите в <0>Настройки.", - "profile_reported": "Профиль сообщил", - "report": "Отчет", - "report_description": "Дополнительная информация", - "report_description_placeholder": "Дополнительная информация", + "locked_profile": "Этот профиль является частным", + "image_process_failure": "Сбой при обработке изображения", + "required_field": "Это поле обязательно к заполнению", + "displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.", + "displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.", "report_profile": "Пожаловаться на этот профиль", "report_reason": "Почему вы жалуетесь на этот профиль?", + "report_description": "Дополнительная информация", + "report_description_placeholder": "Дополнительная информация", + "report": "Пожаловаться", "report_reason_hate": "Разжигание ненависти", - "report_reason_other": "Другой", "report_reason_sexual_content": "Сексуальный контент", - "report_reason_spam": "Спам", "report_reason_violence": "Насилие", - "required_field": "Это поле обязательно к заполнению", - "undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.", + "report_reason_spam": "Спам", + "report_reason_other": "Другое", + "profile_reported": "Жалоба на профиль отправлена", "your_friend_code": "Код вашего друга:", "upload_banner": "Загрузить баннер", "uploading_banner": "Загрузка баннера...", @@ -572,7 +684,12 @@ "show_achievements_on_profile": "Покажите свои достижения в профиле", "show_points_on_profile": "Показывать заработанные очки в своем профиле", "error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга", - "friend_code_length_error": "Код друга должен содержать 8 символов" + "friend_code_length_error": "Код друга должен содержать 8 символов", + "game_removed_from_pinned": "Игра удалена из закрепленных", + "game_added_to_pinned": "Игра добавлена в закрепленные", + "karma": "Карма", + "karma_count": "карма", + "karma_description": "Заработана положительными оценками отзывов" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 26aa8aae..323d8ad5 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -27,7 +27,68 @@ "favorites": "Улюблені", "friends": "Друзі", "need_help": "Потрібна допомога?", - "playable_button_title": "Показати лише ігри, які можна грати зараз" + "playable_button_title": "Показати лише ігри, які можна грати зараз", + "add_custom_game_tooltip": "Додати власну гру", + "show_playable_only_tooltip": "Показати лише доступні для гри", + "custom_game_modal": "Додати власну гру", + "custom_game_modal_description": "Додайте власну гру до бібліотеки, вибравши виконуваний файл", + "custom_game_modal_executable_path": "Шлях до виконуваного файлу", + "custom_game_modal_select_executable": "Виберіть виконуваний файл", + "custom_game_modal_title": "Назва гри", + "custom_game_modal_enter_title": "Введіть назву гри", + "custom_game_modal_browse": "Огляд", + "custom_game_modal_cancel": "Скасувати", + "custom_game_modal_add": "Додати гру", + "custom_game_modal_adding": "Додавання гри...", + "custom_game_modal_success": "Власну гру успішно додано", + "custom_game_modal_failed": "Не вдалося додати власну гру", + "custom_game_modal_executable": "Виконуваний файл", + "edit_game_modal": "Налаштувати ресурси", + "edit_game_modal_description": "Налаштуйте ресурси та деталі гри", + "edit_game_modal_title": "Назва", + "edit_game_modal_enter_title": "Введіть назву", + "edit_game_modal_image": "Зображення", + "edit_game_modal_select_image": "Виберіть зображення", + "edit_game_modal_browse": "Огляд", + "edit_game_modal_image_preview": "Попередній перегляд зображення", + "edit_game_modal_icon": "Іконка", + "edit_game_modal_select_icon": "Виберіть іконку", + "edit_game_modal_icon_preview": "Попередній перегляд іконки", + "edit_game_modal_logo": "Логотип", + "edit_game_modal_select_logo": "Виберіть логотип", + "edit_game_modal_logo_preview": "Попередній перегляд логотипу", + "edit_game_modal_hero": "Зображення обкладинки гри", + "edit_game_modal_select_hero": "Виберіть обкладинку гри", + "edit_game_modal_hero_preview": "Попередній перегляд обкладинки гри", + "edit_game_modal_cancel": "Скасувати", + "edit_game_modal_update": "Оновити", + "edit_game_modal_updating": "Оновлення...", + "edit_game_modal_fill_required": "Будь ласка, заповніть всі обов'язкові поля", + "edit_game_modal_success": "Ресурси успішно оновлено", + "edit_game_modal_failed": "Не вдалося оновити ресурси", + "edit_game_modal_image_filter": "Зображення", + "edit_game_modal_icon_resolution": "Рекомендована роздільна здатність: 256x256px", + "edit_game_modal_logo_resolution": "Рекомендована роздільна здатність: 640x360px", + "edit_game_modal_hero_resolution": "Рекомендована роздільна здатність: 1920x620px", + "edit_game_modal_assets": "Ресурси", + "edit_game_modal_drop_icon_image_here": "Перетягніть зображення іконки сюди", + "edit_game_modal_drop_logo_image_here": "Перетягніть зображення логотипу сюди", + "edit_game_modal_drop_hero_image_here": "Перетягніть зображення обкладинки сюди", + "edit_game_modal_drop_to_replace_icon": "Перетягніть для заміни іконки", + "edit_game_modal_drop_to_replace_logo": "Перетягніть для заміни логотипу", + "edit_game_modal_drop_to_replace_hero": "Перетягніть для заміни обкладинки", + "install_decky_plugin": "Встановити плагін Decky", + "update_decky_plugin": "Оновити плагін Decky", + "decky_plugin_installed_version": "Плагін Decky (v{{version}})", + "install_decky_plugin_title": "Встановити плагін Hydra Decky", + "install_decky_plugin_message": "Це завантажить і встановить плагін Hydra для Decky Loader. Можуть знадобитися підвищені дозволи. Продовжити?", + "update_decky_plugin_title": "Оновити плагін Hydra Decky", + "update_decky_plugin_message": "Доступна нова версія плагіна Hydra Decky. Бажаєте оновити його зараз?", + "decky_plugin_installed": "Плагін Decky v{{version}} успішно встановлено", + "decky_plugin_installation_failed": "Не вдалося встановити плагін Decky: {{error}}", + "decky_plugin_installation_error": "Помилка встановлення плагіна Decky: {{error}}", + "confirm": "Підтвердити", + "cancel": "Скасувати" }, "header": { "search": "Пошук", @@ -86,6 +147,7 @@ "amount_minutes": "{{amount}} хвилин", "accuracy": "{{accuracy}}% точність", "add_to_library": "Додати до бібліотеки", + "already_in_library": "Вже в бібліотеці", "remove_from_library": "Видалити з бібліотеки", "no_downloads": "Немає доступних завантажень", "play_time": "Час гри: {{amount}}", @@ -102,6 +164,7 @@ "download_now": "Завантажити зараз", "calculating_eta": "Обчислення залишкового часу…", "create_shortcut": "Створити ярлик на робочому столі", + "create_shortcut_simple": "Створити ярлик", "create_shortcut_success": "Ярлик успішно створено", "create_shortcut_error": "Виникла помилка під час створення ярлику", "nsfw_content_title": "Ця гра містить неприйнятний контент", @@ -135,6 +198,7 @@ "open_folder": "Відкрити папку", "open_screenshot": "Відкрити скріншот", "options": "Налаштування", + "properties": "Властивості", "paused": "Призупинено", "previous_screenshot": "Попередній скріншот", "remove_files": "Видалити файли", @@ -171,7 +235,7 @@ "loading_save_preview": "Виконується пошук збережень гри...", "wine_prefix": "Префікс Wine", "wine_prefix_description": "Префікс Wine використовувався для запуску цієї гри", - "launch_options": "Параметри загрузки", + "launch_options": "Параметри завантаження", "launch_options_description": "Досвідчені користувачі можуть ввести власні модифікації до параметрів запуску (експериментальна функція).", "launch_options_placeholder": "Параметри не вказано", "no_download_option_info": "Немає інформації", @@ -198,11 +262,105 @@ "download_error_not_cached_on_hydra": "Це завантаження недоступне через Nimbus.", "game_removed_from_favorites": "Гра видалена з улюбленних", "game_added_to_favorites": "Гра була добавлена у улюблені", - "automatically_extract_downloaded_files": "Автоматично розархівувати завантаженні файли" + "automatically_extract_downloaded_files": "Автоматично розархівувати завантаженні файли", + "create_steam_shortcut": "Створити ярлик Steam", + "you_might_need_to_restart_steam": "Можливо, вам знадобиться перезапустити Steam, щоб побачити зміни", + "add_to_favorites": "Додати до улюбленого", + "remove_from_favorites": "Видалити з улюбленого", + "failed_update_favorites": "Не вдалося оновити улюблене", + "game_removed_from_library": "Гру видалено з бібліотеки", + "failed_remove_from_library": "Не вдалося видалити з бібліотеки", + "files_removed_success": "Файли успішно видалено", + "failed_remove_files": "Не вдалося видалити файли", + "show_more": "Показати більше", + "show_less": "Показати менше", + "reviews": "Відгуки", + "leave_a_review": "Залишити відгук", + "write_review_placeholder": "Поділіться своїми думками про цю гру...", + "sort_newest": "Спочатку нові", + "no_reviews_yet": "Поки що немає відгуків", + "be_first_to_review": "Станьте першим, хто поділиться своїми думками про цю гру!", + "sort_oldest": "Спочатку старі", + "sort_highest_score": "Найвища оцінка", + "sort_lowest_score": "Найнижча оцінка", + "sort_most_voted": "Найпопулярніші", + "rating": "Оцінка", + "rating_stats": "Оцінка", + "rating_very_negative": "Дуже негативно", + "rating_negative": "Негативно", + "rating_neutral": "Нейтрально", + "rating_positive": "Позитивно", + "rating_very_positive": "Дуже позитивно", + "submit_review": "Відправити", + "submitting": "Відправка...", + "review_submitted_successfully": "Відгук успішно відправлено!", + "review_submission_failed": "Не вдалося відправити відгук. Будь ласка, спробуйте ще раз.", + "review_cannot_be_empty": "Текстове поле відгуку не може бути порожнім.", + "review_deleted_successfully": "Відгук успішно видалено.", + "review_deletion_failed": "Не вдалося видалити відгук. Будь ласка, спробуйте ще раз.", + "loading_reviews": "Завантаження відгуків...", + "loading_more_reviews": "Завантаження додаткових відгуків...", + "load_more_reviews": "Завантажити більше відгуків", + "you_seemed_to_enjoy_this_game": "Схоже, вам сподобалася ця гра", + "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": "Скасувати", + "backup_failed": "Помилка резервного копіювання", + "update_playtime_title": "Оновити час гри", + "update_playtime_description": "Вручну оновіть час гри для {{game}}", + "update_playtime": "Оновити час гри", + "update_playtime_success": "Час гри успішно оновлено", + "update_playtime_error": "Не вдалося оновити час гри", + "update_game_playtime": "Оновити час гри", + "manual_playtime_warning": "Ваші години будуть позначені як оновлені вручну. Цю дію не можна скасувати.", + "manual_playtime_tooltip": "Цей час гри було оновлено вручну", + "game_removed_from_pinned": "Гру видалено із закріплених", + "game_added_to_pinned": "Гру додано до закріплених", + "create_start_menu_shortcut": "Створити ярлик у меню «Пуск»", + "invalid_wine_prefix_path": "Недійсний шлях префікса Wine", + "invalid_wine_prefix_path_description": "Шлях до префікса Wine недійсний. Будь ласка, перевірте шлях і спробуйте знову.", + "missing_wine_prefix": "Префікс Wine необхідний для створення резервної копії в Linux", + "artifact_renamed": "Резервну копію успішно перейменовано", + "rename_artifact": "Перейменувати резервну копію", + "rename_artifact_description": "Перейменуйте резервну копію, надавши їй більш описову назву.", + "artifact_name_label": "Назва резервної копії", + "artifact_name_placeholder": "Введіть назву для резервної копії", + "save_changes": "Зберегти зміни", + "required_field": "Це поле обов'язкове", + "max_length_field": "Це поле має містити менше ніж {{length}} символів", + "freeze_backup": "Закріпити, щоб вона не була перезаписана автоматичними резервними копіями", + "unfreeze_backup": "Відкріпити", + "backup_frozen": "Резервну копію закріплено", + "backup_unfrozen": "Резервну копію відкріплено", + "backup_freeze_failed": "Не вдалося закріпити резервну копію", + "backup_freeze_failed_description": "Ви повинні залишити принаймні один вільний слот для автоматичних резервних копій", + "edit_game_modal_button": "Змінити деталі гри", + "game_details": "Деталі гри", + "currency_symbol": "₴", + "currency_country": "ua", + "prices": "Ціни", + "no_prices_found": "Ціни не знайдено", + "view_all_prices": "Натисніть, щоб переглянути всі ціни", + "retail_price": "Роздрібна ціна", + "keyshop_price": "Ціна в магазині ключів", + "historical_retail": "Історичні роздрібні ціни", + "historical_keyshop": "Історичні ціни в магазинах ключів", + "language": "Мова", + "caption": "Субтитри", + "audio": "Аудіо", + "filter_by_source": "Фільтр за джерелом", + "no_repacks_found": "Джерела для цієї гри не знайдено" }, "activation": { "title": "Активувати Hydra", - "installation_id": "ID установки:", + "installation_id": "ID встановлення:", "enter_activation_code": "Введіть ваш активаційний код", "message": "Якщо ви не знаєте, де його запросити, то не повинні мати його.", "activate": "Активувати", @@ -226,7 +384,7 @@ "install": "Встановити", "download_in_progress": "В процесі", "downloads_completed": "Завершено", - "no_downloads_description": "Ви ще нічого не завантажили через Hydra, але ніколи не пізно почати!", + "no_downloads_description": "Ви ще нічого не завантажили через Hydra, але ніколи не пізно почати", "no_downloads_title": "Тут так пусто...", "queued": "В черзі", "queued_downloads": "Завантаження в черзі", @@ -339,6 +497,8 @@ "delete_theme_description": "Це видалить тему {{theme}}", "cancel": "Відмінити", "appearance": "Вигляд", + "debrid": "Debrid", + "debrid_description": "Сервіси Debrid - це преміум-завантажувачі без обмежень, які дозволяють швидко завантажувати файли з різних файлообмінників, обмежуючись лише швидкістю вашого інтернету.", "enable_torbox": "Включити TorBox", "torbox_description": "TorBox — це ваш преміум-сервіс для сідінгу, що конкурує навіть з найкращими серверами на ринку.", "torbox_account_linked": "TorBox акаунт прив'язано", @@ -357,7 +517,25 @@ "install_common_redist": "Встановити", "installing_common_redist": "Встановлюється…", "show_download_speed_in_megabytes": "Показувати швидкість завантаження в мегабайтах на секунду", - "extract_files_by_default": "Розпаковувати файли після завантаження" + "extract_files_by_default": "Розпаковувати файли після завантаження", + "enable_steam_achievements": "Увімкнути пошук досягнень Steam", + "achievement_custom_notification_position": "Позиція сповіщень про досягнення", + "top-left": "Верхній лівий кут", + "top-center": "Верхній центр", + "top-right": "Верхній правий кут", + "bottom-left": "Нижній лівий кут", + "bottom-center": "Нижній центр", + "bottom-right": "Нижній правий кут", + "enable_achievement_custom_notifications": "Увімкнути сповіщення про досягнення", + "alignment": "Вирівнювання", + "variation": "Варіація", + "default": "За замовчуванням", + "rare": "Рідкісне", + "platinum": "Платиновий", + "hidden": "Прихований", + "test_notification": "Тестове сповіщення", + "notification_preview": "Попередній перегляд сповіщення про досягнення", + "enable_friend_start_game_notifications": "Коли друг починає грати в гру" }, "notifications": { "download_complete": "Завантаження завершено", @@ -372,7 +550,10 @@ "new_friend_request_description": "Ви отримали новий запит на дружбу", "new_friend_request_title": "Новий запит на дружбу", "extraction_complete": "Витягування завершено", - "game_extracted": "{{title}} успішно витягнуто" + "game_extracted": "{{title}} успішно витягнуто", + "friend_started_playing_game": "{{displayName}} почав грати в гру", + "test_achievement_notification_title": "Це тестове сповіщення", + "test_achievement_notification_description": "Досить круто, чи не так?" }, "system_tray": { "open": "Відкрити Hydra", @@ -381,7 +562,8 @@ "game_card": { "no_downloads": "Немає доступних завантажень", "available_one": "Доступний", - "available_other": "Доступні" + "available_other": "Доступні", + "calculating": "Обчислення" }, "binary_not_found_modal": { "title": "Програми не встановлені", @@ -398,11 +580,17 @@ "activity": "Остання активність", "amount_hours": "{{amount}} годин", "amount_minutes": "{{amount}} хвилин", + "amount_hours_short": "{{amount}}год", + "amount_minutes_short": "{{amount}}хв", "cancel": "Скасувати", "display_name": "Відображуване ім'я", "edit_profile": "Редагувати профіль", "last_time_played": "Остання гра {{period}}", "library": "Бібліотека", + "pinned": "Закріплені", + "achievements_earned": "Зароблені досягнення", + "played_recently": "Недавно зіграні", + "playtime": "Час гри", "no_recent_activity_description": "Ви давно не грали в ігри. Пора це змінити!", "no_recent_activity_title": "Хммм... Тут нічого немає", "playing_for": "Зіграно {{amount}}", @@ -414,9 +602,10 @@ "sign_out_modal_title": "Ви впевнені?", "successfully_signed_out": "Успішний вихід з акаунту", "total_play_time": "Всього зіграно", + "manual_playtime_tooltip": "Час гри було оновлено вручну", "try_again": "Будь ласка, попробуйте ще раз", - "add_friends": "Добавити друзів", - "add": "Добавити", + "add_friends": "Додати друзів", + "add": "Додати", "friend_code": "Код друга", "see_profile": "Переглянути профіль", "sending": "Надсилання", @@ -425,7 +614,7 @@ "friends_list": "Список друзів", "user_not_found": "Користувача не найдено", "block_user": "Заблокувати користувача", - "add_friend": "Добавити друга", + "add_friend": "Додати друга", "request_sent": "надіслано запит на дружбу", "request_received": "Отримано запит на дружбу", "accept_request": "Прийняти запит", @@ -473,7 +662,14 @@ "achievements_unlocked": "Досягнень розблоковано", "earned_points": "Отримано балів", "show_achievements_on_profile": "Покажіть свої досягнення у своєму профілі", - "show_points_on_profile": "Покажіть ваші отриманні бали у своєму профілі" + "show_points_on_profile": "Покажіть ваші отриманні бали у своєму профілі", + "error_adding_friend": "Не вдалося відправити запит на дружбу. Будь ласка, перевірте код друга", + "friend_code_length_error": "Код друга має містити 8 символів", + "game_removed_from_pinned": "Гру видалено із закріплених", + "game_added_to_pinned": "Гру додано до закріплених", + "karma": "Карма", + "karma_count": "карма", + "karma_description": "Зароблена позитивними оцінками на відгуках" }, "achievement": { "achievement_unlocked": "Досягнення розблоковано", diff --git a/src/main/constants.ts b/src/main/constants.ts index b067be80..82b99b2a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); export const MAIN_LOOP_INTERVAL = 2000; + +export const DECKY_PLUGINS_LOCATION = path.join( + SystemPath.getPath("home"), + "homebrew", + "plugins" +); + +export const HYDRA_DECKY_PLUGIN_LOCATION = path.join( + DECKY_PLUGINS_LOCATION, + "Hydra" +); diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts deleted file mode 100644 index c8c24cbe..00000000 --- a/src/main/events/catalogue/get-catalogue.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { CatalogueCategory } from "@shared"; -import { ShopAssets } from "@types"; - -const getCatalogue = async ( - _event: Electron.IpcMainInvokeEvent, - category: CatalogueCategory -) => { - const params = new URLSearchParams({ - take: "12", - skip: "0", - }); - - return HydraApi.get( - `/catalogue/${category}?${params.toString()}`, - {}, - { needsAuth: false } - ); -}; - -registerEvent("getCatalogue", getCatalogue); diff --git a/src/main/events/catalogue/get-developers.ts b/src/main/events/catalogue/get-developers.ts deleted file mode 100644 index 76ae566b..00000000 --- a/src/main/events/catalogue/get-developers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get(`/catalogue/developers`, null, { - needsAuth: false, - }); -}; - -registerEvent("getDevelopers", getDevelopers); diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts new file mode 100644 index 00000000..0e45f886 --- /dev/null +++ b/src/main/events/catalogue/get-game-assets.ts @@ -0,0 +1,55 @@ +import type { GameShop, ShopAssets } from "@types"; +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +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) + ); + + if ( + cachedAssets && + cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now() + ) { + return cachedAssets; + } + + return HydraApi.get( + `/games/${shop}/${objectId}/assets`, + null, + { + needsAuth: false, + } + ).then(async (assets) => { + if (!assets) return null; + + // Preserve existing title if it differs from the incoming title (indicating it was customized) + const shouldPreserveTitle = + cachedAssets?.title && cachedAssets.title !== assets.title; + + await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), { + ...assets, + title: shouldPreserveTitle ? cachedAssets.title : assets.title, + updatedAt: Date.now(), + }); + + return assets; + }); +}; + +const getGameAssetsEvent = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop +) => { + return getGameAssets(objectId, shop); +}; + +registerEvent("getGameAssets", getGameAssetsEvent); 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/catalogue/get-how-long-to-beat.ts b/src/main/events/catalogue/get-how-long-to-beat.ts deleted file mode 100644 index 0d630164..00000000 --- a/src/main/events/catalogue/get-how-long-to-beat.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GameShop, HowLongToBeatCategory } from "@types"; - -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const getHowLongToBeat = async ( - _event: Electron.IpcMainInvokeEvent, - objectId: string, - shop: GameShop -): Promise => { - return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, null, { - needsAuth: false, - }); -}; - -registerEvent("getHowLongToBeat", getHowLongToBeat); diff --git a/src/main/events/catalogue/get-publishers.ts b/src/main/events/catalogue/get-publishers.ts deleted file mode 100644 index 3b8fdc5f..00000000 --- a/src/main/events/catalogue/get-publishers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get(`/catalogue/publishers`, null, { - needsAuth: false, - }); -}; - -registerEvent("getPublishers", getPublishers); diff --git a/src/main/events/catalogue/get-trending-games.ts b/src/main/events/catalogue/get-trending-games.ts deleted file mode 100644 index 4c587f37..00000000 --- a/src/main/events/catalogue/get-trending-games.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db, levelKeys } from "@main/level"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { TrendingGame } from "@types"; - -const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { - const language = await db - .get(levelKeys.language, { - valueEncoding: "utf8", - }) - .then((language) => language || "en"); - - const trendingGames = await HydraApi.get( - "/catalogue/featured", - { language }, - { needsAuth: false } - ).catch(() => []); - - return trendingGames.slice(0, 1); -}; - -registerEvent("getTrendingGames", getTrendingGames); diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts deleted file mode 100644 index bf5f8b81..00000000 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { GameShop, ShopAssets } from "@types"; -import { gamesShopAssetsSublevel, levelKeys } from "@main/level"; -import { registerEvent } from "../register-event"; - -const saveGameShopAssets = async ( - _event: Electron.IpcMainInvokeEvent, - objectId: string, - shop: GameShop, - assets: ShopAssets -): Promise => { - const key = levelKeys.game(shop, objectId); - const existingAssets = await gamesShopAssetsSublevel.get(key); - - // Preserve existing title if it differs from the incoming title (indicating it was customized) - const shouldPreserveTitle = - existingAssets?.title && existingAssets.title !== assets.title; - - return gamesShopAssetsSublevel.put(key, { - ...existingAssets, - ...assets, - title: shouldPreserveTitle ? existingAssets.title : assets.title, - }); -}; - -registerEvent("saveGameShopAssets", saveGameShopAssets); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts deleted file mode 100644 index 8b22101d..00000000 --- a/src/main/events/catalogue/search-games.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CatalogueSearchPayload } from "@types"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const searchGames = async ( - _event: Electron.IpcMainInvokeEvent, - payload: CatalogueSearchPayload, - take: number, - skip: number -) => { - return HydraApi.post( - "/catalogue/search", - { ...payload, take, skip }, - { needsAuth: false } - ); -}; - -registerEvent("searchGames", searchGames); diff --git a/src/main/events/cloud-save/delete-game-artifact.ts b/src/main/events/cloud-save/delete-game-artifact.ts deleted file mode 100644 index e293bc56..00000000 --- a/src/main/events/cloud-save/delete-game-artifact.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const deleteGameArtifact = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string -) => - HydraApi.delete<{ ok: boolean }>( - `/profile/games/artifacts/${gameArtifactId}` - ); - -registerEvent("deleteGameArtifact", deleteGameArtifact); diff --git a/src/main/events/cloud-save/get-game-artifacts.ts b/src/main/events/cloud-save/get-game-artifacts.ts deleted file mode 100644 index 3fa8552c..00000000 --- a/src/main/events/cloud-save/get-game-artifacts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; -import type { GameArtifact, GameShop } from "@types"; -import { SubscriptionRequiredError, UserNotLoggedInError } from "@shared"; - -const getGameArtifacts = async ( - _event: Electron.IpcMainInvokeEvent, - objectId: string, - shop: GameShop -) => { - const params = new URLSearchParams({ - objectId, - shop, - }); - - return HydraApi.get( - `/profile/games/artifacts?${params.toString()}`, - {}, - { needsSubscription: true } - ).catch((err) => { - if (err instanceof SubscriptionRequiredError) { - return []; - } - - if (err instanceof UserNotLoggedInError) { - return []; - } - - throw err; - }); -}; - -registerEvent("getGameArtifacts", getGameArtifacts); diff --git a/src/main/events/cloud-save/rename-game-artifact.ts b/src/main/events/cloud-save/rename-game-artifact.ts deleted file mode 100644 index f8257c4b..00000000 --- a/src/main/events/cloud-save/rename-game-artifact.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const renameGameArtifact = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string, - label: string -) => { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}`, { - label, - }); -}; - -registerEvent("renameGameArtifact", renameGameArtifact); diff --git a/src/main/events/cloud-save/toggle-artifact-freeze.ts b/src/main/events/cloud-save/toggle-artifact-freeze.ts deleted file mode 100644 index d532d459..00000000 --- a/src/main/events/cloud-save/toggle-artifact-freeze.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const toggleArtifactFreeze = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string, - freeze: boolean -) => { - if (freeze) { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/freeze`); - } else { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/unfreeze`); - } -}; - -registerEvent("toggleArtifactFreeze", toggleArtifactFreeze); diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts new file mode 100644 index 00000000..bea009cb --- /dev/null +++ b/src/main/events/download-sources/add-download-source.ts @@ -0,0 +1,50 @@ +import { registerEvent } from "../register-event"; +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 +) => { + try { + const existingSources = await downloadSourcesSublevel.values().all(); + const urlExists = existingSources.some((source) => source.url === url); + + if (urlExists) { + throw new Error("Download source with this URL already exists"); + } + + const downloadSource = await HydraApi.post( + "/download-sources", + { + url, + }, + { 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); + } + } + + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + return downloadSource; + } catch (error) { + logger.error("Failed to add download source:", error); + throw error; + } +}; + +registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/create-download-sources.ts b/src/main/events/download-sources/create-download-sources.ts deleted file mode 100644 index cf1f8f51..00000000 --- a/src/main/events/download-sources/create-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const createDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent, - urls: string[] -) => { - await HydraApi.post("/profile/download-sources", { - urls, - }); -}; - -registerEvent("createDownloadSources", createDownloadSources); 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/put-download-source.ts b/src/main/events/download-sources/put-download-source.ts deleted file mode 100644 index 72297059..00000000 --- a/src/main/events/download-sources/put-download-source.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const putDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - objectIds: string[] -) => { - return HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds, - }, - { needsAuth: false } - ); -}; - -registerEvent("putDownloadSource", putDownloadSource); 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.ts b/src/main/events/download-sources/sync-download-sources.ts new file mode 100644 index 00000000..68a6be3f --- /dev/null +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -0,0 +1,29 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; + +const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { + const downloadSources = await downloadSourcesSublevel.values().all(); + + const response = await HydraApi.post( + "/download-sources/sync", + { + ids: downloadSources.map((downloadSource) => downloadSource.id), + }, + { needsAuth: false } + ); + + for (const downloadSource of response) { + const existingDownloadSource = downloadSources.find( + (source) => source.id === downloadSource.id + ); + + await downloadSourcesSublevel.put(downloadSource.id, { + ...existingDownloadSource, + ...downloadSource, + }); + } +}; + +registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/hardware/get-disk-free-space.ts b/src/main/events/hardware/get-disk-free-space.ts index b5ac86e3..b54431eb 100644 --- a/src/main/events/hardware/get-disk-free-space.ts +++ b/src/main/events/hardware/get-disk-free-space.ts @@ -1,10 +1,13 @@ -import disk from "diskusage"; - +import { DiskUsage } from "@types"; import { registerEvent } from "../register-event"; +import checkDiskSpace from "check-disk-space"; const getDiskFreeSpace = async ( _event: Electron.IpcMainInvokeEvent, path: string -) => disk.check(path); +): Promise => { + const result = await checkDiskSpace(path); + return { free: result.free, total: result.size }; +}; registerEvent("getDiskFreeSpace", getDiskFreeSpace); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 713ce581..0ab5499a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,16 +1,9 @@ import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { ipcMain } from "electron"; -import "./catalogue/get-catalogue"; import "./catalogue/get-game-shop-details"; -import "./catalogue/save-game-shop-assets"; -import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; -import "./catalogue/search-games"; import "./catalogue/get-game-stats"; -import "./catalogue/get-trending-games"; -import "./catalogue/get-publishers"; -import "./catalogue/get-developers"; import "./hardware/get-disk-free-space"; import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; @@ -46,13 +39,15 @@ import "./library/copy-custom-game-asset"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; -import "./misc/get-features"; import "./misc/show-item-in-folder"; -import "./misc/get-badges"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; import "./misc/save-temp-file"; import "./misc/delete-temp-file"; +import "./misc/install-hydra-decky-plugin"; +import "./misc/get-hydra-decky-plugin-info"; +import "./misc/check-homebrew-folder-exists"; +import "./misc/hydra-api-call"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; @@ -66,39 +61,23 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; -import "./user-preferences/authenticate-all-debrid"; import "./user-preferences/authenticate-torbox"; -import "./download-sources/put-download-source"; +import "./download-sources/add-download-source"; +import "./download-sources/sync-download-sources"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; -import "./user/get-user"; -import "./user/get-user-library"; -import "./user/get-blocked-users"; -import "./user/block-user"; -import "./user/unblock-user"; -import "./user/get-user-friends"; import "./user/get-auth"; -import "./user/get-user-stats"; -import "./user/report-user"; import "./user/get-unlocked-achievements"; import "./user/get-compared-unlocked-achievements"; -import "./profile/get-friend-requests"; import "./profile/get-me"; -import "./profile/undo-friendship"; -import "./profile/update-friend-request"; import "./profile/update-profile"; import "./profile/process-profile-image"; -import "./profile/send-friend-request"; import "./profile/sync-friend-requests"; import "./cloud-save/download-game-artifact"; -import "./cloud-save/get-game-artifacts"; import "./cloud-save/get-game-backup-preview"; import "./cloud-save/upload-save-game"; -import "./cloud-save/delete-game-artifact"; import "./cloud-save/select-game-backup-path"; -import "./cloud-save/toggle-artifact-freeze"; -import "./cloud-save/rename-game-artifact"; import "./notifications/publish-new-repacks-notification"; import "./notifications/update-achievement-notification-window"; import "./notifications/show-achievement-test-notification"; @@ -112,7 +91,6 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; -import "./download-sources/create-download-sources"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; 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 47fd3436..6a90087e 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -1,6 +1,6 @@ import { registerEvent } from "../register-event"; import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; -import { randomUUID } from "crypto"; +import { randomUUID } from "node:crypto"; import type { GameShop } from "@types"; const addCustomGameToLibrary = async ( @@ -27,6 +27,7 @@ const addCustomGameToLibrary = async ( } const assets = { + updatedAt: Date.now(), objectId, shop, title, @@ -36,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/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts index 22490c07..d1d77e9f 100644 --- a/src/main/events/library/cleanup-unused-assets.ts +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise => { }; const getUsedAssetPaths = async (): Promise> => { - // Get all custom games from the level database const { gamesSublevel } = await import("@main/level"); const allGames = await gamesSublevel.iterator().all(); @@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise> => { const usedPaths = new Set(); customGames.forEach((game) => { - // Extract file paths from local URLs if (game.iconUrl?.startsWith("local:")) { usedPaths.add(game.iconUrl.replace("local:", "")); } diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts index 07c3d6f7..1f5aea0f 100644 --- a/src/main/events/library/copy-custom-game-asset.ts +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -1,7 +1,7 @@ import { registerEvent } from "../register-event"; import fs from "node:fs"; import path from "node:path"; -import { randomUUID } from "crypto"; +import { randomUUID } from "node:crypto"; import { ASSETS_PATH } from "@main/constants"; const copyCustomGameAsset = async ( @@ -13,29 +13,23 @@ const copyCustomGameAsset = async ( throw new Error("Source file does not exist"); } - // Ensure assets directory exists if (!fs.existsSync(ASSETS_PATH)) { fs.mkdirSync(ASSETS_PATH, { recursive: true }); } - // Create custom games assets subdirectory const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); if (!fs.existsSync(customGamesAssetsPath)) { fs.mkdirSync(customGamesAssetsPath, { recursive: true }); } - // Get file extension const fileExtension = path.extname(sourcePath); - // Generate unique filename const uniqueId = randomUUID(); const fileName = `${assetType}-${uniqueId}${fileExtension}`; const destinationPath = path.join(customGamesAssetsPath, fileName); - // Copy the file await fs.promises.copyFile(sourcePath, destinationPath); - // Return the local URL format return `local:${destinationPath}`; }; diff --git a/src/main/events/library/create-steam-shortcut.ts b/src/main/events/library/create-steam-shortcut.ts index f83dd675..d5434d7f 100644 --- a/src/main/events/library/create-steam-shortcut.ts +++ b/src/main/events/library/create-steam-shortcut.ts @@ -1,12 +1,11 @@ import { registerEvent } from "../register-event"; -import type { GameShop, GameStats } from "@types"; +import type { GameShop, ShopAssets } from "@types"; import { gamesSublevel, levelKeys } from "@main/level"; import { composeSteamShortcut, getSteamLocation, getSteamShortcuts, getSteamUsersIds, - HydraApi, logger, SystemPath, writeSteamShortcuts, @@ -15,6 +14,7 @@ import fs from "node:fs"; import axios from "axios"; import path from "node:path"; import { ASSETS_PATH } from "@main/constants"; +import { getGameAssets } from "../catalogue/get-game-assets"; const downloadAsset = async (downloadPath: string, url?: string | null) => { try { @@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => { const downloadAssetsFromSteam = async ( shop: GameShop, objectId: string, - assets: GameStats["assets"] + assets: ShopAssets | null ) => { const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`); @@ -86,9 +86,7 @@ const createSteamShortcut = async ( throw new Error("No executable path found for game"); } - const { assets } = await HydraApi.get( - `/games/${shop}/${objectId}/stats` - ); + const assets = await getGameAssets(objectId, shop); const steamUserIds = await getSteamUsersIds(); 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/misc/check-homebrew-folder-exists.ts b/src/main/events/misc/check-homebrew-folder-exists.ts new file mode 100644 index 00000000..32e09754 --- /dev/null +++ b/src/main/events/misc/check-homebrew-folder-exists.ts @@ -0,0 +1,13 @@ +import { registerEvent } from "../register-event"; +import { DECKY_PLUGINS_LOCATION } from "@main/constants"; +import fs from "node:fs"; +import path from "node:path"; + +const checkHomebrewFolderExists = async ( + _event: Electron.IpcMainInvokeEvent +): Promise => { + const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION); + return fs.existsSync(homebrewPath); +}; + +registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists); diff --git a/src/main/events/misc/get-badges.ts b/src/main/events/misc/get-badges.ts deleted file mode 100644 index c1d62782..00000000 --- a/src/main/events/misc/get-badges.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Badge } from "@types"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { db, levelKeys } from "@main/level"; - -const getBadges = async (_event: Electron.IpcMainInvokeEvent) => { - const language = await db - .get(levelKeys.language, { - valueEncoding: "utf8", - }) - .then((language) => language || "en"); - - const params = new URLSearchParams({ - locale: language, - }); - - return HydraApi.get(`/badges?${params.toString()}`, null, { - needsAuth: false, - }); -}; - -registerEvent("getBadges", getBadges); diff --git a/src/main/events/misc/get-features.ts b/src/main/events/misc/get-features.ts deleted file mode 100644 index 766c84aa..00000000 --- a/src/main/events/misc/get-features.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get("/features", null, { needsAuth: false }); -}; - -registerEvent("getFeatures", getFeatures); diff --git a/src/main/events/misc/get-hydra-decky-plugin-info.ts b/src/main/events/misc/get-hydra-decky-plugin-info.ts new file mode 100644 index 00000000..430bd691 --- /dev/null +++ b/src/main/events/misc/get-hydra-decky-plugin-info.ts @@ -0,0 +1,94 @@ +import { registerEvent } from "../register-event"; +import { logger, HydraApi } from "@main/services"; +import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants"; +import fs from "node:fs"; +import path from "node:path"; + +interface DeckyReleaseInfo { + version: string; + downloadUrl: string; +} + +const getHydraDeckyPluginInfo = async ( + _event: Electron.IpcMainInvokeEvent +): Promise<{ + installed: boolean; + version: string | null; + path: string; + outdated: boolean; + expectedVersion: string | null; +}> => { + try { + // Fetch the expected version from API + let expectedVersion: string | null = null; + try { + const releaseInfo = await HydraApi.get( + "/decky/release", + {}, + { needsAuth: false } + ); + expectedVersion = releaseInfo.version; + } catch (error) { + logger.error("Failed to fetch Decky release info:", error); + } + + // Check if plugin folder exists + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + logger.log("Hydra Decky plugin not installed"); + return { + installed: false, + version: null, + path: HYDRA_DECKY_PLUGIN_LOCATION, + outdated: true, + expectedVersion, + }; + } + + // Check if package.json exists + const packageJsonPath = path.join( + HYDRA_DECKY_PLUGIN_LOCATION, + "package.json" + ); + + if (!fs.existsSync(packageJsonPath)) { + logger.log("Hydra Decky plugin package.json not found"); + return { + installed: false, + version: null, + path: HYDRA_DECKY_PLUGIN_LOCATION, + outdated: true, + expectedVersion, + }; + } + + // Read and parse package.json + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + const version = packageJson.version; + + const outdated = expectedVersion ? version !== expectedVersion : false; + + logger.log( + `Hydra Decky plugin installed, version: ${version}, expected: ${expectedVersion}, outdated: ${outdated}` + ); + + return { + installed: true, + version, + path: HYDRA_DECKY_PLUGIN_LOCATION, + outdated, + expectedVersion, + }; + } catch (error) { + logger.error("Failed to get plugin info:", error); + return { + installed: false, + version: null, + path: HYDRA_DECKY_PLUGIN_LOCATION, + outdated: true, + expectedVersion: null, + }; + } +}; + +registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo); diff --git a/src/main/events/misc/hydra-api-call.ts b/src/main/events/misc/hydra-api-call.ts new file mode 100644 index 00000000..3b5f78ec --- /dev/null +++ b/src/main/events/misc/hydra-api-call.ts @@ -0,0 +1,38 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +interface HydraApiCallPayload { + method: "get" | "post" | "put" | "patch" | "delete"; + url: string; + data?: unknown; + params?: unknown; + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + }; +} + +const hydraApiCall = async ( + _event: Electron.IpcMainInvokeEvent, + payload: HydraApiCallPayload +) => { + const { method, url, data, params, options } = payload; + + switch (method) { + case "get": + return HydraApi.get(url, params, options); + case "post": + return HydraApi.post(url, data, options); + case "put": + return HydraApi.put(url, data, options); + case "patch": + return HydraApi.patch(url, data, options); + case "delete": + return HydraApi.delete(url, options); + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } +}; + +registerEvent("hydraApiCall", hydraApiCall); diff --git a/src/main/events/misc/install-hydra-decky-plugin.ts b/src/main/events/misc/install-hydra-decky-plugin.ts new file mode 100644 index 00000000..e14ea2ed --- /dev/null +++ b/src/main/events/misc/install-hydra-decky-plugin.ts @@ -0,0 +1,50 @@ +import { registerEvent } from "../register-event"; +import { logger, DeckyPlugin } from "@main/services"; +import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants"; + +const installHydraDeckyPlugin = async ( + _event: Electron.IpcMainInvokeEvent +): Promise<{ + success: boolean; + path: string; + currentVersion: string | null; + expectedVersion: string; + error?: string; +}> => { + try { + logger.log("Installing/updating Hydra Decky plugin..."); + + const result = await DeckyPlugin.checkPluginVersion(); + + if (result.exists && !result.outdated) { + logger.log("Plugin installed successfully"); + return { + success: true, + path: HYDRA_DECKY_PLUGIN_LOCATION, + currentVersion: result.currentVersion, + expectedVersion: result.expectedVersion, + }; + } else { + logger.error("Failed to install plugin"); + return { + success: false, + path: HYDRA_DECKY_PLUGIN_LOCATION, + currentVersion: result.currentVersion, + expectedVersion: result.expectedVersion, + error: "Plugin installation failed", + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("Failed to install plugin:", error); + return { + success: false, + path: HYDRA_DECKY_PLUGIN_LOCATION, + currentVersion: null, + expectedVersion: "unknown", + error: errorMessage, + }; + } +}; + +registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin); diff --git a/src/main/events/profile/get-friend-requests.ts b/src/main/events/profile/get-friend-requests.ts deleted file mode 100644 index 39573b67..00000000 --- a/src/main/events/profile/get-friend-requests.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { FriendRequest } from "@types"; - -const getFriendRequests = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - return HydraApi.get(`/profile/friend-requests`).catch(() => []); -}; - -registerEvent("getFriendRequests", getFriendRequests); diff --git a/src/main/events/profile/send-friend-request.ts b/src/main/events/profile/send-friend-request.ts deleted file mode 100644 index d696606f..00000000 --- a/src/main/events/profile/send-friend-request.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const sendFriendRequest = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - return HydraApi.post("/profile/friend-requests", { friendCode: userId }); -}; - -registerEvent("sendFriendRequest", sendFriendRequest); diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts deleted file mode 100644 index 371bc5cc..00000000 --- a/src/main/events/profile/undo-friendship.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const undoFriendship = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.delete(`/profile/friends/${userId}`); -}; - -registerEvent("undoFriendship", undoFriendship); diff --git a/src/main/events/profile/update-friend-request.ts b/src/main/events/profile/update-friend-request.ts deleted file mode 100644 index b265f88c..00000000 --- a/src/main/events/profile/update-friend-request.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { FriendRequestAction } from "@types"; - -const updateFriendRequest = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - action: FriendRequestAction -) => { - if (action == "CANCEL") { - return HydraApi.delete(`/profile/friend-requests/${userId}`); - } - - return HydraApi.patch(`/profile/friend-requests/${userId}`, { - requestState: action, - }); -}; - -registerEvent("updateFriendRequest", updateFriendRequest); diff --git a/src/main/events/user-preferences/authenticate-all-debrid.ts b/src/main/events/user-preferences/authenticate-all-debrid.ts deleted file mode 100644 index 713db965..00000000 --- a/src/main/events/user-preferences/authenticate-all-debrid.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AllDebridClient } from "@main/services/download/all-debrid"; -import { registerEvent } from "../register-event"; - -const authenticateAllDebrid = async ( - _event: Electron.IpcMainInvokeEvent, - apiKey: string -) => { - AllDebridClient.authorize(apiKey); - const result = await AllDebridClient.getUser(); - if ("error_code" in result) { - return { error_code: result.error_code }; - } - - return result.user; -}; - -registerEvent("authenticateAllDebrid", authenticateAllDebrid); diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts deleted file mode 100644 index c81231e5..00000000 --- a/src/main/events/user/block-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const blockUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.post(`/users/${userId}/block`); -}; - -registerEvent("blockUser", blockUser); diff --git a/src/main/events/user/get-blocked-users.ts b/src/main/events/user/get-blocked-users.ts deleted file mode 100644 index 9696cd7b..00000000 --- a/src/main/events/user/get-blocked-users.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { UserNotLoggedInError } from "@shared"; -import type { UserBlocks } from "@types"; - -export const getBlockedUsers = async ( - _event: Electron.IpcMainInvokeEvent, - take: number, - skip: number -): Promise => { - return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => { - if (err instanceof UserNotLoggedInError) { - return { blocks: [] }; - } - throw err; - }); -}; - -registerEvent("getBlockedUsers", getBlockedUsers); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts deleted file mode 100644 index aefc7052..00000000 --- a/src/main/events/user/get-user-friends.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { db } from "@main/level"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { User, UserFriends } from "@types"; -import { levelKeys } from "@main/level/sublevels"; - -export const getUserFriends = async ( - userId: string, - take: number, - skip: number -): Promise => { - const user = await db.get(levelKeys.user, { - valueEncoding: "json", - }); - - if (user?.id === userId) { - return HydraApi.get(`/profile/friends`, { take, skip }); - } - - return HydraApi.get(`/users/${userId}/friends`, { take, skip }); -}; - -const getUserFriendsEvent = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - take: number, - skip: number -) => { - return getUserFriends(userId, take, skip); -}; - -registerEvent("getUserFriends", getUserFriendsEvent); diff --git a/src/main/events/user/get-user-library.ts b/src/main/events/user/get-user-library.ts deleted file mode 100644 index f3c3eed5..00000000 --- a/src/main/events/user/get-user-library.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserLibraryResponse } from "@types"; - -const getUserLibrary = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - take: number = 12, - skip: number = 0, - sortBy?: string -): Promise => { - const params = new URLSearchParams(); - - params.append("take", take.toString()); - params.append("skip", skip.toString()); - - if (sortBy) { - params.append("sortBy", sortBy); - } - - const queryString = params.toString(); - const baseUrl = `/users/${userId}/library`; - const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; - - return HydraApi.get(url).catch(() => null); -}; - -registerEvent("getUserLibrary", getUserLibrary); diff --git a/src/main/events/user/get-user-stats.ts b/src/main/events/user/get-user-stats.ts deleted file mode 100644 index f88a4f12..00000000 --- a/src/main/events/user/get-user-stats.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserStats } from "@types"; - -export const getUserStats = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -): Promise => { - return HydraApi.get(`/users/${userId}/stats`); -}; - -registerEvent("getUserStats", getUserStats); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts deleted file mode 100644 index fe77a7c1..00000000 --- a/src/main/events/user/get-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserProfile } from "@types"; - -const getUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -): Promise => { - return HydraApi.get(`/users/${userId}`).catch(() => null); -}; - -registerEvent("getUser", getUser); diff --git a/src/main/events/user/report-user.ts b/src/main/events/user/report-user.ts deleted file mode 100644 index 1e8efbaa..00000000 --- a/src/main/events/user/report-user.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -export const reportUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - reason: string, - description: string -): Promise => { - return HydraApi.post(`/users/${userId}/report`, { - reason, - description, - }); -}; - -registerEvent("reportUser", reportUser); diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts deleted file mode 100644 index c604a0b5..00000000 --- a/src/main/events/user/unblock-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const unblockUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.post(`/users/${userId}/unblock`); -}; - -registerEvent("unblockUser", unblockUser); 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/index.ts b/src/main/index.ts index 106feaf0..65e20144 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { clearGamesPlaytime, WindowManager, Lock, + Aria2, } from "@main/services"; import resources from "@locales"; import { PythonRPC } from "./services/python-rpc"; @@ -222,6 +223,7 @@ app.on("before-quit", async (e) => { e.preventDefault(); /* Disconnects libtorrent */ PythonRPC.kill(); + Aria2.kill(); await clearGamesPlaytime(); canAppBeClosed = true; app.quit(); diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts new file mode 100644 index 00000000..b6cdad0b --- /dev/null +++ b/src/main/level/sublevels/download-sources.ts @@ -0,0 +1,10 @@ +import { db } from "../level"; +import { levelKeys } from "./keys"; +import type { DownloadSource } from "@types"; + +export const downloadSourcesSublevel = db.sublevel( + levelKeys.downloadSources, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/game-shop-assets.ts b/src/main/level/sublevels/game-shop-assets.ts index 561d85df..806e041f 100644 --- a/src/main/level/sublevels/game-shop-assets.ts +++ b/src/main/level/sublevels/game-shop-assets.ts @@ -3,9 +3,9 @@ import type { ShopAssets } from "@types"; import { db } from "../level"; import { levelKeys } from "./keys"; -export const gamesShopAssetsSublevel = db.sublevel( - levelKeys.gameShopAssets, - { - valueEncoding: "json", - } -); +export const gamesShopAssetsSublevel = db.sublevel< + string, + ShopAssets & { updatedAt: number } +>(levelKeys.gameShopAssets, { + valueEncoding: "json", +}); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index f78f09b8..3619ae26 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -6,3 +6,4 @@ export * from "./game-stats-cache"; export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; +export * from "./download-sources"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index bba35169..a28690b2 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -17,4 +17,5 @@ export const levelKeys = { language: "language", screenState: "screenState", rpcPassword: "rpcPassword", + downloadSources: "downloadSources", }; diff --git a/src/main/main.ts b/src/main/main.ts index 67391057..ffb8f8a9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,7 +8,6 @@ import { CommonRedistManager, TorBoxClient, RealDebridClient, - AllDebridClient, Aria2, DownloadManager, HydraApi, @@ -16,7 +15,9 @@ import { startMainLoop, Ludusavi, Lock, + DeckyPlugin, } from "@main/services"; +import { migrateDownloadSources } from "./helpers/migrate-download-sources"; export const loadState = async () => { await Lock.acquireLock(); @@ -38,10 +39,6 @@ export const loadState = async () => { RealDebridClient.authorize(userPreferences.realDebridApiToken); } - if (userPreferences?.allDebridApiKey) { - AllDebridClient.authorize(userPreferences.allDebridApiKey); - } - if (userPreferences?.torBoxApiToken) { TorBoxClient.authorize(userPreferences.torBoxApiToken); } @@ -49,8 +46,16 @@ export const loadState = async () => { Ludusavi.copyConfigFileToUserData(); Ludusavi.copyBinaryToUserData(); - await HydraApi.setupApi().then(() => { + if (process.platform === "linux") { + DeckyPlugin.checkAndUpdateIfOutdated(); + } + + await HydraApi.setupApi().then(async () => { uploadGamesBatch(); + void migrateDownloadSources(); + + const { syncDownloadSourcesFromApi } = await import("./services/user"); + void syncDownloadSourcesFromApi(); // WSClient.connect(); }); diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index 72b49bc5..69437801 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -5,15 +5,18 @@ import { logger } from "../logger"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { AxiosError } from "axios"; -const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60; // 1 hour - const getModifiedSinceHeader = ( - cachedAchievements: GameAchievement | undefined + cachedAchievements: GameAchievement | undefined, + userLanguage: string ): Date | undefined => { if (!cachedAchievements) { return undefined; } + if (userLanguage != cachedAchievements.language) { + return undefined; + } + return cachedAchievements.updatedAt ? new Date(cachedAchievements.updatedAt) : undefined; @@ -24,17 +27,15 @@ export const getGameAchievementData = async ( shop: GameShop, useCachedData: boolean ) => { + if (shop === "custom") { + return []; + } + const gameKey = levelKeys.game(shop, objectId); const cachedAchievements = await gameAchievementsSublevel.get(gameKey); - if (cachedAchievements?.achievements && useCachedData) - return cachedAchievements.achievements; - - if ( - cachedAchievements?.achievements && - Date.now() < (cachedAchievements.updatedAt ?? 0) + LOCAL_CACHE_EXPIRATION - ) { + if (cachedAchievements?.achievements && useCachedData) { return cachedAchievements.achievements; } @@ -50,14 +51,15 @@ export const getGameAchievementData = async ( language, }, { - ifModifiedSince: getModifiedSinceHeader(cachedAchievements), + ifModifiedSince: getModifiedSinceHeader(cachedAchievements, language), } ) .then(async (achievements) => { await gameAchievementsSublevel.put(gameKey, { unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], achievements, - updatedAt: Date.now() + LOCAL_CACHE_EXPIRATION, + updatedAt: Date.now(), + language, }); return achievements; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index f2ea03ac..804f5933 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -37,6 +37,7 @@ const saveAchievementsOnLocal = async ( achievements: gameAchievement?.achievements ?? [], unlockedAchievements: unlockedAchievements, updatedAt: gameAchievement?.updatedAt, + language: gameAchievement?.language, }); if (!sendUpdateEvent) return; diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index c6b97b9f..f6835558 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -1,6 +1,7 @@ import path from "node:path"; import cp from "node:child_process"; import { app } from "electron"; +import { logger } from "./logger"; export class Aria2 { private static process: cp.ChildProcess | null = null; @@ -23,6 +24,9 @@ export class Aria2 { } public static kill() { - this.process?.kill(); + if (this.process) { + logger.log("Killing aria2 process"); + this.process.kill(); + } } } diff --git a/src/main/services/cloud-sync.ts b/src/main/services/cloud-sync.ts index 6da24ce1..200a5ee3 100644 --- a/src/main/services/cloud-sync.ts +++ b/src/main/services/cloud-sync.ts @@ -80,7 +80,7 @@ export class CloudSync { try { await fs.promises.rm(backupPath, { recursive: true }); } catch (error) { - logger.error("Failed to remove backup path", error); + logger.error("Failed to remove backup path", { backupPath, error }); } } @@ -163,7 +163,7 @@ export class CloudSync { try { await fs.promises.unlink(bundleLocation); } catch (error) { - logger.error("Failed to remove tar file", error); + logger.error("Failed to remove tar file", { bundleLocation, error }); } } } diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts new file mode 100644 index 00000000..4dc1fdad --- /dev/null +++ b/src/main/services/decky-plugin.ts @@ -0,0 +1,400 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import axios from "axios"; +import sudo from "sudo-prompt"; +import { app } from "electron"; +import { + HYDRA_DECKY_PLUGIN_LOCATION, + DECKY_PLUGINS_LOCATION, +} from "@main/constants"; +import { logger } from "./logger"; +import { SevenZip } from "./7zip"; +import { SystemPath } from "./system-path"; +import { HydraApi } from "./hydra-api"; + +interface DeckyReleaseInfo { + version: string; + downloadUrl: string; +} + +export class DeckyPlugin { + private static releaseInfo: DeckyReleaseInfo | null = null; + + private static async getDeckyReleaseInfo(): Promise { + if (this.releaseInfo) { + return this.releaseInfo; + } + + try { + const response = await HydraApi.get( + "/decky/release", + {}, + { needsAuth: false } + ); + + this.releaseInfo = response; + return response; + } catch (error) { + logger.error("Failed to fetch Decky release info:", error); + throw error; + } + } + + private static getPackageJsonPath(): string { + return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json"); + } + + private static async downloadPlugin(): Promise { + logger.log("Downloading Hydra Decky plugin..."); + + const releaseInfo = await this.getDeckyReleaseInfo(); + const tempDir = SystemPath.getPath("temp"); + const zipPath = path.join(tempDir, "Hydra.zip"); + + const response = await axios.get(releaseInfo.downloadUrl, { + responseType: "arraybuffer", + }); + + await fs.promises.writeFile(zipPath, response.data); + logger.log(`Plugin downloaded to: ${zipPath}`); + + return zipPath; + } + + private static async extractPlugin(zipPath: string): Promise { + logger.log("Extracting Hydra Decky plugin..."); + + const tempDir = SystemPath.getPath("temp"); + const extractPath = path.join(tempDir, "hydra-decky-plugin"); + + if (fs.existsSync(extractPath)) { + await fs.promises.rm(extractPath, { recursive: true, force: true }); + } + + await fs.promises.mkdir(extractPath, { recursive: true }); + + return new Promise((resolve, reject) => { + SevenZip.extractFile( + { + filePath: zipPath, + outputPath: extractPath, + }, + () => { + logger.log(`Plugin extracted to: ${extractPath}`); + resolve(extractPath); + }, + () => { + reject(new Error("Failed to extract plugin")); + } + ); + }); + } + + private static needsSudo(): boolean { + try { + if (fs.existsSync(DECKY_PLUGINS_LOCATION)) { + fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK); + return false; + } + + const parentDir = path.dirname(DECKY_PLUGINS_LOCATION); + if (fs.existsSync(parentDir)) { + fs.accessSync(parentDir, fs.constants.W_OK); + return false; + } + + return true; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error.code === "EACCES" || error.code === "EPERM") + ) { + return true; + } + throw error; + } + } + + private static async installPluginWithSudo( + extractPath: string + ): Promise { + logger.log("Installing plugin with sudo..."); + + const username = os.userInfo().username; + const sourcePath = path.join(extractPath, "Hydra"); + + return new Promise((resolve, reject) => { + const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`; + + sudo.exec( + command, + { name: app.getName() }, + (sudoError, _stdout, stderr) => { + if (sudoError) { + logger.error("Failed to install plugin with sudo:", sudoError); + reject(sudoError); + } else { + logger.log("Plugin installed successfully with sudo"); + if (stderr) { + logger.log("Sudo stderr:", stderr); + } + resolve(); + } + } + ); + }); + } + + private static async installPluginWithoutSudo( + extractPath: string + ): Promise { + logger.log("Installing plugin without sudo..."); + + const sourcePath = path.join(extractPath, "Hydra"); + + if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) { + await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true }); + } + + if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, { + recursive: true, + force: true, + }); + } + + await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, { + recursive: true, + }); + + logger.log("Plugin installed successfully"); + } + + private static async installPlugin(extractPath: string): Promise { + if (this.needsSudo()) { + await this.installPluginWithSudo(extractPath); + } else { + await this.installPluginWithoutSudo(extractPath); + } + } + + private static async updatePlugin(): Promise { + let zipPath: string | null = null; + let extractPath: string | null = null; + + try { + zipPath = await this.downloadPlugin(); + extractPath = await this.extractPlugin(zipPath); + await this.installPlugin(extractPath); + + logger.log("Plugin update completed successfully"); + } catch (error) { + logger.error("Failed to update plugin:", error); + throw error; + } finally { + if (zipPath) { + try { + await fs.promises.rm(zipPath, { force: true }); + logger.log("Cleaned up downloaded zip file"); + } catch (cleanupError) { + logger.error("Failed to clean up zip file:", cleanupError); + } + } + + if (extractPath) { + try { + await fs.promises.rm(extractPath, { recursive: true, force: true }); + logger.log("Cleaned up extraction directory"); + } catch (cleanupError) { + logger.error( + "Failed to clean up extraction directory:", + cleanupError + ); + } + } + } + } + + public static async checkAndUpdateIfOutdated(): Promise { + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + logger.log("Hydra Decky plugin not installed, skipping update check"); + return; + } + + const packageJsonPath = this.getPackageJsonPath(); + + try { + if (!fs.existsSync(packageJsonPath)) { + logger.log( + "Hydra Decky plugin package.json not found, skipping update" + ); + return; + } + + const releaseInfo = await this.getDeckyReleaseInfo(); + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + const currentVersion = packageJson.version; + const isOutdated = currentVersion !== releaseInfo.version; + + if (isOutdated) { + logger.log( + `Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}. Updating...` + ); + + await this.updatePlugin(); + logger.log("Hydra Decky plugin updated successfully"); + } else { + logger.log(`Hydra Decky plugin is up to date (${currentVersion})`); + } + } catch (error) { + logger.error(`Error checking/updating Hydra Decky plugin: ${error}`); + } + } + + public static async checkPluginVersion(): Promise<{ + exists: boolean; + outdated: boolean; + currentVersion: string | null; + expectedVersion: string; + }> { + try { + const releaseInfo = await this.getDeckyReleaseInfo(); + + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + logger.log("Hydra Decky plugin folder not found, installing..."); + + try { + await this.updatePlugin(); + + // Read the actual installed version from package.json + const packageJsonPath = this.getPackageJsonPath(); + if (fs.existsSync(packageJsonPath)) { + const packageJsonContent = fs.readFileSync( + packageJsonPath, + "utf-8" + ); + const packageJson = JSON.parse(packageJsonContent); + return { + exists: true, + outdated: false, + currentVersion: packageJson.version, + expectedVersion: releaseInfo.version, + }; + } + + return { + exists: true, + outdated: false, + currentVersion: releaseInfo.version, + expectedVersion: releaseInfo.version, + }; + } catch (error) { + logger.error("Failed to install plugin:", error); + return { + exists: false, + outdated: true, + currentVersion: null, + expectedVersion: releaseInfo.version, + }; + } + } + + const packageJsonPath = this.getPackageJsonPath(); + + try { + if (!fs.existsSync(packageJsonPath)) { + logger.log( + "Hydra Decky plugin package.json not found, installing..." + ); + + await this.updatePlugin(); + + // Read the actual installed version from package.json + if (fs.existsSync(packageJsonPath)) { + const packageJsonContent = fs.readFileSync( + packageJsonPath, + "utf-8" + ); + const packageJson = JSON.parse(packageJsonContent); + return { + exists: true, + outdated: false, + currentVersion: packageJson.version, + expectedVersion: releaseInfo.version, + }; + } + + return { + exists: true, + outdated: false, + currentVersion: releaseInfo.version, + expectedVersion: releaseInfo.version, + }; + } + + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + const currentVersion = packageJson.version; + const isOutdated = currentVersion !== releaseInfo.version; + + if (isOutdated) { + logger.log( + `Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}` + ); + + await this.updatePlugin(); + + if (fs.existsSync(packageJsonPath)) { + const updatedPackageJsonContent = fs.readFileSync( + packageJsonPath, + "utf-8" + ); + const updatedPackageJson = JSON.parse(updatedPackageJsonContent); + return { + exists: true, + outdated: false, + currentVersion: updatedPackageJson.version, + expectedVersion: releaseInfo.version, + }; + } + + return { + exists: true, + outdated: false, + currentVersion: releaseInfo.version, + expectedVersion: releaseInfo.version, + }; + } else { + logger.log(`Hydra Decky plugin is up to date (${currentVersion})`); + } + + return { + exists: true, + outdated: isOutdated, + currentVersion, + expectedVersion: releaseInfo.version, + }; + } catch (error) { + logger.error(`Error checking Hydra Decky plugin version: ${error}`); + return { + exists: false, + outdated: true, + currentVersion: null, + expectedVersion: releaseInfo.version, + }; + } + } catch (error) { + logger.error(`Error fetching release info: ${error}`); + return { + exists: false, + outdated: true, + currentVersion: null, + expectedVersion: "unknown", + }; + } + } +} diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts deleted file mode 100644 index 05ee56c6..00000000 --- a/src/main/services/download/all-debrid.ts +++ /dev/null @@ -1,315 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import type { AllDebridUser } from "@types"; -import { logger } from "@main/services"; - -interface AllDebridMagnetStatus { - id: number; - filename: string; - size: number; - status: string; - statusCode: number; - downloaded: number; - uploaded: number; - seeders: number; - downloadSpeed: number; - uploadSpeed: number; - uploadDate: number; - completionDate: number; - links: Array<{ - link: string; - filename: string; - size: number; - }>; -} - -interface AllDebridError { - code: string; - message: string; -} - -interface AllDebridDownloadUrl { - link: string; - size?: number; - filename?: string; -} - -export class AllDebridClient { - private static instance: AxiosInstance; - private static readonly baseURL = "https://api.alldebrid.com/v4"; - - static authorize(apiKey: string) { - logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty"); - this.instance = axios.create({ - baseURL: this.baseURL, - params: { - agent: "hydra", - apikey: apiKey, - }, - }); - } - - static async getUser() { - try { - const response = await this.instance.get<{ - status: string; - data?: { user: AllDebridUser }; - error?: AllDebridError; - }>("/user"); - - logger.info("[AllDebrid] API Response:", response.data); - - if (response.data.status === "error") { - const error = response.data.error; - logger.error("[AllDebrid] API Error:", error); - if (error?.code === "AUTH_MISSING_APIKEY") { - return { error_code: "alldebrid_missing_key" }; - } - if (error?.code === "AUTH_BAD_APIKEY") { - return { error_code: "alldebrid_invalid_key" }; - } - if (error?.code === "AUTH_BLOCKED") { - return { error_code: "alldebrid_blocked" }; - } - if (error?.code === "AUTH_USER_BANNED") { - return { error_code: "alldebrid_banned" }; - } - return { error_code: "alldebrid_unknown_error" }; - } - - if (!response.data.data?.user) { - logger.error("[AllDebrid] No user data in response"); - return { error_code: "alldebrid_invalid_response" }; - } - - logger.info( - "[AllDebrid] Successfully got user:", - response.data.data.user.username - ); - return { user: response.data.data.user }; - } catch (error: any) { - logger.error("[AllDebrid] Request Error:", error); - if (error.response?.data?.error) { - return { error_code: "alldebrid_invalid_key" }; - } - return { error_code: "alldebrid_network_error" }; - } - } - - private static async uploadMagnet(magnet: string) { - try { - logger.info("[AllDebrid] Uploading magnet with params:", { magnet }); - - const response = await this.instance.get("/magnet/upload", { - params: { - magnets: [magnet], - }, - }); - - logger.info( - "[AllDebrid] Upload Magnet Raw Response:", - JSON.stringify(response.data, null, 2) - ); - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - const magnetInfo = response.data.data.magnets[0]; - logger.info( - "[AllDebrid] Magnet Info:", - JSON.stringify(magnetInfo, null, 2) - ); - - if (magnetInfo.error) { - throw new Error(magnetInfo.error.message); - } - - return magnetInfo.id; - } catch (error: any) { - logger.error("[AllDebrid] Upload Magnet Error:", error); - throw error; - } - } - - private static async checkMagnetStatus( - magnetId: number - ): Promise { - try { - logger.info("[AllDebrid] Checking magnet status for ID:", magnetId); - - const response = await this.instance.get(`/magnet/status`, { - params: { - id: magnetId, - }, - }); - - logger.info( - "[AllDebrid] Check Magnet Status Raw Response:", - JSON.stringify(response.data, null, 2) - ); - - if (!response.data) { - throw new Error("No response data received"); - } - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - // Verificăm noua structură a răspunsului - const magnetData = response.data.data?.magnets; - if (!magnetData || typeof magnetData !== "object") { - logger.error( - "[AllDebrid] Invalid response structure:", - JSON.stringify(response.data, null, 2) - ); - throw new Error("Invalid magnet status response format"); - } - - // Convertim răspunsul în formatul așteptat - const magnetStatus: AllDebridMagnetStatus = { - id: magnetData.id, - filename: magnetData.filename, - size: magnetData.size, - status: magnetData.status, - statusCode: magnetData.statusCode, - downloaded: magnetData.downloaded, - uploaded: magnetData.uploaded, - seeders: magnetData.seeders, - downloadSpeed: magnetData.downloadSpeed, - uploadSpeed: magnetData.uploadSpeed, - uploadDate: magnetData.uploadDate, - completionDate: magnetData.completionDate, - links: magnetData.links.map((link) => ({ - link: link.link, - filename: link.filename, - size: link.size, - })), - }; - - logger.info( - "[AllDebrid] Magnet Status:", - JSON.stringify(magnetStatus, null, 2) - ); - - return magnetStatus; - } catch (error: any) { - logger.error("[AllDebrid] Check Magnet Status Error:", error); - throw error; - } - } - - private static async unlockLink(link: string) { - try { - const response = await this.instance.get<{ - status: string; - data?: { link: string }; - error?: AllDebridError; - }>("/link/unlock", { - params: { - link, - }, - }); - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - const unlockedLink = response.data.data?.link; - if (!unlockedLink) { - throw new Error("No download link received from AllDebrid"); - } - - return unlockedLink; - } catch (error: any) { - logger.error("[AllDebrid] Unlock Link Error:", error); - throw error; - } - } - - public static async getDownloadUrls( - uri: string - ): Promise { - try { - logger.info("[AllDebrid] Getting download URLs for URI:", uri); - - if (uri.startsWith("magnet:")) { - logger.info("[AllDebrid] Detected magnet link, uploading..."); - // 1. Upload magnet - const magnetId = await this.uploadMagnet(uri); - logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId); - - // 2. Verificăm statusul până când avem link-uri - let retries = 0; - let magnetStatus: AllDebridMagnetStatus; - - do { - magnetStatus = await this.checkMagnetStatus(magnetId); - logger.info( - "[AllDebrid] Magnet status:", - magnetStatus.status, - "statusCode:", - magnetStatus.statusCode - ); - - if (magnetStatus.statusCode === 4) { - // Ready - // Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează - const unlockedLinks = await Promise.all( - magnetStatus.links.map(async (link) => { - try { - const unlockedLink = await this.unlockLink(link.link); - logger.info( - "[AllDebrid] Successfully unlocked link:", - unlockedLink - ); - - return { - link: unlockedLink, - size: link.size, - filename: link.filename, - }; - } catch (error) { - logger.error( - "[AllDebrid] Failed to unlock link:", - link.link, - error - ); - throw new Error("Failed to unlock all links"); - } - }) - ); - - logger.info( - "[AllDebrid] Got unlocked download links:", - unlockedLinks - ); - console.log("[AllDebrid] FINAL LINKS →", unlockedLinks); - return unlockedLinks; - } - - if (retries++ > 30) { - // Maximum 30 de încercări - throw new Error("Timeout waiting for magnet to be ready"); - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări - } while (magnetStatus.statusCode !== 4); - } else { - logger.info("[AllDebrid] Regular link, unlocking..."); - // Pentru link-uri normale, doar debridam link-ul - const downloadUrl = await this.unlockLink(uri); - logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl); - return [ - { - link: downloadUrl, - }, - ]; - } - } catch (error: any) { - logger.error("[AllDebrid] Get Download URLs Error:", error); - throw error; - } - return []; // Add default return for TypeScript - } -} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 7c256b51..4dcebbb0 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -17,7 +17,6 @@ import { } from "./types"; import { calculateETA, getDirSize } from "./helpers"; import { RealDebridClient } from "./real-debrid"; -import { AllDebridClient } from "./all-debrid"; import path from "path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; @@ -379,27 +378,6 @@ export class DownloadManager { allow_multiple_connections: true, }; } - case Downloader.AllDebrid: { - const downloadUrls = await AllDebridClient.getDownloadUrls( - download.uri - ); - - if (!downloadUrls.length) - throw new Error(DownloadError.NotCachedInAllDebrid); - - const totalSize = downloadUrls.reduce( - (total, url) => total + (url.size || 0), - 0 - ); - - return { - action: "start", - game_id: downloadId, - url: downloadUrls.map((d) => d.link), - save_path: download.downloadPath, - total_size: totalSize, - }; - } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index c28d560b..f4e2eddc 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,4 +1,3 @@ export * from "./download-manager"; export * from "./real-debrid"; -export * from "./all-debrid"; export * from "./torbox"; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index ef88b062..12090df3 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -12,7 +12,7 @@ import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; -interface HydraApiOptions { +export interface HydraApiOptions { needsAuth?: boolean; needsSubscription?: boolean; ifModifiedSince?: Date; @@ -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(); } @@ -102,8 +102,12 @@ export class HydraApi { WindowManager.mainWindow.webContents.send("on-signin"); await clearGamesRemoteIds(); uploadGamesBatch(); + // WSClient.close(); // WSClient.connect(); + + const { syncDownloadSourcesFromApi } = await import("./user"); + syncDownloadSourcesFromApi(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 727805c7..da4e6848 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -17,3 +17,5 @@ export * from "./system-path"; export * from "./library-sync"; export * from "./wine"; export * from "./lock"; +export * from "./decky-plugin"; +export * from "./user"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index b5b2d551..c00e4961 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -22,7 +22,8 @@ export const mergeWithRemoteGames = async () => { const updatedLastTimePlayed = localGame.lastTimePlayed == null || (game.lastTimePlayed && - new Date(game.lastTimePlayed) > localGame.lastTimePlayed) + new Date(game.lastTimePlayed) > + new Date(localGame.lastTimePlayed)) ? game.lastTimePlayed : localGame.lastTimePlayed; @@ -57,7 +58,11 @@ export const mergeWithRemoteGames = async () => { }); } + const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + await gamesShopAssetsSublevel.put(gameKey, { + updatedAt: Date.now(), + ...localGameShopAsset, shop: game.shop, objectId: game.objectId, title: localGame?.title || game.title, // Preserve local title if it exists @@ -67,6 +72,7 @@ export const mergeWithRemoteGames = async () => { logoImageUrl: game.logoImageUrl, iconUrl: game.iconUrl, logoPosition: game.logoPosition, + downloadSources: game.downloadSources, }); } }) diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index fa9ac593..d28c3cd7 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; -import type { Game, GameStats, UserPreferences, UserProfile } from "@types"; +import type { Game, UserPreferences, UserProfile } from "@types"; import { db, levelKeys } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; @@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async ( }; export const publishFriendStartedPlayingGameNotification = async ( - friend: UserProfile, - game: GameStats + friend: UserProfile ) => { new Notification({ title: t("friend_started_playing_game", { ns: "notifications", displayName: friend.displayName, }), - body: game.assets?.title, + body: friend?.currentGame?.title, icon: friend?.profileImageUrl ? await downloadImage(friend.profileImageUrl) : trayIcon, diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 8c407ad5..6408c30d 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,10 +1,10 @@ import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; -import type { Game, GameRunning } from "@types"; +import type { Game, GameRunning, UserPreferences } from "@types"; import { PythonRPC } from "./python-rpc"; import axios from "axios"; import { ProcessPayload } from "./download/types"; -import { gamesSublevel, levelKeys } from "@main/level"; +import { db, gamesSublevel, levelKeys } from "@main/level"; import { CloudSync } from "./cloud-sync"; import { logger } from "./logger"; import path from "path"; @@ -79,11 +79,18 @@ const findGamePathByProcess = async ( const executables = gameExecutables[gameId]; for (const executable of executables) { - const pathSet = processMap.get(executable.exe); + const executablewithoutExtension = executable.exe.replace(/\.exe$/i, ""); + + const pathSet = + processMap.get(executable.exe) ?? + processMap.get(executablewithoutExtension); if (pathSet) { for (const path of pathSet) { - if (path.toLowerCase().endsWith(executable.name)) { + if ( + path.toLowerCase().endsWith(executable.name) || + path.toLowerCase().endsWith(executablewithoutExtension) + ) { const gameKey = levelKeys.game("steam", gameId); const game = await gamesSublevel.get(gameKey); @@ -124,7 +131,6 @@ const getSystemProcessMap = async () => { if (!key || !value) return; const STEAM_COMPAT_DATA_PATH = process.environ?.STEAM_COMPAT_DATA_PATH; - if (STEAM_COMPAT_DATA_PATH) { winePrefixMap.set(value, STEAM_COMPAT_DATA_PATH); } @@ -203,6 +209,17 @@ function onOpenGame(game: Game) { lastSyncTick: now, }); + // Hide Hydra to tray on game startup if enabled + db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }) + .then((userPreferences) => { + if (userPreferences?.hideToTrayOnGameStart) { + WindowManager.mainWindow?.hide(); + } + }) + .catch(() => {}); + if (game.remoteId) { updateGamePlaytime( game, diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index f3ce9f6c..2a1dce79 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -106,7 +106,7 @@ export class PythonRPC { "main.py" ); - const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], { + const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { stdio: ["inherit", "inherit"], }); diff --git a/src/main/services/steam.ts b/src/main/services/steam.ts index bc867111..886772b5 100644 --- a/src/main/services/steam.ts +++ b/src/main/services/steam.ts @@ -19,7 +19,12 @@ export interface SteamAppDetailsResponse { export const getSteamLocation = async () => { if (process.platform === "linux") { - return path.join(SystemPath.getPath("home"), ".local", "share", "Steam"); + const possiblePaths = [ + path.join(SystemPath.getPath("home"), ".steam", "steam"), + path.join(SystemPath.getPath("home"), ".local", "share", "Steam"), + ]; + + return possiblePaths.find((p) => fs.existsSync(p)) || possiblePaths[0]; } if (process.platform === "darwin") { @@ -76,7 +81,11 @@ export const getSteamAppDetails = async ( return null; }) .catch((err) => { - logger.error(err, { method: "getSteamAppDetails" }); + logger.error("Error on getSteamAppDetails", { + message: err?.message, + code: err?.code, + name: err?.name, + }); return null; }); }; diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index d26c995d..6bf3fffc 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -8,58 +8,65 @@ import { levelKeys } from "@main/level/sublevels"; export const getUserData = async () => { return HydraApi.get(`/profile/me`) .then(async (me) => { - db.get(levelKeys.user, { valueEncoding: "json" }).then( - (user) => { - return db.put( - levelKeys.user, - { - ...user, - id: me.id, - displayName: me.displayName, - profileImageUrl: me.profileImageUrl, - backgroundImageUrl: me.backgroundImageUrl, - subscription: me.subscription, - }, - { valueEncoding: "json" } - ); - } - ); - + try { + const user = await db.get(levelKeys.user, { + valueEncoding: "json", + }); + await db.put( + levelKeys.user, + { + ...user, + id: me.id, + displayName: me.displayName, + profileImageUrl: me.profileImageUrl, + backgroundImageUrl: me.backgroundImageUrl, + subscription: me.subscription, + }, + { valueEncoding: "json" } + ); + } catch (error) { + logger.error("Failed to update user in DB", error); + } return me; }) .catch(async (err) => { if (err instanceof UserNotLoggedInError) { return null; } - logger.error("Failed to get logged user"); - const loggedUser = await db.get(levelKeys.user, { - valueEncoding: "json", - }); + logger.error("Failed to get logged user", err); - if (loggedUser) { - return { - ...loggedUser, - username: "", - bio: "", - email: null, - profileVisibility: "PUBLIC" as ProfileVisibility, - quirks: { - backupsPerGameLimit: 0, - }, - subscription: loggedUser.subscription - ? { - id: loggedUser.subscription.id, - status: loggedUser.subscription.status, - plan: { - id: loggedUser.subscription.plan.id, - name: loggedUser.subscription.plan.name, - }, - expiresAt: loggedUser.subscription.expiresAt, - } - : null, - featurebaseJwt: "", - } as UserDetails; + try { + const loggedUser = await db.get(levelKeys.user, { + valueEncoding: "json", + }); + + if (loggedUser) { + return { + ...loggedUser, + username: "", + bio: "", + email: null, + profileVisibility: "PUBLIC" as ProfileVisibility, + quirks: { + backupsPerGameLimit: 0, + }, + subscription: loggedUser.subscription + ? { + id: loggedUser.subscription.id, + status: loggedUser.subscription.status, + plan: { + id: loggedUser.subscription.plan.id, + name: loggedUser.subscription.plan.name, + }, + expiresAt: loggedUser.subscription.expiresAt, + } + : null, + featurebaseJwt: "", + } as UserDetails; + } + } catch (dbError) { + logger.error("Failed to read user from DB", dbError); } return null; 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 70ff130e..673bf1a0 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,20 +55,41 @@ export class WindowManager { show: false, }; - private static loadMainWindowURL(hash = "") { + 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"]) { - this.mainWindow?.loadURL( - `${process.env["ELECTRON_RENDERER_URL"]}#/${hash}` - ); - } else { - this.mainWindow?.loadFile( - path.join(__dirname, "../renderer/index.html"), - { + window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`); + } else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) { + // Try to load from remote URL in production + try { + await window.loadURL( + `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 + logger.error( + "Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:", + error + ); + window.loadFile(path.join(__dirname, "../renderer/index.html"), { hash, - } - ); + }); + } + } else { + window.loadFile(path.join(__dirname, "../renderer/index.html"), { + hash, + }); + } + } + + private static async loadMainWindowURL(hash: string = "") { + if (this.mainWindow) { + await this.loadWindowURL(this.mainWindow, hash); } } @@ -268,17 +290,8 @@ export class WindowManager { } private static loadNotificationWindowURL() { - if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { - this.notificationWindow?.loadURL( - `${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification` - ); - } else { - this.notificationWindow?.loadFile( - path.join(__dirname, "../renderer/index.html"), - { - hash: "achievement-notification", - } - ); + if (this.notificationWindow) { + this.loadWindowURL(this.notificationWindow, "achievement-notification"); } } @@ -450,15 +463,7 @@ export class WindowManager { editorWindow.removeMenu(); - if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { - editorWindow.loadURL( - `${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}` - ); - } else { - editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), { - hash: `theme-editor?themeId=${themeId}`, - }); - } + this.loadWindowURL(editorWindow, `theme-editor?themeId=${themeId}`); editorWindow.once("ready-to-show", () => { editorWindow.show(); diff --git a/src/main/services/ws/events/friend-game-session.ts b/src/main/services/ws/events/friend-game-session.ts index 67967b3c..b089c421 100644 --- a/src/main/services/ws/events/friend-game-session.ts +++ b/src/main/services/ws/events/friend-game-session.ts @@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope"; import { db, levelKeys } from "@main/level"; import { HydraApi } from "@main/services/hydra-api"; import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications"; -import type { GameStats, UserPreferences, UserProfile } from "@types"; +import type { UserPreferences, UserProfile } from "@types"; export const friendGameSessionEvent = async (payload: FriendGameSession) => { const userPreferences = await db.get( @@ -14,12 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => { if (userPreferences?.friendStartGameNotificationsEnabled === false) return; - const [friend, gameStats] = await Promise.all([ - HydraApi.get(`/users/${payload.friendId}`), - HydraApi.get(`/games/steam/${payload.objectId}/stats`), - ]).catch(() => [null, null]); + const friend = await HydraApi.get(`/users/${payload.friendId}`); - if (friend && gameStats) { - publishFriendStartedPlayingGameNotification(friend, gameStats); + if (friend) { + publishFriendStartedPlayingGameNotification(friend); } }; diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index 69ba99fb..7b0ed536 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_WS_URL: string; + readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; + readonly ELECTRON_RENDERER_URL: string; } interface ImportMeta { diff --git a/src/preload/index.ts b/src/preload/index.ts index cb41e176..f89ec4db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,17 +11,15 @@ import type { GameRunning, FriendRequestAction, UpdateProfileRequest, - CatalogueSearchPayload, SeedingStatus, GameAchievement, Theme, FriendRequestSync, ShortcutLocation, - ShopAssets, AchievementCustomNotificationPosition, AchievementNotificationInfo, } from "@types"; -import type { AuthPage, CatalogueCategory } from "@shared"; +import type { AuthPage } from "@shared"; import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { @@ -63,20 +61,13 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("checkDebridAvailability", magnets), /* Catalogue */ - searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) => - ipcRenderer.invoke("searchGames", payload, take, skip), - getCatalogue: (category: CatalogueCategory) => - ipcRenderer.invoke("getCatalogue", category), - saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) => - ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets), getGameShopDetails: (objectId: string, shop: GameShop, language: string) => ipcRenderer.invoke("getGameShopDetails", objectId, shop, language), getRandomGame: () => ipcRenderer.invoke("getRandomGame"), - getHowLongToBeat: (objectId: string, shop: GameShop) => - ipcRenderer.invoke("getHowLongToBeat", objectId, shop), getGameStats: (objectId: string, shop: GameShop) => ipcRenderer.invoke("getGameStats", objectId, shop), - getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), + getGameAssets: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("getGameAssets", objectId, shop), onUpdateAchievements: ( objectId: string, shop: GameShop, @@ -102,19 +93,16 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("autoLaunch", autoLaunchProps), authenticateRealDebrid: (apiToken: string) => ipcRenderer.invoke("authenticateRealDebrid", apiToken), - authenticateAllDebrid: (apiKey: string) => - ipcRenderer.invoke("authenticateAllDebrid", apiKey), authenticateTorBox: (apiToken: string) => ipcRenderer.invoke("authenticateTorBox", apiToken), /* Download sources */ - putDownloadSource: (objectIds: string[]) => - ipcRenderer.invoke("putDownloadSource", objectIds), - createDownloadSources: (urls: string[]) => - ipcRenderer.invoke("createDownloadSources", urls), + addDownloadSource: (url: string) => + ipcRenderer.invoke("addDownloadSource", url), removeDownloadSource: (url: string, removeAll?: boolean) => ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), + syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), /* Library */ toggleAutomaticCloudSync: ( @@ -286,10 +274,6 @@ contextBridge.exposeInMainWorld("electron", { downloadOptionTitle: string | null ) => ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle), - toggleArtifactFreeze: (gameArtifactId: string, freeze: boolean) => - ipcRenderer.invoke("toggleArtifactFreeze", gameArtifactId, freeze), - renameGameArtifact: (gameArtifactId: string, label: string) => - ipcRenderer.invoke("renameGameArtifact", gameArtifactId, label), downloadGameArtifact: ( objectId: string, shop: GameShop, @@ -300,8 +284,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getGameArtifacts", objectId, shop), getGameBackupPreview: (objectId: string, shop: GameShop) => ipcRenderer.invoke("getGameBackupPreview", objectId, shop), - deleteGameArtifact: (gameArtifactId: string) => - ipcRenderer.invoke("deleteGameArtifact", gameArtifactId), selectGameBackupPath: ( shop: GameShop, objectId: string, @@ -358,10 +340,99 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("showOpenDialog", options), showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), - getFeatures: () => ipcRenderer.invoke("getFeatures"), - getBadges: () => ipcRenderer.invoke("getBadges"), + hydraApi: { + get: ( + url: string, + options?: { + params?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "get", + url, + params: options?.params, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + ifModifiedSince: options?.ifModifiedSince, + }, + }), + post: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "post", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + put: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "put", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + patch: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "patch", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + delete: ( + url: string, + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "delete", + url, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + }, canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"), installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"), + installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"), + getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"), + checkHomebrewFolderExists: () => + ipcRenderer.invoke("checkHomebrewFolderExists"), platform: process.platform, /* Auto update */ @@ -392,13 +463,10 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), - undoFriendship: (userId: string) => - ipcRenderer.invoke("undoFriendship", userId), updateProfile: (updateProfile: UpdateProfileRequest) => ipcRenderer.invoke("updateProfile", updateProfile), processProfileImage: (imagePath: string) => ipcRenderer.invoke("processProfileImage", imagePath), - getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"), onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { const listener = ( @@ -411,26 +479,8 @@ contextBridge.exposeInMainWorld("electron", { }, updateFriendRequest: (userId: string, action: FriendRequestAction) => ipcRenderer.invoke("updateFriendRequest", userId, action), - sendFriendRequest: (userId: string) => - ipcRenderer.invoke("sendFriendRequest", userId), /* User */ - getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), - getUserLibrary: ( - userId: string, - take?: number, - skip?: number, - sortBy?: string - ) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy), - blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), - unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), - getUserFriends: (userId: string, take: number, skip: number) => - ipcRenderer.invoke("getUserFriends", userId, take, skip), - getBlockedUsers: (take: number, skip: number) => - ipcRenderer.invoke("getBlockedUsers", take, skip), - getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId), - reportUser: (userId: string, reason: string, description: string) => - ipcRenderer.invoke("reportUser", userId, reason, description), getComparedUnlockedAchievements: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 7cd6eaa4..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"; @@ -23,8 +22,6 @@ import { } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; -import { downloadSourcesWorker } from "./workers"; -import { downloadSourcesTable } from "./dexie"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; @@ -41,8 +38,6 @@ export function App() { const { t } = useTranslation("app"); - const { updateRepacks } = useRepacks(); - const { clearDownload, setLastPacket } = useDownload(); const { @@ -136,15 +131,6 @@ export function App() { }, [fetchUserDetails, updateUserDetails, dispatch]); const onSignIn = useCallback(() => { - window.electron.getDownloadSources().then((sources) => { - sources.forEach((source) => { - downloadSourcesWorker.postMessage([ - "IMPORT_DOWNLOAD_SOURCE", - source.url, - ]); - }); - }); - fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); @@ -209,43 +195,6 @@ export function App() { }); }, [dispatch, draggingDisabled]); - useEffect(() => { - updateRepacks(); - - const id = crypto.randomUUID(); - const channel = new BroadcastChannel(`download_sources:sync:${id}`); - - channel.onmessage = async (event: MessageEvent) => { - const newRepacksCount = event.data; - window.electron.publishNewRepacksNotification(newRepacksCount); - updateRepacks(); - - const downloadSources = await downloadSourcesTable.toArray(); - - await Promise.all( - downloadSources - .filter((source) => !source.fingerprint) - .map(async (downloadSource) => { - const { fingerprint } = await window.electron.putDownloadSource( - downloadSource.objectIds - ); - - return downloadSourcesTable.update(downloadSource.id, { - fingerprint, - }); - }) - ); - - channel.close(); - }; - - downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); - - return () => { - channel.close(); - }; - }, [updateRepacks]); - const loadAndApplyTheme = useCallback(async () => { const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme?.code) { diff --git a/src/renderer/src/assets/icons/decky.png b/src/renderer/src/assets/icons/decky.png new file mode 100644 index 00000000..205552dd Binary files /dev/null and b/src/renderer/src/assets/icons/decky.png differ diff --git a/src/renderer/src/components/achievements/notification/achievement-notification.scss b/src/renderer/src/components/achievements/notification/achievement-notification.scss index 0a41782e..090c91c4 100644 --- a/src/renderer/src/components/achievements/notification/achievement-notification.scss +++ b/src/renderer/src/components/achievements/notification/achievement-notification.scss @@ -302,7 +302,8 @@ $margin-bottom: 28px; } &--rare &__trophy-overlay { - background: linear-gradient( + background: + linear-gradient( 118deg, #e8ad15 18.96%, #d5900f 26.41%, diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.scss b/src/renderer/src/components/confirm-modal/confirm-modal.scss deleted file mode 100644 index e5bda187..00000000 --- a/src/renderer/src/components/confirm-modal/confirm-modal.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "../../scss/globals.scss"; - -.confirm-modal { - &__actions { - display: flex; - width: 100%; - justify-content: flex-end; - align-items: center; - gap: globals.$spacing-unit; - } -} diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.tsx b/src/renderer/src/components/confirm-modal/confirm-modal.tsx deleted file mode 100644 index d210c035..00000000 --- a/src/renderer/src/components/confirm-modal/confirm-modal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Modal } from "@renderer/components"; -import "./confirm-modal.scss"; - -export interface ConfirmModalProps { - visible: boolean; - title: string; - description?: string; - onClose: () => void; - onConfirm: () => Promise | void; - confirmLabel?: string; - cancelLabel?: string; - confirmTheme?: "primary" | "outline" | "danger"; - confirmDisabled?: boolean; -} - -export function ConfirmModal({ - visible, - title, - description, - onClose, - onConfirm, - confirmLabel, - cancelLabel, - confirmTheme = "outline", - confirmDisabled = false, -}: ConfirmModalProps) { - const { t } = useTranslation(); - - const handleConfirm = async () => { - await onConfirm(); - onClose(); - }; - - return ( - -
- - - -
-
- ); -} diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.scss b/src/renderer/src/components/confirmation-modal/confirmation-modal.scss index 428818c4..7689ebcd 100644 --- a/src/renderer/src/components/confirmation-modal/confirmation-modal.scss +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.scss @@ -8,7 +8,7 @@ &__actions { display: flex; align-self: flex-end; - gap: calc(globals.$spacing-unit * 2); + gap: globals.$spacing-unit; } &__description { font-size: 16px; diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx index 63256935..f81453fa 100644 --- a/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx @@ -42,7 +42,7 @@ export function ConfirmationModal({ {cancelButtonLabel} ))} + {window.electron.platform === "linux" && homebrewFolderExists && ( +
  • + +
  • + )} @@ -321,18 +411,20 @@ export function Sidebar() { - {hasActiveSubscription && ( - - )} +
    + {hasActiveSubscription && ( + + )} +
    )} - {rightContent} - {hintContent} ); } ); - TextField.displayName = "TextField"; diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 5f5661ea..3329a0cc 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -1,6 +1,6 @@ import { Downloader } from "@shared"; -export const VERSION_CODENAME = "Lumen"; +export const VERSION_CODENAME = "Supernova"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", @@ -11,7 +11,6 @@ export const DOWNLOADER_NAME = { [Downloader.Datanodes]: "Datanodes", [Downloader.Mediafire]: "Mediafire", [Downloader.TorBox]: "TorBox", - [Downloader.AllDebrid]: "All-Debrid", [Downloader.Hydra]: "Nimbus", }; 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 f9287a11..abc359e9 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -87,7 +87,7 @@ export function CloudSyncContextProvider({ const [loadingPreview, setLoadingPreview] = useState(false); const [freezingArtifact, setFreezingArtifact] = useState(false); - const { showSuccessToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); const downloadGameArtifact = useCallback( async (gameArtifactId: string) => { @@ -98,7 +98,23 @@ export function CloudSyncContextProvider({ ); const getGameArtifacts = useCallback(async () => { - const results = await window.electron.getGameArtifacts(objectId, shop); + if (shop === "custom") { + setArtifacts([]); + return; + } + + const params = new URLSearchParams({ + objectId, + shop, + }); + + const results = await window.electron.hydraApi + .get(`/profile/games/artifacts?${params.toString()}`, { + needsSubscription: true, + }) + .catch(() => { + return []; + }); setArtifacts(results); }, [objectId, shop]); @@ -122,16 +138,25 @@ export function CloudSyncContextProvider({ const uploadSaveGame = useCallback( async (downloadOptionTitle: string | null) => { setUploadingBackup(true); - window.electron.uploadSaveGame(objectId, shop, downloadOptionTitle); + window.electron + .uploadSaveGame(objectId, shop, downloadOptionTitle) + .catch((err) => { + setUploadingBackup(false); + logger.error("Failed to upload save game", { objectId, shop, err }); + showErrorToast(t("backup_failed")); + }); }, - [objectId, shop] + [objectId, shop, t, showErrorToast] ); const toggleArtifactFreeze = useCallback( async (gameArtifactId: string, freeze: boolean) => { setFreezingArtifact(true); try { - await window.electron.toggleArtifactFreeze(gameArtifactId, freeze); + const endpoint = freeze ? "freeze" : "unfreeze"; + await window.electron.hydraApi.put( + `/profile/games/artifacts/${gameArtifactId}/${endpoint}` + ); getGameArtifacts(); } catch (err) { logger.error("Failed to toggle artifact freeze", objectId, shop, err); @@ -179,10 +204,12 @@ export function CloudSyncContextProvider({ const deleteGameArtifact = useCallback( async (gameArtifactId: string) => { - return window.electron.deleteGameArtifact(gameArtifactId).then(() => { - getGameBackupPreview(); - getGameArtifacts(); - }); + return window.electron.hydraApi + .delete<{ ok: boolean }>(`/profile/games/artifacts/${gameArtifactId}`) + .then(() => { + getGameBackupPreview(); + getGameArtifacts(); + }); }, [getGameBackupPreview, getGameArtifacts] ); 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 23ea3845..c5b88607 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,11 +1,4 @@ -import { - createContext, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; import { getSteamLanguage } from "@renderer/helpers"; @@ -13,11 +6,11 @@ import { useAppDispatch, useAppSelector, useDownload, - useRepacks, useUserDetails, } from "@renderer/hooks"; import type { + GameRepack, GameShop, GameStats, LibraryGame, @@ -84,12 +77,7 @@ export function GameDetailsContextProvider({ const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); - - const { getRepacksForObjectId } = useRepacks(); - - const repacks = useMemo(() => { - return getRepacksForObjectId(objectId); - }, [getRepacksForObjectId, objectId]); + const [repacks, setRepacks] = useState([]); const { i18n } = useTranslation("game_details"); const location = useLocation(); @@ -142,29 +130,26 @@ export function GameDetailsContextProvider({ } }); - const statsPromise = window.electron - .getGameStats(objectId, shop) - .then((result) => { - if (abortController.signal.aborted) return null; + if (shop !== "custom") { + window.electron.getGameStats(objectId, shop).then((result) => { + if (abortController.signal.aborted) return; setStats(result); - return result; }); + } - Promise.all([shopDetailsPromise, statsPromise]) - .then(([_, stats]) => { - if (stats) { - const assets = stats.assets; - if (assets) { - window.electron.saveGameShopAssets(objectId, shop, assets); + const assetsPromise = window.electron.getGameAssets(objectId, shop); - setShopDetails((prev) => { - if (!prev) return null; - return { - ...prev, - assets, - }; - }); - } + Promise.all([shopDetailsPromise, assetsPromise]) + .then(([_, assets]) => { + if (assets) { + if (abortController.signal.aborted) return; + setShopDetails((prev) => { + if (!prev) return null; + return { + ...prev, + assets, + }; + }); } }) .finally(() => { @@ -172,7 +157,7 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - if (userDetails) { + if (userDetails && shop !== "custom") { window.electron .getUnlockedAchievements(objectId, shop) .then((achievements) => { @@ -201,13 +186,14 @@ export function GameDetailsContextProvider({ }, [objectId, gameTitle, dispatch]); useEffect(() => { - const state = (location && (location.state as Record)) || {}; + const state = + (location && (location.state as Record)) || {}; if (state.openRepacks) { setShowRepacksModal(true); try { window.history.replaceState({}, document.title, location.pathname); - } catch (_e) { - void _e; + } catch (e) { + console.error(e); } } }, [location]); @@ -291,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, @@ -319,6 +292,34 @@ export function GameDetailsContextProvider({ }; }, [objectId, shop, userDetails]); + useEffect(() => { + const fetchDownloadSources = async () => { + try { + const sources = await window.electron.getDownloadSources(); + + const params = { + take: 100, + skip: 0, + downloadSourceIds: sources.map((source) => source.id), + }; + + const downloads = await window.electron.hydraApi.get( + `/games/${shop}/${objectId}/download-sources`, + { + params, + needsAuth: false, + } + ); + + setRepacks(downloads); + } catch (error) { + console.error("Failed to fetch download sources:", error); + } + }; + + fetchDownloadSources(); + }, [shop, objectId]); + const getDownloadsPath = async () => { if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; return window.electron.getDefaultDownloadsPath(); @@ -363,7 +364,7 @@ export function GameDetailsContextProvider({ stats, achievements, hasNSFWContentBlocked, - lastDownloadedOption, + lastDownloadedOption: null, setHasNSFWContentBlocked, selectGameExecutable, updateGame, diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 5c79f38d..1160ca3e 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -116,7 +116,13 @@ export function SettingsContextProvider({ }, []); const fetchBlockedUsers = useCallback(async () => { - const blockedUsers = await window.electron.getBlockedUsers(12, 0); + const blockedUsers = await window.electron.hydraApi + .get("/profile/blocks", { + params: { take: 12, skip: 0 }, + }) + .catch(() => { + return { blocks: [] }; + }); setBlockedUsers(blockedUsers.blocks); }, []); diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 2750442a..87e2a669 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -66,10 +66,7 @@ export function UserProfileContextProvider({ const isMe = userDetails?.id === userProfile?.id; const getHeroBackgroundFromImageUrl = async (imageUrl: string) => { - const output = await average(imageUrl, { - amount: 1, - format: "hex", - }); + const output = await average(imageUrl, { amount: 1, format: "hex" }); return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`; }; @@ -82,26 +79,38 @@ export function UserProfileContextProvider({ return ""; }; - const { t } = useTranslation("user_profile"); + const { t, i18n } = useTranslation("user_profile"); const { showErrorToast } = useToast(); const navigate = useNavigate(); const getUserStats = useCallback(async () => { - window.electron.getUserStats(userId).then((stats) => { - setUserStats(stats); - }); + window.electron.hydraApi + .get(`/users/${userId}/stats`) + .then((stats) => { + setUserStats(stats); + }); }, [userId]); const getUserLibraryGames = useCallback( async (sortBy?: string) => { try { - const response = await window.electron.getUserLibrary( - userId, - 12, - 0, - sortBy - ); + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", "0"); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); if (response) { setLibraryGames(response.library); @@ -122,8 +131,9 @@ export function UserProfileContextProvider({ getUserStats(); getUserLibraryGames(); - return window.electron.getUser(userId).then((userProfile) => { - if (userProfile) { + return window.electron.hydraApi + .get(`/users/${userId}`) + .then((userProfile) => { setUserProfile(userProfile); if (userProfile.profileImageUrl) { @@ -131,17 +141,23 @@ export function UserProfileContextProvider({ (color) => setHeroBackground(color) ); } - } else { + }) + .catch(() => { showErrorToast(t("user_not_found")); navigate(-1); - } - }); + }); }, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]); const getBadges = useCallback(async () => { - const badges = await window.electron.getBadges(); + const language = i18n.language.split("-")[0]; + const params = new URLSearchParams({ locale: language }); + + const badges = await window.electron.hydraApi.get( + `/badges?${params.toString()}`, + { needsAuth: false } + ); setBadges(badges); - }, []); + }, [i18n]); useEffect(() => { setUserProfile(null); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index ad7a5386..fa4ab3d6 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -1,47 +1,38 @@ -import type { AuthPage, CatalogueCategory } from "@shared"; +import type { AuthPage } from "@shared"; import type { AppUpdaterEvent, GameShop, - HowLongToBeatCategory, Steam250Game, DownloadProgress, SeedingStatus, UserPreferences, StartGameDownloadPayload, RealDebridUser, - AllDebridUser, UserProfile, - FriendRequest, FriendRequestAction, - UserFriends, - UserBlocks, UpdateProfileRequest, GameStats, - TrendingGame, - UserStats, UserDetails, FriendRequestSync, GameArtifact, LudusaviBackup, UserAchievement, ComparedAchievements, - CatalogueSearchPayload, LibraryGame, GameRunning, TorBoxUser, Theme, - Badge, Auth, ShortcutLocation, - CatalogueSearchResult, ShopAssets, ShopDetailsWithAssets, AchievementCustomNotificationPosition, AchievementNotificationInfo, - UserLibraryResponse, + Game, + DiskUsage, + DownloadSource, } from "@types"; import type { AxiosProgressEvent } from "axios"; -import type disk from "diskusage"; declare global { declare module "*.svg" { @@ -71,36 +62,22 @@ declare global { ) => Promise>; /* Catalogue */ - searchGames: ( - payload: CatalogueSearchPayload, - take: number, - skip: number - ) => Promise<{ edges: CatalogueSearchResult[]; count: number }>; - getCatalogue: (category: CatalogueCategory) => Promise; - saveGameShopAssets: ( - objectId: string, - shop: GameShop, - assets: ShopAssets - ) => Promise; getGameShopDetails: ( objectId: string, shop: GameShop, language: string ) => Promise; getRandomGame: () => Promise; - getHowLongToBeat: ( + getGameStats: (objectId: string, shop: GameShop) => Promise; + getGameAssets: ( objectId: string, shop: GameShop - ) => Promise; - getGameStats: (objectId: string, shop: GameShop) => Promise; - getTrendingGames: () => Promise; + ) => Promise; onUpdateAchievements: ( objectId: string, shop: GameShop, cb: (achievements: UserAchievement[]) => void ) => () => Electron.IpcRenderer; - getPublishers: () => Promise; - getDevelopers: () => Promise; /* Library */ toggleAutomaticCloudSync: ( @@ -213,9 +190,6 @@ declare global { ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; - authenticateAllDebrid: ( - apiKey: string - ) => Promise; authenticateTorBox: (apiToken: string) => Promise; getUserPreferences: () => Promise; updateUserPreferences: ( @@ -233,17 +207,16 @@ declare global { createSteamShortcut: (shop: GameShop, objectId: string) => Promise; /* Download sources */ - putDownloadSource: ( - objectIds: string[] - ) => Promise<{ fingerprint: string }>; - createDownloadSources: (urls: string[]) => Promise; - removeDownloadSource: (url: string, removeAll?: boolean) => Promise; - getDownloadSources: () => Promise< - Pick[] - >; + addDownloadSource: (url: string) => Promise; + removeDownloadSource: ( + removeAll = false, + downloadSourceId?: string + ) => Promise; + getDownloadSources: () => Promise; + syncDownloadSources: () => Promise; /* Hardware */ - getDiskFreeSpace: (path: string) => Promise; + getDiskFreeSpace: (path: string) => Promise; checkFolderWritePermission: (path: string) => Promise; /* Cloud save */ @@ -252,14 +225,6 @@ declare global { shop: GameShop, downloadOptionTitle: string | null ) => Promise; - toggleArtifactFreeze: ( - gameArtifactId: string, - freeze: boolean - ) => Promise; - renameGameArtifact: ( - gameArtifactId: string, - label: string - ) => Promise; downloadGameArtifact: ( objectId: string, shop: GameShop, @@ -273,7 +238,6 @@ declare global { objectId: string, shop: GameShop ) => Promise; - deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; selectGameBackupPath: ( shop: GameShop, objectId: string, @@ -307,10 +271,65 @@ declare global { options: Electron.OpenDialogOptions ) => Promise; showItemInFolder: (path: string) => Promise; - getFeatures: () => Promise; - getBadges: () => Promise; + hydraApi: { + get: ( + url: string, + options?: { + params?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + } + ) => Promise; + post: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + put: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + patch: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + delete: ( + url: string, + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + }; canInstallCommonRedist: () => Promise; installCommonRedist: () => Promise; + installHydraDeckyPlugin: () => Promise<{ + success: boolean; + path: string; + currentVersion: string | null; + expectedVersion: string; + error?: string; + }>; + getHydraDeckyPluginInfo: () => Promise<{ + installed: boolean; + version: string | null; + path: string; + outdated: boolean; + expectedVersion: string | null; + }>; + checkHomebrewFolderExists: () => Promise; onCommonRedistProgress: ( cb: (value: { log: string; complete: boolean }) => void ) => () => Electron.IpcRenderer; @@ -335,27 +354,6 @@ declare global { onSignOut: (cb: () => void) => () => Electron.IpcRenderer; /* User */ - getUser: (userId: string) => Promise; - getUserLibrary: ( - userId: string, - take?: number, - skip?: number, - sortBy?: string - ) => Promise; - blockUser: (userId: string) => Promise; - unblockUser: (userId: string) => Promise; - getUserFriends: ( - userId: string, - take: number, - skip: number - ) => Promise; - getBlockedUsers: (take: number, skip: number) => Promise; - getUserStats: (userId: string) => Promise; - reportUser: ( - userId: string, - reason: string, - description: string - ) => Promise; getComparedUnlockedAchievements: ( objectId: string, shop: GameShop, @@ -368,7 +366,6 @@ declare global { /* Profile */ getMe: () => Promise; - undoFriendship: (userId: string) => Promise; updateProfile: ( updateProfile: UpdateProfileRequest ) => Promise; @@ -376,7 +373,6 @@ declare global { processProfileImage: ( path: string ) => Promise<{ imagePath: string; mimeType: string }>; - getFriendRequests: () => Promise; syncFriendRequests: () => Promise; onSyncFriendRequests: ( cb: (friendRequests: FriendRequestSync) => void @@ -385,7 +381,6 @@ declare global { userId: string, action: FriendRequestAction ) => Promise; - sendFriendRequest: (userId: string) => Promise; /* Notifications */ publishNewRepacksNotification: (newRepacksCount: number) => Promise; diff --git a/src/renderer/src/dexie.ts b/src/renderer/src/dexie.ts deleted file mode 100644 index 7991dc8a..00000000 --- a/src/renderer/src/dexie.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { GameShop, HowLongToBeatCategory } from "@types"; -import { Dexie } from "dexie"; - -export interface HowLongToBeatEntry { - id?: number; - objectId: string; - categories: HowLongToBeatCategory[]; - shop: GameShop; - createdAt: Date; - updatedAt: Date; -} - -export const db = new Dexie("Hydra"); - -db.version(9).stores({ - repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`, - downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`, - howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`, -}); - -export const downloadSourcesTable = db.table("downloadSources"); -export const repacksTable = db.table("repacks"); -export const howLongToBeatEntriesTable = db.table( - "howLongToBeatEntries" -); - -db.open(); diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index 9d48c0df..a7e64e1f 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -6,5 +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 "./catalogue-search"; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 01b4d6cc..f09cec84 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,6 +1,7 @@ import type { GameShop } from "@types"; import Color from "color"; +import { v4 as uuidv4 } from "uuid"; import { THEME_WEB_STORE_URL } from "./constants"; export const formatDownloadProgress = ( @@ -104,3 +105,19 @@ export const generateRandomGradient = (): string => { // Return as data URL that works in img tags return `data:image/svg+xml;base64,${btoa(svgContent)}`; }; + +export const formatNumber = (num: number): string => { + return new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, + }).format(num); +}; + +/** + * Generates a UUID v4 + * @returns A random UUID string + */ +export const generateUUID = (): string => { + return uuidv4(); +}; 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-feature.ts b/src/renderer/src/hooks/use-feature.ts index d4727105..c4841f8b 100644 --- a/src/renderer/src/hooks/use-feature.ts +++ b/src/renderer/src/hooks/use-feature.ts @@ -11,10 +11,12 @@ export function useFeature() { const [features, setFeatures] = useState(null); useEffect(() => { - window.electron.getFeatures().then((features) => { - localStorage.setItem("features", JSON.stringify(features || [])); - setFeatures(features || []); - }); + window.electron.hydraApi + .get("/features", { needsAuth: false }) + .then((features) => { + localStorage.setItem("features", JSON.stringify(features || [])); + setFeatures(features || []); + }); }, []); const isFeatureEnabled = useCallback( diff --git a/src/renderer/src/hooks/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts deleted file mode 100644 index dbc918b9..00000000 --- a/src/renderer/src/hooks/use-repacks.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { repacksTable } from "@renderer/dexie"; -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(() => { - repacksTable.toArray().then((repacks) => { - dispatch( - setRepacks( - JSON.parse( - JSON.stringify( - repacks.filter((repack) => Array.isArray(repack.objectIds)) - ) - ) - ) - ); - }); - }, [dispatch]); - - return { getRepacksForObjectId, updateRepacks }; -} diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index a35a760b..6d89f9b4 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -11,6 +11,7 @@ import type { FriendRequestAction, UpdateProfileRequest, UserDetails, + FriendRequest, } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; @@ -68,6 +69,7 @@ export function useUserDetails() { username: userDetails?.username || "", subscription: userDetails?.subscription || null, featurebaseJwt: userDetails?.featurebaseJwt || "", + karma: userDetails?.karma || 0, }); }, [ @@ -75,12 +77,13 @@ export function useUserDetails() { userDetails?.username, userDetails?.subscription, userDetails?.featurebaseJwt, + userDetails?.karma, ] ); const fetchFriendRequests = useCallback(async () => { - return window.electron - .getFriendRequests() + return window.electron.hydraApi + .get("/profile/friend-requests") .then((friendRequests) => { window.electron.syncFriendRequests(); dispatch(setFriendRequests(friendRequests)); @@ -102,8 +105,10 @@ export function useUserDetails() { const sendFriendRequest = useCallback( async (userId: string) => { - return window.electron - .sendFriendRequest(userId) + return window.electron.hydraApi + .post("/profile/friend-requests", { + data: { friendCode: userId }, + }) .then(() => fetchFriendRequests()); }, [fetchFriendRequests] @@ -111,19 +116,31 @@ export function useUserDetails() { const updateFriendRequestState = useCallback( async (userId: string, action: FriendRequestAction) => { - return window.electron - .updateFriendRequest(userId, action) + if (action === "CANCEL") { + return window.electron.hydraApi + .delete(`/profile/friend-requests/${userId}`) + .then(() => fetchFriendRequests()); + } + + return window.electron.hydraApi + .patch(`/profile/friend-requests/${userId}`, { + data: { + requestState: action, + }, + }) .then(() => fetchFriendRequests()); }, [fetchFriendRequests] ); const undoFriendship = (userId: string) => - window.electron.undoFriendship(userId); + window.electron.hydraApi.delete(`/profile/friends/${userId}`); - const blockUser = (userId: string) => window.electron.blockUser(userId); + const blockUser = (userId: string) => + window.electron.hydraApi.post(`/users/${userId}/block`); - const unblockUser = (userId: string) => window.electron.unblockUser(userId); + const unblockUser = (userId: string) => + window.electron.hydraApi.post(`/users/${userId}/unblock`); const hasActiveSubscription = useMemo(() => { const expiresAt = new Date(userDetails?.subscription?.expiresAt ?? 0); diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index 421f9695..bbeda906 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -1,16 +1,14 @@ -import type { CatalogueSearchResult, DownloadSource } from "@types"; +import type { + CatalogueSearchResult, + CatalogueSearchPayload, + DownloadSource, +} from "@types"; -import { - useAppDispatch, - useAppSelector, - useFormat, - useRepacks, -} from "@renderer/hooks"; +import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import "./catalogue.scss"; -import { downloadSourcesTable } from "@renderer/dexie"; import { FilterSection } from "./filter-section"; import { setFilters, setPage } from "@renderer/features"; import { useTranslation } from "react-i18next"; @@ -35,13 +33,12 @@ export default function Catalogue() { const abortControllerRef = useRef(null); const cataloguePageRef = useRef(null); - const { steamDevelopers, steamPublishers } = useCatalogue(); + const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue(); const { steamGenres, steamUserTags } = useAppSelector( (state) => state.catalogueSearch ); - const [downloadSources, setDownloadSources] = useState([]); const [isLoading, setIsLoading] = useState(true); const [results, setResults] = useState([]); @@ -56,25 +53,42 @@ export default function Catalogue() { const { t, i18n } = useTranslation("catalogue"); - const { getRepacksForObjectId } = useRepacks(); - 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.searchGames( - filters, - pageSize, - offset - ); + 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) => @@ -85,18 +99,17 @@ export default function Catalogue() { setIsLoading(true); abortControllerRef.current?.abort(); - debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE); + debouncedSearch( + filters, + downloadSources, + PAGE_SIZE, + (page - 1) * PAGE_SIZE + ); return () => { debouncedSearch.cancel(); }; - }, [filters, page, debouncedSearch]); - - useEffect(() => { - downloadSourcesTable.toArray().then((sources) => { - setDownloadSources(sources.filter((source) => !!source.fingerprint)); - }); - }, [getRepacksForObjectId]); + }, [filters, downloadSources, page, debouncedSearch]); const language = i18n.language.split("-")[0]; @@ -174,7 +187,7 @@ export default function Catalogue() { value: publisher, })), ]; - }, [filters, steamUserTags, steamGenresMapping, language, downloadSources]); + }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]); const filterSections = useMemo(() => { return [ @@ -190,13 +203,15 @@ export default function Catalogue() { }, { title: t("download_sources"), - items: downloadSources.map((source) => ({ - label: source.name, - value: source.fingerprint, - checked: filters.downloadSourceFingerprints.includes( - source.fingerprint - ), - })), + items: downloadSources + .filter((source) => source.fingerprint) + .map((source) => ({ + label: source.name, + value: source.fingerprint!, + checked: filters.downloadSourceFingerprints.includes( + source.fingerprint! + ), + })), key: "downloadSourceFingerprints", }, { diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index ecfe0f73..4583afd3 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -1,6 +1,6 @@ import { Badge } from "@renderer/components"; import { buildGameDetailsPath } from "@renderer/helpers"; -import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useLibrary } from "@renderer/hooks"; import { useMemo, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; @@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) { const { steamGenres } = useAppSelector((state) => state.catalogueSearch); - const { getRepacksForObjectId } = useRepacks(); - - const repacks = getRepacksForObjectId(game.objectId); - const [isAddingToLibrary, setIsAddingToLibrary] = useState(false); const [added, setAdded] = useState(false); @@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) { } }; - const uniqueRepackers = useMemo(() => { - return Array.from(new Set(repacks.map((repack) => repack.repacker))); - }, [repacks]); - const genres = useMemo(() => { return game.genres?.map((genre) => { const index = steamGenres["en"]?.findIndex( @@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) { {genres.join(", ")}
    - {uniqueRepackers.map((repacker) => ( - {repacker} + {game.downloadSources.map((sourceName) => ( + {sourceName} ))}
    diff --git a/src/renderer/src/pages/catalogue/pagination.scss b/src/renderer/src/pages/catalogue/pagination.scss index 141dfe54..cac10211 100644 --- a/src/renderer/src/pages/catalogue/pagination.scss +++ b/src/renderer/src/pages/catalogue/pagination.scss @@ -1,3 +1,5 @@ +@use "../../scss/globals.scss"; + .pagination { display: flex; gap: 4px; @@ -18,4 +20,31 @@ font-size: 16px; } } + + &__page-input { + box-sizing: border-box; + width: 40px; + min-width: 40px; + max-width: 40px; + min-height: 40px; + border-radius: 8px; + border: solid 1px globals.$border-color; + background-color: transparent; + color: globals.$muted-color; + text-align: center; + font-size: 12px; + padding: 0 6px; + outline: none; + } + + &__double-chevron { + display: flex; + align-items: center; + justify-content: center; + font-size: 0; // remove whitespace node width between SVGs + } + + &__double-chevron > svg + svg { + margin-left: -8px; // pull the second chevron closer + } } diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index dfae6164..9febc8f8 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -1,8 +1,51 @@ import { Button } from "@renderer/components/button/button"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useFormat } from "@renderer/hooks/use-format"; +import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent, KeyboardEvent, RefObject } from "react"; import "./pagination.scss"; +interface JumpControlProps { + isOpen: boolean; + value: string; + totalPages: number; + inputRef: RefObject; + onOpen: () => void; + onClose: () => void; + onChange: (e: ChangeEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; +} + +function JumpControl({ + isOpen, + value, + totalPages, + inputRef, + onOpen, + onClose, + onChange, + onKeyDown, +}: JumpControlProps) { + return isOpen ? ( + + ) : ( + + ); +} + interface PaginationProps { page: number; totalPages: number; @@ -16,20 +59,82 @@ export function Pagination({ }: PaginationProps) { const { formatNumber } = useFormat(); + const [isJumpOpen, setIsJumpOpen] = useState(false); + const [jumpValue, setJumpValue] = useState(""); + const jumpInputRef = useRef(null); + + useEffect(() => { + if (isJumpOpen) { + setJumpValue(""); + setTimeout(() => jumpInputRef.current?.focus(), 0); + } + }, [isJumpOpen, page]); + if (totalPages <= 1) return null; const visiblePages = 3; + const isLastThree = totalPages > 3 && page >= totalPages - 2; let startPage = Math.max(1, page - 1); let endPage = startPage + visiblePages - 1; - if (endPage > totalPages) { + if (isLastThree) { + startPage = Math.max(1, totalPages - 2); + endPage = totalPages; + } else if (endPage > totalPages) { endPage = totalPages; startPage = Math.max(1, endPage - visiblePages + 1); } + const onJumpChange = (e: ChangeEvent) => { + const val = e.target.value; + if (val === "") { + setJumpValue(""); + return; + } + const num = Number(val); + if (Number.isNaN(num)) { + return; + } + if (num < 1) { + setJumpValue("1"); + return; + } + if (num > totalPages) { + setJumpValue(String(totalPages)); + return; + } + setJumpValue(val); + }; + + const onJumpKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + if (jumpValue.trim() === "") return; + const parsed = Number(jumpValue); + if (Number.isNaN(parsed)) return; + const target = Math.max(1, Math.min(totalPages, parsed)); + onPageChange(target); + setIsJumpOpen(false); + } else if (e.key === "Escape") { + setIsJumpOpen(false); + } + }; + return (
    + {startPage > 1 && ( + + )} + - {page > 2 && ( + {isLastThree && startPage > 1 && ( <> - -
    - ... -
    + setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + /> )} @@ -70,11 +180,18 @@ export function Pagination({ ))} - {page < totalPages - 1 && ( + {!isLastThree && page < totalPages - 1 && ( <> -
    - ... -
    + setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + /> + + {endPage < totalPages && ( + + )}
    ); } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index bfa27792..06e9face 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -114,15 +114,6 @@ export function DownloadGroup({ return

    {t("deleting")}

    ; } - if (download.downloader === Downloader.AllDebrid) { - return ( - <> -

    {progress}

    -

    {t("alldebrid_size_not_supported")}

    - - ); - } - if (isGameDownloading) { if (lastPacket?.isDownloadingMetadata) { return

    {t("downloading_metadata")}

    ; @@ -190,15 +181,6 @@ export function DownloadGroup({ } if (download.status === "active") { - if ((download.downloader as unknown as string) === "alldebrid") { - return ( - <> -

    {formatDownloadProgress(download.progress)}

    -

    {t("alldebrid_size_not_supported")}

    - - ); - } - return ( <>

    {formatDownloadProgress(download.progress)}

    @@ -293,9 +275,7 @@ export function DownloadGroup({ (download?.downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || (download?.downloader === Downloader.TorBox && - !userPreferences?.torBoxApiToken) || - (download?.downloader === Downloader.AllDebrid && - !userPreferences?.allDebridApiKey); + !userPreferences?.torBoxApiToken); return [ { diff --git a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx index 25525331..c2e6e3a5 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx @@ -58,7 +58,14 @@ export function CloudSyncRenameArtifactModal({ try { if (!artifact) return; - await window.electron.renameGameArtifact(artifact.id, data.label); + await window.electron.hydraApi.put( + `/profile/games/artifacts/${artifact.id}`, + { + data: { + label: data.label, + }, + } + ); await getGameArtifacts(); showSuccessToast(t("artifact_renamed")); 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 1af1480d..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 @@ -2,20 +2,29 @@ .description-header { width: 100%; - padding: calc(globals.$spacing-unit * 1.5); + padding: calc(globals.$spacing-unit * 2); display: flex; justify-content: space-between; align-items: center; background-color: globals.$background-color; height: 72px; - border-radius: 12px; + border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - margin-bottom: calc(globals.$spacing-unit * 1); &__info { display: flex; - gap: globals.$spacing-unit; + gap: calc(globals.$spacing-unit * 0.5); flex-direction: column; + + p { + font-size: globals.$small-font-size; + color: globals.$muted-color; + font-weight: 400; + + &:first-child { + font-weight: 600; + } + } } } 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 9483b50e..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; @@ -25,11 +25,6 @@ overflow: hidden; border-radius: 8px; - @media (min-width: 1024px) { - width: 80%; - max-height: 400px; - } - @media (min-width: 1280px) { width: 60%; max-height: 500px; @@ -65,17 +60,13 @@ &__preview { width: 100%; padding: globals.$spacing-unit 0; - height: 100%; + height: auto; display: flex; position: relative; overflow-x: auto; overflow-y: hidden; gap: calc(globals.$spacing-unit / 2); - @media (min-width: 1024px) { - width: 80%; - } - @media (min-width: 1280px) { width: 60%; } 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 ca2ca023..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,33 +1,74 @@ -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"; import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; import { EditGameModal } from "./modals"; +import { GameReviews } from "./game-reviews"; +import { GameLogo } from "./game-logo"; -import { useTranslation } from "react-i18next"; -import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { AuthPage } from "@shared"; +import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; 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")); + $images.forEach(($image) => { + $image.loading = "lazy"; + $image.removeAttribute("width"); + $image.removeAttribute("height"); + $image.removeAttribute("style"); + $image.style.maxWidth = "100%"; + $image.style.width = "auto"; + $image.style.height = "auto"; + $image.style.boxSizing = "border-box"; + }); + + // Handle videos the same way + const $videos = Array.from(document.querySelectorAll("video")); + $videos.forEach(($video) => { + $video.removeAttribute("width"); + $video.removeAttribute("height"); + $video.removeAttribute("style"); + $video.style.maxWidth = "100%"; + $video.style.width = "auto"; + $video.style.height = "auto"; + $video.style.boxSizing = "border-box"; + }); +}; + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; export function GameDetailsContent() { - const heroRef = useRef(null); - const { t } = useTranslation("game_details"); - const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } = - useContext(gameDetailsContext); + const { + objectId, + shopDetails, + game, + hasNSFWContentBlocked, + updateGame, + shop, + } = useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); const { userDetails, hasActiveSubscription } = useUserDetails(); - const { updateLibrary } = useLibrary(); + const { updateLibrary, library } = useLibrary(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -40,33 +81,7 @@ export function GameDetailsContent() { "text/html" ); - const $images = Array.from(document.querySelectorAll("img")); - $images.forEach(($image) => { - $image.loading = "lazy"; - // Remove any inline width/height styles that might cause overflow - $image.removeAttribute("width"); - $image.removeAttribute("height"); - $image.removeAttribute("style"); - // Set max-width to prevent overflow - $image.style.maxWidth = "100%"; - $image.style.width = "auto"; - $image.style.height = "auto"; - $image.style.boxSizing = "border-box"; - }); - - // Handle videos the same way - const $videos = Array.from(document.querySelectorAll("video")); - $videos.forEach(($video) => { - // Remove any inline width/height styles that might cause overflow - $video.removeAttribute("width"); - $video.removeAttribute("height"); - $video.removeAttribute("style"); - // Set max-width to prevent overflow - $video.style.maxWidth = "100%"; - $video.style.width = "auto"; - $video.style.height = "auto"; - $video.style.boxSizing = "border-box"; - }); + processMediaElements(document); return document.body.outerHTML; } @@ -80,6 +95,16 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditGameModal, setShowEditGameModal] = useState(false); + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [hasUserReviewed, setHasUserReviewed] = useState(false); + + // Check if the current game is in the user's library + const isGameInLibrary = useMemo(() => { + if (!library || !shop || !objectId) return false; + return library.some( + (libItem) => libItem.shop === shop && libItem.objectId === objectId + ); + }, [library, shop, objectId]); useEffect(() => { setBackdropOpacity(1); @@ -103,7 +128,7 @@ export function GameDetailsContent() { setShowEditGameModal(true); }; - const handleGameUpdated = (_updatedGame: any) => { + const handleGameUpdated = () => { updateGame(); updateLibrary(); }; @@ -114,15 +139,6 @@ export function GameDetailsContent() { const isCustomGame = game?.shop === "custom"; - // Helper function to get image with custom asset priority - const getImageWithCustomPriority = ( - customUrl: string | null | undefined, - originalUrl: string | null | undefined, - fallbackUrl?: string | null | undefined - ) => { - return customUrl || originalUrl || fallbackUrl || ""; - }; - const heroImage = isCustomGame ? game?.libraryHeroImageUrl || game?.iconUrl || "" : getImageWithCustomPriority( @@ -130,65 +146,24 @@ export function GameDetailsContent() { shopDetails?.assets?.libraryHeroImageUrl ); - const logoImage = isCustomGame - ? game?.logoImageUrl || "" - : getImageWithCustomPriority( - game?.customLogoImageUrl, - shopDetails?.assets?.logoImageUrl - ); - - const renderGameLogo = () => { - if (isCustomGame) { - // For custom games, show logo image if available, otherwise show game title as text - if (logoImage) { - return ( - {game?.title} - ); - } else { - return ( -
    {game?.title}
    - ); - } - } else { - // For non-custom games, show logo image if available - return logoImage ? ( - {game?.title} - ) : null; - } - }; - return (
    -
    +
    {game?.title} -
    - {renderGameLogo()} +
    {game && ( @@ -220,11 +195,13 @@ export function GameDetailsContent() { )}
    + +
    + +
    - -
    @@ -234,11 +211,37 @@ export function GameDetailsContent() { dangerouslySetInnerHTML={{ __html: aboutTheGame, }} - className="game-details__description" + className={`game-details__description ${ + isDescriptionExpanded + ? "game-details__description--expanded" + : "game-details__description--collapsed" + }`} /> + + {aboutTheGame && aboutTheGame.length > 500 && ( + + )} + + {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 c39da3bb..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,58 +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 e1140d31..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,143 +13,6 @@ $hero-height: 300px; } } - &__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%; @@ -172,6 +21,7 @@ $hero-height: 300px; z-index: 1; } + // Description Section Styles &__description-container { display: flex; width: 100%; @@ -192,6 +42,8 @@ $hero-height: 300px; min-width: 0; flex: 1; overflow-x: hidden; + display: flex; + flex-direction: column; } &__description { @@ -203,6 +55,7 @@ $hero-height: 300px; margin-right: auto; overflow-x: auto; min-height: auto; + transition: max-height 0.3s ease-in-out; @media (min-width: 1280px) { width: 60%; @@ -212,6 +65,27 @@ $hero-height: 300px; width: 50%; } + &--collapsed { + max-height: 300px; + overflow: hidden; + position: relative; + + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 60px; + background: linear-gradient(transparent, globals.$background-color); + pointer-events: none; + } + } + + &--expanded { + max-height: none; + } + img, video { border-radius: 5px; @@ -237,134 +111,70 @@ $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); - } - - @media (min-width: 1024px) { - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); - width: 80%; - } - - @media (min-width: 1280px) { - width: 60%; - line-height: 22px; - } - - @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; + &__description-toggle { + background: none; + border: 1px solid globals.$border-color; + color: globals.$body-color; + padding: calc(globals.$spacing-unit * 0.75) + calc(globals.$spacing-unit * 1.5); + border-radius: 4px; 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; - } + font-size: globals.$body-font-size; + margin-top: calc(globals.$spacing-unit * 1.5); + transition: all 0.2s ease; + align-self: center; &:hover { - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.1); } } - &__stars-icon-container { - width: 16px; - height: 16px; - position: relative; - } + // 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; + } - &__stars-icon { - width: 70px; - position: absolute; - top: -28px; - left: -27px; - } + // Ensure skeleton elements maintain proper spacing + .description-header { + margin-bottom: calc(globals.$spacing-unit * 1.5); + } - &__cloud-icon-container { - width: 20px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - position: relative; - } + .content-sidebar { + min-width: 300px; + max-width: 300px; + } - &__cloud-icon { - width: 26px; - position: absolute; - top: -3px; - } + // Hero panel skeleton spacing + .hero-panel__content { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.5); + } - &__hero-backdrop { - flex: 1; - transition: opacity 0.2s ease; + // 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); + } + + // 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-logo.tsx b/src/renderer/src/pages/game-details/game-logo.tsx new file mode 100644 index 00000000..f0cb92ae --- /dev/null +++ b/src/renderer/src/pages/game-details/game-logo.tsx @@ -0,0 +1,49 @@ +import type { Game, ShopDetailsWithAssets } from "@types"; + +interface GameLogoProps { + game: Game | null; + shopDetails: ShopDetailsWithAssets | null; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function GameLogo({ game, shopDetails }: Readonly) { + const isCustomGame = game?.shop === "custom"; + + const logoImage = isCustomGame + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); + + if (isCustomGame) { + // For custom games, show logo image if available, otherwise show game title as text + if (logoImage) { + return ( + {game?.title} + ); + } else { + return
    {game?.title}
    ; + } + } else { + // For non-custom games, show logo image if available + return logoImage ? ( + {game?.title} + ) : null; + } +} 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 new file mode 100644 index 00000000..1a6fc675 --- /dev/null +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -0,0 +1,547 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { NoteIcon } from "@primer/octicons-react"; +import { useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useTranslation } from "react-i18next"; +import type { GameReview, Game, GameShop } from "@types"; + +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 = + | "newest" + | "oldest" + | "score_high" + | "score_low" + | "most_voted"; + +interface GameReviewsProps { + shop: GameShop; + objectId: string; + game: Game | null; + userDetailsId?: string; + isGameInLibrary: boolean; + hasUserReviewed: boolean; + onUserReviewedChange: (hasReviewed: boolean) => void; +} + +const MAX_REVIEW_CHARS = 1000; + +export function GameReviews({ + shop, + objectId, + game, + userDetailsId, + isGameInLibrary, + hasUserReviewed, + onUserReviewedChange, +}: Readonly) { + const { t, i18n } = useTranslation("game_details"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [reviews, setReviews] = useState([]); + const [reviewsLoading, setReviewsLoading] = useState(false); + const [reviewScore, setReviewScore] = useState(null); + const [submittingReview, setSubmittingReview] = useState(false); + const [reviewCharCount, setReviewCharCount] = useState(0); + const [reviewsSortBy, setReviewsSortBy] = + useState("newest"); + const [reviewsPage, setReviewsPage] = useState(0); + const [hasMoreReviews, setHasMoreReviews] = useState(true); + const [visibleBlockedReviews, setVisibleBlockedReviews] = useState< + Set + >(new Set()); + const [totalReviewCount, setTotalReviewCount] = useState(0); + const [showReviewForm, setShowReviewForm] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [showReviewPrompt, setShowReviewPrompt] = useState(false); + + const previousVotesRef = useRef< + Map + >(new Map()); + const abortControllerRef = useRef(null); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + link: false, + }), + ], + content: "", + editorProps: { + attributes: { + class: "game-details__review-editor", + "data-placeholder": t("write_review_placeholder"), + }, + handlePaste: (view, event) => { + const htmlContent = event.clipboardData?.getData("text/html") || ""; + const plainText = event.clipboardData?.getData("text/plain") || ""; + + const currentText = view.state.doc.textContent; + const remainingChars = MAX_REVIEW_CHARS - currentText.length; + + if ((htmlContent || plainText) && remainingChars > 0) { + event.preventDefault(); + + if (htmlContent) { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlContent; + const textLength = tempDiv.textContent?.length || 0; + + if (textLength <= remainingChars) { + return false; + } + } + + const truncatedText = plainText.slice(0, remainingChars); + view.dispatch(view.state.tr.insertText(truncatedText)); + return true; + } + return false; + }, + }, + onUpdate: ({ editor }) => { + const text = editor.getText(); + setReviewCharCount(text.length); + + if (text.length > MAX_REVIEW_CHARS) { + const truncatedContent = text.slice(0, MAX_REVIEW_CHARS); + editor.commands.setContent(truncatedContent); + setReviewCharCount(MAX_REVIEW_CHARS); + } + }, + }); + + const checkUserReview = useCallback(async () => { + if (!objectId || !userDetailsId || shop === "custom") return; + + try { + const response = await window.electron.hydraApi.get<{ + hasReviewed: boolean; + }>(`/games/${shop}/${objectId}/reviews/check`, { + needsAuth: true, + }); + const hasReviewed = response?.hasReviewed || false; + onUserReviewedChange(hasReviewed); + + const twoHoursInMilliseconds = 2 * 60 * 60 * 1000; + const hasEnoughPlaytime = + game && game.playTimeInMilliseconds >= twoHoursInMilliseconds; + + if ( + !hasReviewed && + hasEnoughPlaytime && + !sessionStorage.getItem(`reviewPromptDismissed_${objectId}`) + ) { + setShowReviewPrompt(true); + setShowReviewForm(true); + } + } catch (error) { + console.error("Failed to check user review:", error); + } + }, [objectId, userDetailsId, shop, game, onUserReviewedChange]); + + const loadReviews = useCallback( + async (reset = false) => { + if (!objectId || shop === "custom") return; + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setReviewsLoading(true); + try { + const skip = reset ? 0 : reviewsPage * 20; + const params = new URLSearchParams({ + take: "20", + skip: skip.toString(), + sortBy: reviewsSortBy, + language: i18n.language, + }); + + const response = await window.electron.hydraApi.get( + `/games/${shop}/${objectId}/reviews?${params.toString()}`, + { needsAuth: false } + ); + + if (abortController.signal.aborted) { + return; + } + + const typedResponse = response as unknown as + | { reviews: GameReview[]; totalCount: number } + | undefined; + const reviewsData = typedResponse?.reviews || []; + const reviewCount = typedResponse?.totalCount || 0; + + if (reset) { + setReviews(reviewsData); + setReviewsPage(0); + setTotalReviewCount(reviewCount); + } else { + setReviews((prev) => [...prev, ...reviewsData]); + } + + setHasMoreReviews(reviewsData.length === 20); + } catch (error) { + if (!abortController.signal.aborted) { + console.error("Failed to load reviews:", error); + } + } finally { + if (!abortController.signal.aborted) { + setReviewsLoading(false); + } + } + }, + [objectId, shop, reviewsPage, reviewsSortBy, i18n.language] + ); + + const handleVoteReview = async ( + reviewId: string, + voteType: "upvote" | "downvote" + ) => { + if (!objectId || votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const reviewIndex = reviews.findIndex((r) => r.id === reviewId); + if (reviewIndex === -1) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const review = reviews[reviewIndex]; + const originalReview = { ...review }; + + const updatedReviews = [...reviews]; + const updatedReview = { ...review }; + + if (voteType === "upvote") { + if (review.hasUpvoted) { + updatedReview.hasUpvoted = false; + updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1); + } else { + updatedReview.hasUpvoted = true; + updatedReview.upvotes = (review.upvotes || 0) + 1; + + if (review.hasDownvoted) { + updatedReview.hasDownvoted = false; + updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1); + } + } + } else { + if (review.hasDownvoted) { + updatedReview.hasDownvoted = false; + updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1); + } else { + updatedReview.hasDownvoted = true; + updatedReview.downvotes = (review.downvotes || 0) + 1; + + if (review.hasUpvoted) { + updatedReview.hasUpvoted = false; + updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1); + } + } + } + + updatedReviews[reviewIndex] = updatedReview; + setReviews(updatedReviews); + + try { + await window.electron.hydraApi.put( + `/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`, + { data: {} } + ); + } catch (error) { + console.error(`Failed to ${voteType} review:`, error); + + const rolledBackReviews = [...reviews]; + rolledBackReviews[reviewIndex] = originalReview; + setReviews(rolledBackReviews); + + showErrorToast(t("vote_failed")); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + }, 500); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + if (!objectId) return; + + try { + await window.electron.hydraApi.delete( + `/games/${shop}/${objectId}/reviews/${reviewId}` + ); + loadReviews(true); + onUserReviewedChange(false); + setShowReviewForm(true); + showSuccessToast(t("review_deleted_successfully")); + } catch (error) { + console.error("Failed to delete review:", error); + showErrorToast(t("review_deletion_failed")); + } + }; + + const handleSubmitReview = async () => { + const reviewHtml = editor?.getHTML() || ""; + const reviewText = editor?.getText() || ""; + + if (!objectId) return; + + if (!reviewText.trim()) { + showErrorToast(t("review_cannot_be_empty")); + return; + } + + if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) { + return; + } + + if (reviewScore === null) { + return; + } + + setSubmittingReview(true); + + try { + await window.electron.hydraApi.post( + `/games/${shop}/${objectId}/reviews`, + { + data: { + reviewHtml, + score: reviewScore, + }, + } + ); + + editor?.commands.clearContent(); + setReviewScore(null); + showSuccessToast(t("review_submitted_successfully")); + + await loadReviews(true); + setShowReviewForm(false); + setShowReviewPrompt(false); + onUserReviewedChange(true); + } catch (error) { + console.error("Failed to submit review:", error); + showErrorToast(t("review_submission_failed")); + } finally { + setSubmittingReview(false); + } + }; + + const handleReviewPromptYes = () => { + setShowReviewPrompt(false); + + setTimeout(() => { + const reviewFormElement = document.querySelector( + ".game-details__review-form" + ); + if (reviewFormElement) { + reviewFormElement.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, 100); + }; + + const handleReviewPromptLater = () => { + setShowReviewPrompt(false); + setShowReviewForm(false); + if (objectId) { + sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true"); + } + }; + + const handleSortChange = (newSortBy: ReviewSortOption) => { + if (newSortBy !== reviewsSortBy) { + setReviewsSortBy(newSortBy); + setReviewsPage(0); + setHasMoreReviews(true); + loadReviews(true); + } + }; + + const toggleBlockedReview = (reviewId: string) => { + setVisibleBlockedReviews((prev) => { + const newSet = new Set(prev); + if (newSet.has(reviewId)) { + newSet.delete(reviewId); + } else { + newSet.add(reviewId); + } + return newSet; + }); + }; + + const loadMoreReviews = () => { + if (!reviewsLoading && hasMoreReviews) { + setReviewsPage((prev) => prev + 1); + loadReviews(false); + } + }; + + const handleVoteAnimationComplete = ( + reviewId: string, + votes: { upvotes: number; downvotes: number } + ) => { + previousVotesRef.current.set(reviewId, votes); + }; + + useEffect(() => { + if (objectId) { + loadReviews(true); + if (userDetailsId) { + checkUserReview(); + } + } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + }, [objectId, userDetailsId, checkUserReview, loadReviews]); + + useEffect(() => { + if (reviewsPage > 0) { + loadReviews(false); + } + }, [reviewsPage, loadReviews]); + + useEffect(() => { + reviews.forEach((review) => { + if (!previousVotesRef.current.has(review.id)) { + previousVotesRef.current.set(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + } + }); + }, [reviews]); + + return ( +
    + {showReviewPrompt && + userDetailsId && + !hasUserReviewed && + isGameInLibrary && ( + + )} + + {showReviewForm && ( + <> + +
    + + )} + +
    +
    +

    {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")} +
    + )} +
    + ); +} 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-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index ac8a1615..2e91d361 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -69,14 +69,32 @@ export function HeroPanelActions() { updateGame(); }; - window.addEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener); - window.addEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener); - window.addEventListener("hydra:game-files-removed", onFilesRemoved as EventListener); + window.addEventListener( + "hydra:game-favorite-toggled", + onFavoriteToggled as EventListener + ); + window.addEventListener( + "hydra:game-removed-from-library", + onGameRemoved as EventListener + ); + window.addEventListener( + "hydra:game-files-removed", + onFilesRemoved as EventListener + ); return () => { - window.removeEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener); - window.removeEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener); - window.removeEventListener("hydra:game-files-removed", onFilesRemoved as EventListener); + window.removeEventListener( + "hydra:game-favorite-toggled", + onFavoriteToggled as EventListener + ); + window.removeEventListener( + "hydra:game-removed-from-library", + onGameRemoved as EventListener + ); + window.removeEventListener( + "hydra:game-files-removed", + onFilesRemoved as EventListener + ); }; }, [updateLibrary, updateGame]); @@ -226,7 +244,7 @@ export function HeroPanelActions() { onClick={() => setShowRepacksModal(true)} theme="outline" disabled={isGameDownloading} - className={`hero-panel-actions__action ${!repacks.length ? 'hero-panel-actions__action--disabled' : ''}`} + className={`hero-panel-actions__action ${repacks.length === 0 ? "hero-panel-actions__action--disabled" : ""}`} > {t("download")} 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/delete-review-modal.scss b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss new file mode 100644 index 00000000..34e916ad --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss @@ -0,0 +1,20 @@ +@use "../../../scss/globals.scss"; + +.delete-review-modal { + &__karma-warning { + background-color: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 4px; + padding: 12px; + margin-bottom: 16px; + color: #ffc107; + font-size: 14px; + font-weight: 500; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } +} diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx new file mode 100644 index 00000000..fe612bbd --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { Button, Modal } from "@renderer/components"; +import "./delete-review-modal.scss"; + +interface DeleteReviewModalProps { + visible: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteReviewModal({ + visible, + onClose, + onConfirm, +}: Readonly) { + const { t } = useTranslation("game_details"); + + const handleDeleteReview = () => { + onConfirm(); + onClose(); + }; + + return ( + +
    + + + +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index fec50c94..a6c32b6e 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -117,8 +117,6 @@ export function DownloadSettingsModal({ return userPreferences?.realDebridApiToken; if (downloader === Downloader.TorBox) return userPreferences?.torBoxApiToken; - if (downloader === Downloader.AllDebrid) - return userPreferences?.allDebridApiKey; if (downloader === Downloader.Hydra) return isFeatureEnabled(Feature.Nimbus); return true; @@ -133,7 +131,6 @@ export function DownloadSettingsModal({ downloaders, userPreferences?.realDebridApiToken, userPreferences?.torBoxApiToken, - userPreferences?.allDebridApiKey, ]); const handleChooseDownloadsPath = async () => { @@ -194,8 +191,6 @@ export function DownloadSettingsModal({ const shouldDisableButton = (downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || - (downloader === Downloader.AllDebrid && - !userPreferences?.allDebridApiKey) || (downloader === Downloader.TorBox && !userPreferences?.torBoxApiToken) || (downloader === Downloader.Hydra && diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss index 5400df07..33558601 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -55,11 +55,8 @@ border: 1px solid var(--color-border); border-radius: 8px; background-color: var(--color-background-secondary); - background-image: linear-gradient( - 45deg, - rgba(255, 255, 255, 0.1) 25%, - transparent 25% - ), + background-image: + linear-gradient(45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%), linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%); diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index a31ce400..8fbdf4a2 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -19,6 +19,68 @@ export interface EditGameModalProps { type AssetType = "icon" | "logo" | "hero"; +interface ElectronFile extends File { + path?: string; +} + +interface GameWithOriginalAssets extends Game { + originalIconPath?: string; + originalLogoPath?: string; + originalHeroPath?: string; +} + +interface LibraryGameWithCustomOriginalAssets extends LibraryGame { + customOriginalIconPath?: string; + customOriginalLogoPath?: string; + customOriginalHeroPath?: string; +} + +interface AssetPaths { + icon: string; + logo: string; + hero: string; +} + +interface AssetUrls { + icon: string | null; + logo: string | null; + hero: string | null; +} + +interface RemovedAssets { + icon: boolean; + logo: boolean; + hero: boolean; +} + +const VALID_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +] as const; + +const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const; + +const INITIAL_ASSET_PATHS: AssetPaths = { + icon: "", + logo: "", + hero: "", +}; + +const INITIAL_REMOVED_ASSETS: RemovedAssets = { + icon: false, + logo: false, + hero: false, +}; + +const INITIAL_ASSET_URLS: AssetUrls = { + icon: null, + logo: null, + hero: null, +}; + export function EditGameModal({ visible, onClose, @@ -30,33 +92,18 @@ export function EditGameModal({ const { showSuccessToast, showErrorToast } = useToast(); const [gameName, setGameName] = useState(""); - const [assetPaths, setAssetPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [assetDisplayPaths, setAssetDisplayPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [originalAssetPaths, setOriginalAssetPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [removedAssets, setRemovedAssets] = useState({ - icon: false, - logo: false, - hero: false, - }); - const [defaultUrls, setDefaultUrls] = useState({ - icon: null as string | null, - logo: null as string | null, - hero: null as string | null, - }); + const [assetPaths, setAssetPaths] = useState(INITIAL_ASSET_PATHS); + const [assetDisplayPaths, setAssetDisplayPaths] = + useState(INITIAL_ASSET_PATHS); + const [originalAssetPaths, setOriginalAssetPaths] = + useState(INITIAL_ASSET_PATHS); + const [removedAssets, setRemovedAssets] = useState( + INITIAL_REMOVED_ASSETS + ); + const [defaultUrls, setDefaultUrls] = useState(INITIAL_ASSET_URLS); const [isUpdating, setIsUpdating] = useState(false); const [selectedAssetType, setSelectedAssetType] = useState("icon"); + const [dragOverTarget, setDragOverTarget] = useState(null); const isCustomGame = (game: LibraryGame | Game): boolean => { return game.shop === "custom"; @@ -66,7 +113,19 @@ export function EditGameModal({ return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; + const capitalizeAssetType = (assetType: AssetType): string => { + return assetType.charAt(0).toUpperCase() + assetType.slice(1); + }; + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { + const gameWithAssets = game as GameWithOriginalAssets; + const iconRemoved = + !game.iconUrl && Boolean(gameWithAssets.originalIconPath); + const logoRemoved = + !game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath); + const heroRemoved = + !game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath); + setAssetPaths({ icon: extractLocalPath(game.iconUrl), logo: extractLocalPath(game.logoImageUrl), @@ -78,17 +137,33 @@ export function EditGameModal({ hero: extractLocalPath(game.libraryHeroImageUrl), }); setOriginalAssetPaths({ - icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl), + icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl), logo: - (game as any).originalLogoPath || extractLocalPath(game.logoImageUrl), + gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl), hero: - (game as any).originalHeroPath || + gameWithAssets.originalHeroPath || extractLocalPath(game.libraryHeroImageUrl), }); + + setRemovedAssets({ + icon: iconRemoved, + logo: logoRemoved, + hero: heroRemoved, + }); }, []); const setNonCustomGameAssets = useCallback( (game: LibraryGame) => { + const gameWithAssets = game as LibraryGameWithCustomOriginalAssets; + const iconRemoved = + !game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath); + const logoRemoved = + !game.customLogoImageUrl && + Boolean(gameWithAssets.customOriginalLogoPath); + const heroRemoved = + !game.customHeroImageUrl && + Boolean(gameWithAssets.customOriginalHeroPath); + setAssetPaths({ icon: extractLocalPath(game.customIconUrl), logo: extractLocalPath(game.customLogoImageUrl), @@ -101,16 +176,22 @@ export function EditGameModal({ }); setOriginalAssetPaths({ icon: - (game as any).customOriginalIconPath || + gameWithAssets.customOriginalIconPath || extractLocalPath(game.customIconUrl), logo: - (game as any).customOriginalLogoPath || + gameWithAssets.customOriginalLogoPath || extractLocalPath(game.customLogoImageUrl), hero: - (game as any).customOriginalHeroPath || + gameWithAssets.customOriginalHeroPath || extractLocalPath(game.customHeroImageUrl), }); + setRemovedAssets({ + icon: iconRemoved, + logo: logoRemoved, + hero: heroRemoved, + }); + setDefaultUrls({ icon: shopDetails?.assets?.iconUrl || game.iconUrl || null, logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null, @@ -143,25 +224,22 @@ export function EditGameModal({ setSelectedAssetType(assetType); }; - const getAssetPath = (assetType: AssetType): string => { - return assetPaths[assetType]; - }; - const getAssetDisplayPath = (assetType: AssetType): string => { - // Use original path if available, otherwise fall back to display path - return originalAssetPaths[assetType] || assetDisplayPaths[assetType]; + if (removedAssets[assetType]) { + return ""; + } + return assetDisplayPaths[assetType] || originalAssetPaths[assetType]; }; - const setAssetPath = (assetType: AssetType, path: string): void => { + const updateAssetPaths = ( + assetType: AssetType, + path: string, + displayPath: string + ): void => { setAssetPaths((prev) => ({ ...prev, [assetType]: path })); - }; - - const setAssetDisplayPath = (assetType: AssetType, path: string): void => { - setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path })); - }; - - const getDefaultUrl = (assetType: AssetType): string | null => { - return defaultUrls[assetType]; + setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath })); + setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath })); + setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); }; const getOriginalAssetUrl = (assetType: AssetType): string | null => { @@ -185,7 +263,7 @@ export function EditGameModal({ filters: [ { name: t("edit_game_modal_image_filter"), - extensions: ["jpg", "jpeg", "png", "gif", "webp"], + extensions: [...IMAGE_EXTENSIONS], }, ], }); @@ -197,48 +275,26 @@ export function EditGameModal({ originalPath, assetType ); - setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); - setAssetDisplayPath(assetType, originalPath); - // Store the original path for display purposes - setOriginalAssetPaths((prev) => ({ - ...prev, - [assetType]: originalPath, - })); - // Clear the removed flag when a new asset is selected - setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); + updateAssetPaths( + assetType, + copiedAssetUrl.replace("local:", ""), + originalPath + ); } catch (error) { console.error(`Failed to copy ${assetType} asset:`, error); - setAssetPath(assetType, originalPath); - setAssetDisplayPath(assetType, originalPath); - setOriginalAssetPaths((prev) => ({ - ...prev, - [assetType]: originalPath, - })); - // Clear the removed flag when a new asset is selected - setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); + updateAssetPaths(assetType, originalPath, originalPath); } } }; const handleRestoreDefault = (assetType: AssetType) => { - if (game && isCustomGame(game)) { - // For custom games, mark asset as removed and clear paths - setRemovedAssets((prev) => ({ ...prev, [assetType]: true })); - setAssetPath(assetType, ""); - setAssetDisplayPath(assetType, ""); - setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" })); - } else { - // For non-custom games, clear custom assets (restore to shop defaults) - setAssetPath(assetType, ""); - setAssetDisplayPath(assetType, ""); - setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" })); - } + setRemovedAssets((prev) => ({ ...prev, [assetType]: true })); + setAssetPaths((prev) => ({ ...prev, [assetType]: "" })); + setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" })); }; const getOriginalTitle = (): string => { if (!game) return ""; - - // For non-custom games, the original title is from shopDetails assets return shopDetails?.assets?.title || game.title || ""; }; @@ -249,12 +305,10 @@ export function EditGameModal({ const isTitleChanged = useMemo((): boolean => { if (!game || isCustomGame(game)) return false; - const originalTitle = getOriginalTitle(); + const originalTitle = shopDetails?.assets?.title || game.title || ""; return gameName.trim() !== originalTitle.trim(); }, [game, gameName, shopDetails]); - const [dragOverTarget, setDragOverTarget] = useState(null); - const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -275,14 +329,9 @@ export function EditGameModal({ }; const validateImageFile = (file: File): boolean => { - const validTypes = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - ]; - return validTypes.includes(file.type); + return VALID_IMAGE_TYPES.includes( + file.type as (typeof VALID_IMAGE_TYPES)[number] + ); }; const processDroppedFile = async (file: File, assetType: AssetType) => { @@ -296,10 +345,6 @@ export function EditGameModal({ try { let filePath: string; - interface ElectronFile extends File { - path?: string; - } - if ("path" in file && typeof (file as ElectronFile).path === "string") { filePath = (file as ElectronFile).path!; } else { @@ -326,12 +371,13 @@ export function EditGameModal({ assetType ); - const assetPath = copiedAssetUrl.replace("local:", ""); - setAssetPath(assetType, assetPath); - setAssetDisplayPath(assetType, filePath); - + updateAssetPaths( + assetType, + copiedAssetUrl.replace("local:", ""), + filePath + ); showSuccessToast( - `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` + `${capitalizeAssetType(assetType)} updated successfully!` ); if (!("path" in file) && filePath) { @@ -362,54 +408,53 @@ export function EditGameModal({ } }; - // Helper function to prepare custom game assets const prepareCustomGameAssets = (game: LibraryGame | Game) => { - // For custom games, check if asset was explicitly removed - let iconUrl; - if (removedAssets.icon) { - iconUrl = null; - } else if (assetPaths.icon) { - iconUrl = `local:${assetPaths.icon}`; - } else { - iconUrl = game.iconUrl; - } + const iconUrl = removedAssets.icon + ? null + : assetPaths.icon + ? `local:${assetPaths.icon}` + : game.iconUrl; - let logoImageUrl; - if (removedAssets.logo) { - logoImageUrl = null; - } else if (assetPaths.logo) { - logoImageUrl = `local:${assetPaths.logo}`; - } else { - logoImageUrl = game.logoImageUrl; - } + const logoImageUrl = removedAssets.logo + ? null + : assetPaths.logo + ? `local:${assetPaths.logo}` + : game.logoImageUrl; - // For hero image, if removed, restore to the original gradient or keep the original - let libraryHeroImageUrl; - if (removedAssets.hero) { - // If the original hero was a gradient (data URL), keep it, otherwise generate a new one - const originalHero = game.libraryHeroImageUrl; - libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml") - ? originalHero - : generateRandomGradient(); - } else { - libraryHeroImageUrl = assetPaths.hero + const libraryHeroImageUrl = removedAssets.hero + ? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml") + ? game.libraryHeroImageUrl + : generateRandomGradient() + : assetPaths.hero ? `local:${assetPaths.hero}` : game.libraryHeroImageUrl; - } return { iconUrl, logoImageUrl, libraryHeroImageUrl }; }; - // Helper function to prepare non-custom game assets const prepareNonCustomGameAssets = () => { + const customIconUrl = + !removedAssets.icon && assetPaths.icon + ? `local:${assetPaths.icon}` + : null; + + const customLogoImageUrl = + !removedAssets.logo && assetPaths.logo + ? `local:${assetPaths.logo}` + : null; + + const customHeroImageUrl = + !removedAssets.hero && assetPaths.hero + ? `local:${assetPaths.hero}` + : null; + return { - customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null, - customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null, - customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl, }; }; - // Helper function to update custom game const updateCustomGame = async (game: LibraryGame | Game) => { const { iconUrl, logoImageUrl, libraryHeroImageUrl } = prepareCustomGameAssets(game); @@ -427,7 +472,6 @@ export function EditGameModal({ }); }; - // Helper function to update non-custom game const updateNonCustomGame = async (game: LibraryGame) => { const { customIconUrl, customLogoImageUrl, customHeroImageUrl } = prepareNonCustomGameAssets(); @@ -439,9 +483,15 @@ export function EditGameModal({ customIconUrl, customLogoImageUrl, customHeroImageUrl, - customOriginalIconPath: originalAssetPaths.icon || undefined, - customOriginalLogoPath: originalAssetPaths.logo || undefined, - customOriginalHeroPath: originalAssetPaths.hero || undefined, + customOriginalIconPath: removedAssets.icon + ? undefined + : originalAssetPaths.icon || undefined, + customOriginalLogoPath: removedAssets.logo + ? undefined + : originalAssetPaths.logo || undefined, + customOriginalHeroPath: removedAssets.hero + ? undefined + : originalAssetPaths.hero || undefined, }); }; @@ -472,26 +522,17 @@ export function EditGameModal({ } }; - // Helper function to reset form to initial state const resetFormToInitialState = useCallback( (game: LibraryGame | Game) => { setGameName(game.title || ""); - - // Reset removed assets state - setRemovedAssets({ - icon: false, - logo: false, - hero: false, - }); + setRemovedAssets(INITIAL_REMOVED_ASSETS); + setAssetPaths(INITIAL_ASSET_PATHS); + setAssetDisplayPaths(INITIAL_ASSET_PATHS); + setOriginalAssetPaths(INITIAL_ASSET_PATHS); if (isCustomGame(game)) { setCustomGameAssets(game); - // Clear default URLs for custom games - setDefaultUrls({ - icon: null, - logo: null, - hero: null, - }); + setDefaultUrls(INITIAL_ASSET_URLS); } else { setNonCustomGameAssets(game as LibraryGame); } @@ -509,8 +550,8 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getPreviewUrl = (assetType: AssetType): string | undefined => { - const assetPath = getAssetPath(assetType); - const defaultUrl = getDefaultUrl(assetType); + const assetPath = assetPaths[assetType]; + const defaultUrl = defaultUrls[assetType]; if (game && !isCustomGame(game)) { return assetPath ? `local:${assetPath}` : defaultUrl || undefined; @@ -519,9 +560,9 @@ export function EditGameModal({ }; const renderImageSection = (assetType: AssetType) => { - const assetPath = getAssetPath(assetType); + const assetPath = assetPaths[assetType]; const assetDisplayPath = getAssetDisplayPath(assetType); - const defaultUrl = getDefaultUrl(assetType); + const defaultUrl = defaultUrls[assetType]; const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl); const isDragOver = dragOverTarget === assetType; diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 724e0003..d0f27b0f 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -2,3 +2,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; export * from "./edit-game-modal"; +export * from "./delete-review-modal"; 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 ec7dc3f8..306e8647 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,7 +15,6 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import { downloadSourcesTable } from "@renderer/dexie"; import type { DownloadSource } from "@types"; import type { GameRepack } from "@types"; @@ -55,7 +54,7 @@ export function RepacksModal({ {} ); - const { repacks, game } = useContext(gameDetailsContext); + const { game, repacks } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -89,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, @@ -104,23 +112,13 @@ export function RepacksModal({ ); }, [repacks, hashesInDebrid]); - useEffect(() => { - downloadSourcesTable.toArray().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); }); @@ -129,8 +127,9 @@ export function RepacksModal({ return downloadSources.some( (src) => + src.fingerprint && selectedFingerprints.includes(src.fingerprint) && - src.name === repack.repacker + src.name === repack.downloadSourceName ); }); @@ -161,6 +160,14 @@ export function RepacksModal({ const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); + useEffect(() => { + if (!visible) { + setFilterTerm(""); + setSelectedFingerprints([]); + setIsFilterDrawerOpen(false); + } + }, [visible]); + return ( <>
    - + {downloadSources.length > 0 && (
    @@ -262,7 +280,7 @@ export function RepacksModal({ )}

    - {repack.fileSize} - {repack.repacker} -{" "} + {repack.fileSize} - {repack.downloadSourceName} -{" "} {repack.uploadDate ? formatDate(repack.uploadDate) : ""}

    @@ -277,4 +295,4 @@ export function RepacksModal({ ); -} \ No newline at end of file +} 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 new file mode 100644 index 00000000..ffcad4e3 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-form.tsx @@ -0,0 +1,150 @@ +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; + reviewScore: number | null; + reviewCharCount: number; + maxReviewChars: number; + submittingReview: boolean; + onScoreChange: (score: number) => void; + onSubmit: () => void; +} + +const getSelectScoreColorClass = (score: number): string => { + if (score >= 1 && score <= 2) return "game-details__review-score-select--red"; + if (score >= 3 && score <= 3) + return "game-details__review-score-select--yellow"; + if (score >= 4 && score <= 5) + return "game-details__review-score-select--green"; + return ""; +}; + +const getRatingText = (score: number, t: (key: string) => string): string => { + switch (score) { + case 1: + return t("rating_very_negative"); + case 2: + return t("rating_negative"); + case 3: + return t("rating_neutral"); + case 4: + return t("rating_positive"); + case 5: + return t("rating_very_positive"); + default: + return ""; + } +}; + +export function ReviewForm({ + editor, + reviewScore, + reviewCharCount, + maxReviewChars, + submittingReview, + onScoreChange, + onSubmit, +}: Readonly) { + const { t } = useTranslation("game_details"); + + return ( + <> +
    +

    {t("leave_a_review")}

    +
    + +
    +
    +
    +
    + + + +
    +
    + maxReviewChars ? "over-limit" : ""} + > + {reviewCharCount}/{maxReviewChars} + +
    +
    +
    + +
    +
    + +
    +
    +
    + {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
    +
    + + +
    +
    + + ); +} diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss new file mode 100644 index 00000000..d4f2d38c --- /dev/null +++ b/src/renderer/src/pages/game-details/review-item.scss @@ -0,0 +1,230 @@ +@use "../../scss/globals.scss"; + +.game-details { + &__review-item { + overflow: hidden; + word-wrap: break-word; + } + + &__review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(globals.$spacing-unit * 1.5); + } + + &__review-user { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); + } + + &__review-user-info { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.25); + } + + &__review-display-name { + color: rgba(255, 255, 255, 0.9); + font-size: globals.$small-font-size; + font-weight: 600; + display: inline-flex; + + &--clickable { + cursor: pointer; + transition: color 0.2s ease; + + &:hover { + text-decoration: underline; + } + } + } + + &__review-actions { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + } + + &__review-votes { + display: flex; + gap: 12px; + } + + &__vote-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 6px 12px; + color: #ccc; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + } + + &--active { + &.game-details__vote-button--upvote { + svg { + fill: white; + } + } + + &.game-details__vote-button--downvote { + svg { + fill: white; + } + } + } + + span { + font-weight: 500; + display: inline-block; + min-width: 1ch; + overflow: hidden; + } + } + + &__delete-review-button { + display: flex; + align-items: center; + justify-content: center; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 6px; + padding: 6px; + color: #f44336; + cursor: pointer; + transition: all 0.2s ease; + gap: 6px; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: #f44336; + color: #ff5722; + } + } + + &__blocked-review-simple { + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); + } + + &__blocked-review-show-link { + background: none; + border: none; + color: #ffc107; + font-size: globals.$small-font-size; + cursor: pointer; + text-decoration: underline; + padding: 0; + transition: color 0.2s ease; + + &:hover { + color: #ffeb3b; + } + } + + &__blocked-review-hide-link { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: globals.$small-font-size; + cursor: pointer; + text-decoration: underline; + padding: 0; + transition: color 0.2s ease; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + } + + &__review-score-stars { + display: flex; + align-items: center; + gap: 2px; + } + + &__review-star { + color: #666666; + transition: color 0.2s ease; + cursor: default; + + &--filled { + color: #ffffff; + + &.game-details__review-score--red { + color: #fca5a5; + } + + &.game-details__review-score--yellow { + color: #fcd34d; + } + + &.game-details__review-score--green { + color: #86efac; + } + } + + &--empty { + color: #666666; + } + + svg { + fill: currentColor; + } + } + + &__review-date { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; + } + + &__review-content { + color: globals.$body-color; + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + max-width: 100%; + } + + &__review-translation-toggle { + display: inline-flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); + margin-top: calc(globals.$spacing-unit * 1.5); + padding: 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; + + &:hover { + text-decoration: underline; + color: rgba(255, 255, 255, 0.9); + } + } +} diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx new file mode 100644 index 00000000..f5e3528a --- /dev/null +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -0,0 +1,332 @@ +import { TrashIcon, ClockIcon } from "@primer/octicons-react"; +import { ThumbsUp, ThumbsDown, Star, Languages } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { GameReview } from "@types"; + +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { formatNumber } from "@renderer/helpers"; +import { Avatar } from "@renderer/components"; + +import "./review-item.scss"; + +interface ReviewItemProps { + review: GameReview; + userDetailsId?: string; + isBlocked: boolean; + isVisible: boolean; + isVoting: boolean; + previousVotes: { upvotes: number; downvotes: number }; + onVote: (reviewId: string, voteType: "upvote" | "downvote") => void; + onDelete: (reviewId: string) => void; + onToggleVisibility: (reviewId: string) => void; + onAnimationComplete: ( + reviewId: string, + votes: { upvotes: number; downvotes: number } + ) => 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: + return t("rating_very_negative"); + case 2: + return t("rating_negative"); + case 3: + return t("rating_neutral"); + case 4: + return t("rating_positive"); + case 5: + return t("rating_very_positive"); + default: + return ""; + } +}; + +export function ReviewItem({ + review, + userDetailsId, + isBlocked, + isVisible, + isVoting, + previousVotes, + onVote, + onDelete, + onToggleVisibility, + onAnimationComplete, +}: Readonly) { + const navigate = useNavigate(); + const { t, i18n } = useTranslation("game_details"); + const { formatDistance } = useDate(); + + 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]; + + // 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]; + + // Get the full language name using Intl.DisplayNames + const getLanguageName = (languageCode: string) => { + try { + const displayNames = new Intl.DisplayNames([i18n.language], { + type: "language", + }); + return displayNames.of(languageCode) || languageCode.toUpperCase(); + } catch { + return languageCode.toUpperCase(); + } + }; + + // Determine which content to show - always show original for own reviews + const displayContent = needsTranslation + ? review.translations[i18n.language] + : review.reviewHtml; + + if (isBlocked && !isVisible) { + return ( +
    +
    + Review from blocked user —{" "} + +
    +
    + ); + } + + return ( +
    +
    +
    + +
    + +
    + + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
    +
    +
    +
    + {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
    +
    +
    +
    + {needsTranslation && ( + <> + + {showOriginal && ( +
    + )} + + )} +
    +
    +
    + onVote(review.id, "upvote")} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + animate={ + review.hasUpvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + previousVotes.upvotes} + variants={{ + enter: (isIncreasing: boolean) => ({ + y: isIncreasing ? 10 : -10, + opacity: 0, + }), + center: { y: 0, opacity: 1 }, + exit: (isIncreasing: boolean) => ({ + y: isIncreasing ? -10 : 10, + opacity: 0, + }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 0.2 }} + onAnimationComplete={() => { + onAnimationComplete(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + }} + > + {formatNumber(review.upvotes || 0)} + + + + onVote(review.id, "downvote")} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + animate={ + review.hasDownvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + previousVotes.downvotes} + variants={{ + enter: (isIncreasing: boolean) => ({ + y: isIncreasing ? 10 : -10, + opacity: 0, + }), + center: { y: 0, opacity: 1 }, + exit: (isIncreasing: boolean) => ({ + y: isIncreasing ? -10 : 10, + opacity: 0, + }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 0.2 }} + onAnimationComplete={() => { + onAnimationComplete(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + }} + > + {formatNumber(review.downvotes || 0)} + + + +
    + {userDetailsId === review.user.id && ( + + )} + {isBlocked && isVisible && ( + + )} +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.scss b/src/renderer/src/pages/game-details/review-prompt-banner.scss new file mode 100644 index 00000000..f9358e52 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-prompt-banner.scss @@ -0,0 +1,46 @@ +@use "../../scss/globals.scss"; + +.review-prompt-banner { + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; + padding: calc(globals.$spacing-unit * 2); + margin-bottom: calc(globals.$spacing-unit * 1.5); + border: 1px solid rgba(255, 255, 255, 0.05); + + &__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(globals.$spacing-unit * 2.5); + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: calc(globals.$spacing-unit * 2); + } + } + + &__text { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.5); + } + + &__playtime { + font-size: globals.$body-font-size; + color: globals.$body-color; + font-weight: 600; + } + + &__question { + font-size: globals.$small-font-size; + color: globals.$muted-color; + font-weight: 400; + } + + &__actions { + display: flex; + gap: globals.$spacing-unit; + align-items: center; + } +} diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx new file mode 100644 index 00000000..053c97e8 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@renderer/components"; +import "./review-prompt-banner.scss"; + +interface ReviewPromptBannerProps { + onYesClick: () => void; + onLaterClick: () => void; +} + +export function ReviewPromptBanner({ + onYesClick, + onLaterClick, +}: Readonly) { + const { t } = useTranslation("game_details"); + + return ( +
    +
    +
    + + {t("you_seemed_to_enjoy_this_game")} + + + {t("would_you_recommend_this_game")} + +
    +
    + + +
    +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss new file mode 100644 index 00000000..fba6c50f --- /dev/null +++ b/src/renderer/src/pages/game-details/review-sort-options.scss @@ -0,0 +1,73 @@ +@use "../../scss/globals.scss"; + +.review-sort-options { + &__container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + margin-bottom: calc(globals.$spacing-unit * 3); + } + + &__label { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + font-weight: 400; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 14px; + flex-wrap: wrap; + + @media (max-width: 768px) { + gap: calc(globals.$spacing-unit * 0.75); + } + } + + &__option { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + padding: 4px 0; + font-size: 14px; + font-weight: 300; + transition: all ease 0.2s; + display: flex; + align-items: center; + gap: 4px; + + &:hover:not(:disabled) { + color: rgba(255, 255, 255, 0.6); + } + + &.active { + color: rgba(255, 255, 255, 0.9); + font-weight: 500; + } + + span { + display: inline-block; + + @media (max-width: 480px) { + display: none; + } + } + + @media (max-width: 480px) { + gap: 0; + } + } + + &__separator { + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + + @media (max-width: 480px) { + display: none; + } + } +} diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx new file mode 100644 index 00000000..0944e58e --- /dev/null +++ b/src/renderer/src/pages/game-details/review-sort-options.tsx @@ -0,0 +1,90 @@ +import { + ThumbsupIcon, + ChevronUpIcon, + ChevronDownIcon, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./review-sort-options.scss"; + +type ReviewSortOption = + | "newest" + | "oldest" + | "score_high" + | "score_low" + | "most_voted"; + +interface ReviewSortOptionsProps { + sortBy: ReviewSortOption; + onSortChange: (sortBy: ReviewSortOption) => void; +} + +export function ReviewSortOptions({ + sortBy, + onSortChange, +}: Readonly) { + const { t } = useTranslation("game_details"); + + const handleDateToggle = () => { + const newSort = sortBy === "newest" ? "oldest" : "newest"; + onSortChange(newSort); + }; + + const handleScoreToggle = () => { + const newSort = sortBy === "score_high" ? "score_low" : "score_high"; + onSortChange(newSort); + }; + + const handleMostVotedClick = () => { + if (sortBy !== "most_voted") { + onSortChange("most_voted"); + } + }; + + const isDateActive = sortBy === "newest" || sortBy === "oldest"; + const isScoreActive = sortBy === "score_high" || sortBy === "score_low"; + const isMostVotedActive = sortBy === "most_voted"; + + return ( +
    +
    + + | + + | + +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx index 9a29f150..61c90389 100644 --- a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx @@ -25,7 +25,7 @@ export function HowLongToBeatSection({ return `${value} ${t(durationTranslation[unit])}`; }; - if (!howLongToBeatData || !isLoading) return null; + if (!howLongToBeatData && !isLoading) return null; return ( diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss index 06519f6c..1330d278 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.scss +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss @@ -106,7 +106,7 @@ .stats { &__section { display: flex; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 1); padding: calc(globals.$spacing-unit * 2); justify-content: space-between; transition: max-height ease 0.5s; @@ -115,10 +115,6 @@ @media (min-width: 1024px) { flex-direction: column; } - - @media (min-width: 1280px) { - flex-direction: row; - } } &__category-title { diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 0a24c418..3056e414 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -5,7 +5,7 @@ import type { UserAchievement, } from "@types"; import { useTranslation } from "react-i18next"; -import { Button, Link } from "@renderer/components"; +import { Button, Link, StarRating } from "@renderer/components"; import { gameDetailsContext } from "@renderer/context"; import { useDate, useFormat, useUserDetails } from "@renderer/hooks"; @@ -14,9 +14,9 @@ import { DownloadIcon, LockIcon, PeopleIcon, + StarIcon, } from "@primer/octicons-react"; import { HowLongToBeatSection } from "./how-long-to-beat-section"; -import { howLongToBeatEntriesTable } from "@renderer/dexie"; import { SidebarSection } from "../sidebar-section/sidebar-section"; import { buildGameAchievementPath } from "@renderer/helpers"; import { useSubscription } from "@renderer/hooks/use-subscription"; @@ -79,40 +79,22 @@ export function Sidebar() { if (objectId) { setHowLongToBeat({ isLoading: true, data: null }); - howLongToBeatEntriesTable - .where({ shop, objectId }) - .first() - .then(async (cachedHowLongToBeat) => { - if (cachedHowLongToBeat) { - setHowLongToBeat({ - isLoading: false, - data: cachedHowLongToBeat.categories, - }); - } else { - try { - const howLongToBeat = await window.electron.getHowLongToBeat( - objectId, - shop - ); - - if (howLongToBeat) { - howLongToBeatEntriesTable.add({ - objectId, - shop: "steam", - createdAt: new Date(), - updatedAt: new Date(), - categories: howLongToBeat, - }); - } - - setHowLongToBeat({ isLoading: false, data: howLongToBeat }); - } catch (err) { - setHowLongToBeat({ isLoading: false, data: null }); - } + // Directly fetch from API without checking cache + window.electron.hydraApi + .get( + `/games/${shop}/${objectId}/how-long-to-beat`, + { + needsAuth: false, } + ) + .then((howLongToBeatData) => { + setHowLongToBeat({ isLoading: false, data: howLongToBeatData }); + }) + .catch(() => { + setHowLongToBeat({ isLoading: false, data: null }); }); } - }, [objectId, shop, gameTitle]); + }, [objectId, shop]); return (
    + +
    +

    + + {t("rating_count")} +

    + +
    )} diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index 0b762882..b8f632a6 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -40,7 +40,21 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const catalogue = await window.electron.getCatalogue(category); + 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, + needsAuth: false, + } + ); setCatalogue((prev) => ({ ...prev, [category]: catalogue })); } finally { diff --git a/src/renderer/src/pages/profile/profile-content/profile-animations.ts b/src/renderer/src/pages/profile/profile-content/profile-animations.ts index f9ac0593..d89f4323 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-animations.ts +++ b/src/renderer/src/pages/profile/profile-content/profile-animations.ts @@ -5,7 +5,7 @@ export const sectionVariants = { height: 0, transition: { duration: 0.3, - ease: [0.25, 0.1, 0.25, 1], + ease: [0.25, 0.1, 0.25, 1] as const, opacity: { duration: 0.1 }, y: { duration: 0.1 }, height: { duration: 0.2 }, @@ -17,13 +17,13 @@ export const sectionVariants = { height: "auto", transition: { duration: 0.3, - ease: [0.25, 0.1, 0.25, 1], + ease: [0.25, 0.1, 0.25, 1] as const, opacity: { duration: 0.2, delay: 0.1 }, y: { duration: 0.3 }, height: { duration: 0.3 }, }, }, -}; +} as const; export const gameCardVariants = { hidden: { @@ -37,7 +37,7 @@ export const gameCardVariants = { scale: 1, transition: { duration: 0.4, - ease: [0.25, 0.1, 0.25, 1], + ease: [0.25, 0.1, 0.25, 1] as const, }, }, exit: { @@ -46,10 +46,10 @@ export const gameCardVariants = { scale: 0.95, transition: { duration: 0.3, - ease: [0.25, 0.1, 0.25, 1], + ease: [0.25, 0.1, 0.25, 1] as const, }, }, -}; +} as const; export const gameGridVariants = { hidden: { @@ -76,16 +76,16 @@ export const chevronVariants = { rotate: 0, transition: { duration: 0.2, - ease: "easeInOut", + ease: "easeInOut" as const, }, }, expanded: { rotate: 90, transition: { duration: 0.2, - ease: "easeInOut", + ease: "easeInOut" as const, }, }, -}; +} as const; export const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500; diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 56f7d20b..d2f1f074 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -10,6 +10,7 @@ import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; +import { UserKarmaBox } from "./user-karma-box"; import { UserLibraryGameCard } from "./user-library-game-card"; import { SortOptions } from "./sort-options"; import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; @@ -223,6 +224,7 @@ export function ProfileContent() { {shouldShowRightContent && (
    + diff --git a/src/renderer/src/pages/profile/profile-content/sort-options.tsx b/src/renderer/src/pages/profile/profile-content/sort-options.tsx index 53da8e40..607e54b9 100644 --- a/src/renderer/src/pages/profile/profile-content/sort-options.tsx +++ b/src/renderer/src/pages/profile/profile-content/sort-options.tsx @@ -14,7 +14,7 @@ export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) { return (
    - Sort by: + {t("sort_by")}
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index a3d24958..72b48a8c 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -234,7 +234,7 @@ export function UserLibraryGameCard({
    {game.title} diff --git a/src/renderer/src/pages/profile/report-profile/report-profile.tsx b/src/renderer/src/pages/profile/report-profile/report-profile.tsx index 40084aba..67b7a72a 100644 --- a/src/renderer/src/pages/profile/report-profile/report-profile.tsx +++ b/src/renderer/src/pages/profile/report-profile/report-profile.tsx @@ -54,8 +54,13 @@ export function ReportProfile() { const onSubmit = useCallback( async (values: FormValues) => { - return window.electron - .reportUser(userProfile!.id, values.reason, values.description) + return window.electron.hydraApi + .post(`/users/${userProfile!.id}/report`, { + data: { + reason: values.reason, + description: values.description, + }, + }) .then(() => { showSuccessToast(t("profile_reported")); setShowReportProfileModal(false); 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 f6b5d151..d938f7f0 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.scss +++ b/src/renderer/src/pages/settings/add-download-source-modal.scss @@ -1,5 +1,14 @@ @use "../../scss/globals.scss"; +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .add-download-source-modal { &__container { display: flex; @@ -24,4 +33,16 @@ &__validate-button { align-self: flex-end; } + + &__spinner { + 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 fee1c5e3..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,14 @@ -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 { logger } from "@renderer/logger"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; -import { downloadSourcesTable } from "@renderer/dexie"; -import type { DownloadSourceValidationResult } from "@types"; -import { downloadSourcesWorker } from "@renderer/workers"; +import { SyncIcon } from "@primer/octicons-react"; import "./add-download-source-modal.scss"; interface AddDownloadSourceModalProps { @@ -27,7 +26,6 @@ export function AddDownloadSourceModal({ onClose, onAddDownloadSource, }: Readonly) { - const [url, setUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation("settings"); @@ -47,89 +45,45 @@ export function AddDownloadSourceModal({ resolver: yupResolver(schema), }); - const [validationResult, setValidationResult] = - useState(null); - const { sourceUrl } = useContext(settingsContext); - const onSubmit = useCallback( - async (values: FormValues) => { - const existingDownloadSource = await downloadSourcesTable - .where({ url: values.url }) - .first(); + const onSubmit = async (values: FormValues) => { + setIsLoading(true); - if (existingDownloadSource) { - setError("url", { - type: "server", - message: t("source_already_exists"), - }); + try { + await window.electron.addDownloadSource(values.url); - return; - } + 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"); - downloadSourcesWorker.postMessage([ - "VALIDATE_DOWNLOAD_SOURCE", - values.url, - ]); - - const channel = new BroadcastChannel( - `download_sources:validate:${values.url}` - ); - - channel.onmessage = ( - event: MessageEvent - ) => { - setValidationResult(event.data); - channel.close(); - }; - - 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]); + }, [visible, clearErrors, setValue, sourceUrl]); - const putDownloadSource = async () => { - const downloadSource = await downloadSourcesTable.where({ url }).first(); - if (!downloadSource) return; - - window.electron - .putDownloadSource(downloadSource.objectIds) - .then(({ fingerprint }) => { - downloadSourcesTable.update(downloadSource.id, { fingerprint }); - }); - }; - - const handleAddDownloadSource = async () => { - if (validationResult) { - setIsLoading(true); - - const channel = new BroadcastChannel(`download_sources:import:${url}`); - - downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); - - channel.onmessage = () => { - window.electron.createDownloadSources([url]); - setIsLoading(false); - - putDownloadSource(); - - onClose(); - onAddDownloadSource(); - channel.close(); - }; - } + const handleClose = () => { + if (isLoading) return; + onClose(); }; return ( @@ -137,49 +91,36 @@ export function AddDownloadSourceModal({ visible={visible} title={t("add_download_source")} description={t("add_download_source_description")} - onClose={onClose} + onClose={handleClose} + clickOutsideToClose={!isLoading} >
    - + + +
    - } - /> - - {validationResult && ( -
    -
    -

    {validationResult?.name}

    - - {t("found_download_option", { - count: validationResult?.downloadCount, - countFormatted: - validationResult?.downloadCount.toLocaleString(), - })} - -
    - - + +
    - )} +
    ); diff --git a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx index 58bcf58d..522d8546 100644 --- a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx @@ -9,6 +9,7 @@ import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { useCallback } from "react"; +import { generateUUID } from "@renderer/helpers"; import "./modals.scss"; @@ -79,7 +80,7 @@ export function AddThemeModal({ const onSubmit = useCallback( async (values: FormValues) => { const theme: Theme = { - id: crypto.randomUUID(), + id: generateUUID(), name: values.name, isActive: false, author: userDetails?.id, diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 601e9568..516f320f 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -3,7 +3,11 @@ import { Modal } from "@renderer/components/modal/modal"; import { useTranslation } from "react-i18next"; import "./modals.scss"; import { Theme } from "@types"; -import { injectCustomCss, removeCustomCss } from "@renderer/helpers"; +import { + injectCustomCss, + removeCustomCss, + generateUUID, +} from "@renderer/helpers"; import { useToast } from "@renderer/hooks"; import { THEME_WEB_STORE_URL } from "@renderer/constants"; import { logger } from "@renderer/logger"; @@ -30,7 +34,7 @@ export const ImportThemeModal = ({ const handleImportTheme = async () => { const theme: Theme = { - id: crypto.randomUUID(), + id: generateUUID(), name: themeName, isActive: false, author: authorId, 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-all-debrid.scss b/src/renderer/src/pages/settings/settings-all-debrid.scss deleted file mode 100644 index 4427ca7d..00000000 --- a/src/renderer/src/pages/settings/settings-all-debrid.scss +++ /dev/null @@ -1,12 +0,0 @@ -.settings-all-debrid { - &__form { - display: flex; - flex-direction: column; - gap: 1rem; - } - - &__description { - margin: 0; - color: var(--text-secondary); - } -} diff --git a/src/renderer/src/pages/settings/settings-all-debrid.tsx b/src/renderer/src/pages/settings/settings-all-debrid.tsx deleted file mode 100644 index aa821bc1..00000000 --- a/src/renderer/src/pages/settings/settings-all-debrid.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useContext, useEffect, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; - -import { Button, CheckboxField, Link, TextField } from "@renderer/components"; -import "./settings-all-debrid.scss"; - -import { useAppSelector, useToast } from "@renderer/hooks"; - -import { settingsContext } from "@renderer/context"; - -const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys"; - -export function SettingsAllDebrid() { - const userPreferences = useAppSelector( - (state) => state.userPreferences.value - ); - - const { updateUserPreferences } = useContext(settingsContext); - - const [isLoading, setIsLoading] = useState(false); - const [form, setForm] = useState({ - useAllDebrid: false, - allDebridApiKey: null as string | null, - }); - - const { showSuccessToast, showErrorToast } = useToast(); - - const { t } = useTranslation("settings"); - - useEffect(() => { - if (userPreferences) { - setForm({ - useAllDebrid: Boolean(userPreferences.allDebridApiKey), - allDebridApiKey: userPreferences.allDebridApiKey ?? null, - }); - } - }, [userPreferences]); - - const handleFormSubmit: React.FormEventHandler = async ( - event - ) => { - setIsLoading(true); - event.preventDefault(); - - try { - if (form.useAllDebrid) { - if (!form.allDebridApiKey) { - showErrorToast(t("alldebrid_missing_key")); - return; - } - - const result = await window.electron.authenticateAllDebrid( - form.allDebridApiKey - ); - - if ("error_code" in result) { - showErrorToast(t(result.error_code)); - return; - } - - if (!result.isPremium) { - showErrorToast( - t("all_debrid_free_account_error", { username: result.username }) - ); - return; - } - - showSuccessToast( - t("all_debrid_account_linked"), - t("debrid_linked_message", { username: result.username }) - ); - } else { - showSuccessToast(t("changes_saved")); - } - - updateUserPreferences({ - allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null, - }); - } catch (err: any) { - showErrorToast(t("alldebrid_unknown_error")); - } finally { - setIsLoading(false); - } - }; - - const isButtonDisabled = - (form.useAllDebrid && !form.allDebridApiKey) || isLoading; - - return ( -
    -

    - {t("all_debrid_description")} -

    - - - setForm((prev) => ({ - ...prev, - useAllDebrid: !form.useAllDebrid, - })) - } - /> - - {form.useAllDebrid && ( - - setForm({ ...form, allDebridApiKey: event.target.value }) - } - rightContent={ - - } - placeholder="API Key" - hint={ - - - - } - /> - )} - - ); -} 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 }) + } + /> + state.userPreferences.value + ); + + const initialCollapseState = useMemo(() => { + return { + torbox: !userPreferences?.torBoxApiToken, + realDebrid: !userPreferences?.realDebridApiToken, + }; + }, [userPreferences]); + + const [collapseState, setCollapseState] = + useState(initialCollapseState); + + const toggleSection = useCallback((section: keyof CollapseState) => { + setCollapseState((prevState) => ({ + ...prevState, + [section]: !prevState[section], + })); + }, []); + + return ( +
    +

    {t("debrid_description")}

    + +
    +
    + +

    Real-Debrid

    + {userPreferences?.realDebridApiToken && ( + + )} +
    + + + {!collapseState.realDebrid && ( + + + + )} + +
    + + {isTorBoxEnabled && ( +
    +
    + +

    TorBox

    + {userPreferences?.torBoxApiToken && ( + + )} +
    + + + {!collapseState.torbox && ( + + + + )} + +
    + )} +
    + ); +} diff --git a/src/renderer/src/pages/settings/settings-download-sources.scss b/src/renderer/src/pages/settings/settings-download-sources.scss index a12bdff3..df0f5c8b 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.scss +++ b/src/renderer/src/pages/settings/settings-download-sources.scss @@ -1,5 +1,14 @@ @use "../../scss/globals.scss"; +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .settings-download-sources { &__list { padding: 0; @@ -22,6 +31,17 @@ &--syncing { opacity: globals.$disabled-opacity; } + + &--pending { + opacity: 0.6; + } + } + + &__spinner { + animation: spin 1s linear infinite; + margin-right: calc(globals.$spacing-unit / 2); + width: 12px; + height: 12px; } &__item-header { diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index fa30dfa1..75f0cc73 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -16,14 +16,13 @@ import { TrashIcon, } from "@primer/octicons-react"; import { AddDownloadSourceModal } from "./add-download-source-modal"; -import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; +import { useAppDispatch, useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; -import { downloadSourcesTable } from "@renderer/dexie"; -import { downloadSourcesWorker } from "@renderer/workers"; import { useNavigate } from "react-router-dom"; import { setFilters, clearFilters } from "@renderer/features"; import "./settings-download-sources.scss"; +import { logger } from "@renderer/logger"; export function SettingsDownloadSources() { const [ @@ -37,7 +36,6 @@ export function SettingsDownloadSources() { useState(false); const [isRemovingDownloadSource, setIsRemovingDownloadSource] = useState(false); - const [isFetchingSources, setIsFetchingSources] = useState(true); const { sourceUrl, clearSourceUrl } = useContext(settingsContext); @@ -48,95 +46,103 @@ export function SettingsDownloadSources() { const navigate = useNavigate(); - const { updateRepacks } = useRepacks(); - - const getDownloadSources = async () => { - await downloadSourcesTable - .toCollection() - .sortBy("createdAt") - .then((sources) => { - setDownloadSources(sources.reverse()); - }) - .finally(() => { - setIsFetchingSources(false); - }); - }; - - useEffect(() => { - getDownloadSources(); - }, []); - useEffect(() => { if (sourceUrl) setShowAddDownloadSourceModal(true); }, [sourceUrl]); - const handleRemoveSource = (downloadSource: DownloadSource) => { - setIsRemovingDownloadSource(true); - const channel = new BroadcastChannel( - `download_sources:delete:${downloadSource.id}` + 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 ); - downloadSourcesWorker.postMessage([ - "DELETE_DOWNLOAD_SOURCE", - downloadSource.id, - ]); + if (!hasPendingOrMatchingSource || !downloadSources.length) { + return; + } - channel.onmessage = () => { - showSuccessToast(t("removed_download_source")); - window.electron.removeDownloadSource(downloadSource.url); + 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); - getDownloadSources(); - setIsRemovingDownloadSource(false); - channel.close(); - updateRepacks(); - }; - }; + return () => clearInterval(intervalId); + }, [downloadSources]); - const handleRemoveAllDownloadSources = () => { + const handleRemoveSource = async (downloadSource: DownloadSource) => { setIsRemovingDownloadSource(true); - const id = crypto.randomUUID(); - const channel = new BroadcastChannel(`download_sources:delete_all:${id}`); + try { + await window.electron.removeDownloadSource(false, downloadSource.id); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + showSuccessToast(t("removed_download_source")); + } catch (error) { + logger.error("Failed to remove download source:", error); + } finally { + setIsRemovingDownloadSource(false); + } + }; - downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]); + const handleRemoveAllDownloadSources = async () => { + setIsRemovingDownloadSource(true); - channel.onmessage = () => { - showSuccessToast(t("removed_download_sources")); - window.electron.removeDownloadSource("", true); - getDownloadSources(); + try { + await window.electron.removeDownloadSource(true); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + showSuccessToast(t("removed_all_download_sources")); + } catch (error) { + logger.error("Failed to remove all download sources:", error); + } finally { setIsRemovingDownloadSource(false); setShowConfirmationDeleteAllSourcesModal(false); - channel.close(); - updateRepacks(); - }; + } }; const handleAddDownloadSource = async () => { - await getDownloadSources(); - showSuccessToast(t("added_download_source")); - updateRepacks(); + try { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + } catch (error) { + logger.error("Failed to refresh download sources:", error); + } }; const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); + try { + await window.electron.syncDownloadSources(); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); - const id = crypto.randomUUID(); - const channel = new BroadcastChannel(`download_sources:sync:${id}`); - - downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); - - channel.onmessage = () => { - showSuccessToast(t("download_sources_synced")); - getDownloadSources(); + showSuccessToast(t("download_sources_synced_successfully")); + } finally { setIsSyncingDownloadSources(false); - channel.close(); - updateRepacks(); - }; + } }; 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 = () => { @@ -144,7 +150,12 @@ export function SettingsDownloadSources() { setShowAddDownloadSourceModal(false); }; - const navigateToCatalogue = (fingerprint: string) => { + const navigateToCatalogue = (fingerprint?: string) => { + if (!fingerprint) { + logger.error("Cannot navigate: fingerprint is undefined"); + return; + } + dispatch(clearFilters()); dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] })); @@ -179,8 +190,7 @@ export function SettingsDownloadSources() { disabled={ !downloadSources.length || isSyncingDownloadSources || - isRemovingDownloadSource || - isFetchingSources + isRemovingDownloadSource } onClick={syncDownloadSources} > @@ -196,8 +206,7 @@ export function SettingsDownloadSources() { disabled={ isRemovingDownloadSource || isSyncingDownloadSources || - !downloadSources.length || - isFetchingSources + !downloadSources.length } > @@ -208,11 +217,7 @@ export function SettingsDownloadSources() { type="button" theme="outline" onClick={() => setShowAddDownloadSourceModal(true)} - disabled={ - isSyncingDownloadSources || - isFetchingSources || - isRemovingDownloadSource - } + disabled={isSyncingDownloadSources || isRemovingDownloadSource} > {t("add_download_source")} @@ -221,54 +226,69 @@ export function SettingsDownloadSources() {
      - {downloadSources.map((downloadSource) => ( -
    • -
      -

      {downloadSource.name}

      + {downloadSources.map((downloadSource) => { + const isPendingOrMatching = + downloadSource.status === DownloadSourceStatus.PendingMatching || + downloadSource.status === DownloadSourceStatus.Matching; -
      - {statusTitle[downloadSource.status]} + return ( +
    • +
      +

      {downloadSource.name}

      + +
      + + {isPendingOrMatching && ( + + )} + {statusTitle[downloadSource.status]} + +
      + +
      - -
    - - handleRemoveSource(downloadSource)} - disabled={isRemovingDownloadSource} - > - - {t("remove_download_source")} - - } - /> - - ))} + handleRemoveSource(downloadSource)} + disabled={isRemovingDownloadSource} + > + + {t("remove_download_source")} + + } + /> + + ); + })} ); 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={