diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..5015ab7e --- /dev/null +++ b/.cursorrules @@ -0,0 +1,37 @@ +# Hydra Project Rules + +## Logging + +- **Always use `logger` instead of `console` for logging** in both main and renderer processes +- In main process: `import { logger } from "@main/services";` +- In renderer process: `import { logger } from "@renderer/logger";` +- Replace all instances of: + - `console.log()` → `logger.log()` + - `console.error()` → `logger.error()` + - `console.warn()` → `logger.warn()` + - `console.info()` → `logger.info()` + - `console.debug()` → `logger.debug()` +- Do not use `console` for any logging purposes + +## Internationalization (i18n) + +- All user-facing strings must be translated using i18next +- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");` +- Add new translation keys to `src/locales/en/translation.json` +- Never hardcode English strings in the UI code +- Placeholder text in form fields must also be translated + +## Code Style + +- Use ESLint and Prettier for code formatting +- Follow TypeScript strict mode conventions +- Use async/await instead of promises when possible +- Prefer named exports over default exports for utilities and services + +## Comments + +- Keep comments concise and purposeful; avoid verbose explanations. +- Focus on the "why" or non-obvious context, not restating the code. +- Prefer self-explanatory naming and structure over excessive comments. +- Do not comment every line or obvious behavior; remove stale comments. +- Use docblocks only where they add value (public APIs, complex logic). diff --git a/.env.example b/.env.example index 3f914eb3..051d8aa3 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL= MAIN_VITE_WS_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= +MAIN_VITE_LAUNCHER_SUBDOMAIN= diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 6aefac43..34f7d303 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -6,23 +6,37 @@ concurrency: on: push: - branches: main + branches: + - release/** jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + + env: + NODE_OPTIONS: --max-old-space-size=4096 + BRANCH_NAME: ${{ github.ref_name }} + steps: - name: Check out Git repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Install Node.js + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.0 + node-version: 22.21.0 + cache: "yarn" + + - name: Enable Corepack (Yarn) + run: corepack enable - name: Install dependencies - run: yarn --frozen-lockfile --ignore-scripts + run: yarn install --frozen-lockfile --ignore-scripts - name: Build Renderer run: yarn build @@ -36,5 +50,5 @@ jobs: run: | npx --yes wrangler@3 pages deploy out/renderer \ --project-name="hydra" \ - --commit-dirty=true \ - --branch="main" + --branch "$BRANCH_NAME" \ + --commit-dirty diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5062c7ad..92fcebc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,12 @@ name: Build +on: + pull_request: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -on: pull_request - jobs: build: strategy: @@ -22,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile @@ -38,6 +39,12 @@ 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: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac359364..89e8b59f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ceb42c7..df01b358 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ concurrency: on: push: - branches: [main] + branches: + - release/** jobs: build: @@ -23,7 +24,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile @@ -39,6 +40,12 @@ 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: | @@ -55,7 +62,7 @@ jobs: RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} - MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }} + MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Build Windows if: matrix.os == 'windows-2022' @@ -72,7 +79,7 @@ jobs: RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} - MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }} + MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Create artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index 2a3583bc..fa12b500 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -95,9 +95,12 @@ jobs: - name: Update PKGBUILD and .SRCINFO if: steps.check-update.outputs.update_needed == 'true' run: | + # sleeps for 1 minute to be sure GH updated the release info + sleep 60 # Update pkgver in PKGBUILD cd hydra-launcher-bin NEW_VERSION="${{ steps.get-version.outputs.version }}" + NEW_VERSION="${NEW_VERSION#v}" echo "Updating PKGBUILD pkgver to $NEW_VERSION" @@ -137,6 +140,9 @@ jobs: 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 diff --git a/package.json b/package.json index 342b078a..e2fec5ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.1", + "version": "3.7.4", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -75,6 +75,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", "react-router-dom": "^6.22.3", @@ -90,8 +91,7 @@ "winreg": "^1.2.5", "ws": "^8.18.1", "yaml": "^2.6.1", - "yup": "^1.5.0", - "zod": "^3.24.1" + "yup": "^1.5.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.705.0", @@ -116,9 +116,9 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^33.4.11", + "electron": "^37.7.1", "electron-builder": "^26.0.12", - "electron-vite": "^3.0.0", + "electron-vite": "^4.0.1", "eslint": "^8.56.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", @@ -130,7 +130,7 @@ "sass-embedded": "^1.80.6", "ts-node": "^10.9.2", "typescript": "^5.3.3", - "vite": "5.4.20", + "vite": "5.4.21", "vite-plugin-svgr": "^4.5.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index d65e86e9..b2d228ad 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "settings": "Settings", "my_library": "My library", @@ -94,6 +95,7 @@ "search": "Search games", "home": "Home", "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "search_results": "Search results", "settings": "Settings", @@ -223,6 +225,7 @@ "show_more": "Show more", "show_less": "Show less", "reviews": "Reviews", + "review_played_for": "Played for", "leave_a_review": "Leave a Review", "write_review_placeholder": "Share your thoughts about this game...", "sort_newest": "Newest", @@ -361,7 +364,10 @@ "show_original": "Show original", "show_translation": "Show translation", "show_original_translated_from": "Show original (translated from {{language}})", - "hide_original": "Hide original" + "hide_original": "Hide original", + "review_from_blocked_user": "Review from blocked user", + "show": "Show", + "hide": "Hide" }, "activation": { "title": "Activate Hydra", @@ -429,6 +435,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", @@ -436,9 +445,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", @@ -469,6 +485,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", @@ -544,7 +561,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", @@ -680,7 +699,32 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews" + "karma_description": "Earned from positive likes on reviews", + "user_reviews": "Reviews", + "delete_review": "Delete Review", + "loading_reviews": "Loading reviews..." + }, + "library": { + "library": "Library", + "play": "Play", + "download": "Download", + "downloading": "Downloading", + "game": "game", + "games": "games", + "grid_view": "Grid view", + "compact_view": "Compact view", + "large_view": "Large view", + "no_games_title": "Your library is empty", + "no_games_description": "Add games from the catalogue or download them to get started", + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "This playtime has been manually updated", + "all_games": "All Games", + "favourited_games": "Favourited", + "new_games": "New Games", + "top_10": "Top 10" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index dfa7f7a1..c7e9d13e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -325,6 +325,7 @@ "maybe_later": "Tal vez después", "no_repacks_found": "Sin fuentes encontradas para este juego", "no_reviews_yet": "Sin reseñas aún", + "review_played_for": "Jugado por", "properties": "Propiedades", "rating": "Calificación", "rating_count": "Calificación", @@ -361,7 +362,10 @@ "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego", "language": "Idioma", "caption": "Subtítulo", - "audio": "Audio" + "audio": "Audio", + "review_from_blocked_user": "Reseña de usuario bloqueado", + "show": "Mostrar", + "hide": "Ocultar" }, "activation": { "title": "Activar Hydra", @@ -541,7 +545,9 @@ "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", - "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego" + "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", @@ -676,7 +682,11 @@ "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" + "game_added_to_pinned": "Juego añadido a fijados", + "user_reviews": "Reseñas", + "loading_reviews": "Cargando reseñas...", + "no_reviews": "Sin reseñas aún", + "delete_review": "Eliminar reseña" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 7d868aee..8aea356b 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -8,7 +8,7 @@ "no_results": "Nincs találat", "start_typing": "Kereséshez gépelj...", "hot": "Most felkapott", - "weekly": "📅 A hét felkapott játékai", + "weekly": "📅 A hét felkapottjai", "achievements": "🏆 Achievement támogatott" }, "sidebar": { @@ -26,7 +26,7 @@ "sign_in": "Bejelentkezés", "friends": "Barátok", "need_help": "Elakadtál?", - "favorites": "Kedvenc játékok", + "favorites": "Kedvenc Játékaim", "playable_button_title": "Csak az azonnal játszható játékokat mutasd", "add_custom_game_tooltip": "Saját játék hozzáadása", "show_playable_only_tooltip": "Csak játszható játék mutatása", @@ -224,7 +224,7 @@ "show_less": "Mutass kevesebbet", "reviews": "Vélemények", "leave_a_review": "Hagyd itt a véleményed", - "write_review_placeholder": "Oszd meg a gondolataid a játékról...", + "write_review_placeholder": "Oszd meg gondolatod a játékról...", "sort_newest": "Legújabb", "no_reviews_yet": "Még nem lett vélemény megosztva", "be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!", @@ -252,7 +252,7 @@ "you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot", "would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?", "yes": "Igen", - "maybe_later": "Talán Később", + "maybe_later": "Talán később", "cloud_save": "Mentés felhőben", "cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön", "backups": "Biztonsági másolatok", @@ -356,13 +356,18 @@ "delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?", "delete_review_modal_description": "Ez a lépés nem vonható vissza.", "delete_review_modal_delete_button": "Törlés", - "delete_review_modal_cancel_button": "Mégse" + "delete_review_modal_cancel_button": "Mégse", + "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.", + "show_original": "Eredeti megjelenítése", + "show_translation": "Fordítás megjelenítése", + "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", + "hide_original": "Eredeti elrejtése" }, "activation": { "title": "Hydra Aktiválása", "installation_id": "Telepítési Azonosító:", "enter_activation_code": "Írd be az aktiválási kódod", - "message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.", + "message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.", "activate": "Aktiválás", "loading": "Töltés…" }, @@ -386,7 +391,7 @@ "download_in_progress": "Folyamatban lévő", "queued_downloads": "Várakozósoron lévő letöltések", "downloads_completed": "Befejezett", - "queued": "Várakozási sorban", + "queued": "Várakozásban", "no_downloads_title": "Oly üres..", "no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.", "checking_files": "Fájlok ellenőrzése…", @@ -419,20 +424,30 @@ "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "save_changes": "Változtatások mentése", "changes_saved": "Változtatások sikeresen mentve", - "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", + "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "validate_download_source": "Érvényesítés", "remove_download_source": "Eltávolítás", "add_download_source": "Forrás hozáadása", + "adding": "Hozzáadás…", + "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.", + "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", "download_count_zero": "Nincs letöltési opció", "download_count_one": "{{countFormatted}} letöltési opció", "download_count_other": "{{countFormatted}} letöltési opció", - "download_source_url": "URL forrás:", + "download_source_url": "URL Forrás:", "add_download_source_description": "Helyezd be a .json fájl URL-jét", "download_source_up_to_date": "Naprakész", "download_source_errored": "Hiba történt", + "download_source_pending_matching": "Frissítés hamarosan", + "download_source_matched": "Naprakész", + "download_source_matching": "Frissítés..", + "download_source_failed": "Hiba", + "download_source_no_information": "Nincs elérhető információ", "sync_download_sources": "Források szinkronizálása", "removed_download_source": "Letöltési forrás eltávolítva", "removed_download_sources": "Letöltési források eltávolítva", + "removed_all_download_sources": "Összes letöltési forrás eltávolítva", + "download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva", "cancel_button_confirmation_delete_all_sources": "Nem", "confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent", "title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése", @@ -445,6 +460,7 @@ "found_download_option_one": "{{countFormatted}} Letöltési opció találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "import": "Importálás", + "importing": "Importálás...", "public": "Publikus", "private": "Privát", "friends_only": "Csak barátok", @@ -462,6 +478,7 @@ "seed_after_download_complete": "Letöltés utáni seedelés", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt", "account": "Fiók", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "Nincsenek letiltott felhasználóid", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "manage_subscription": "Előfizetés kezelése", @@ -498,14 +515,14 @@ "cancel": "Mégsem", "appearance": "Megjelenés", "debrid": "Debrid", - "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.", + "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, és csak az internet sebességed szab határt.", "enable_torbox": "TorBox bekapcsolása", "torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.", "torbox_account_linked": "TorBox fiók összekapcsolva", "create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod", "create_torbox_account": "Kattints ide ha még nincs TorBox fiókod", "real_debrid_account_linked": "Real-Debrid fiók összekapcsolva", - "name_min_length": "A téma neve legalább 3 karakter hosszú legyen", + "name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen", "import_theme": "Téma importálása", "import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}", "error_importing_theme": "Hiba lépett fel a téma importálása közben", @@ -535,7 +552,9 @@ "hidden": "Rejtett", "test_notification": "Értesítés tesztelése", "notification_preview": "Achievement Értesítés Előnézete", - "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot" + "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot", + "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", + "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" }, "notifications": { "download_complete": "Letöltés befejezve", @@ -563,10 +582,10 @@ "available_one": "Elérhető", "available_other": "Elérhető", "no_downloads": "Nincs elérhető letöltés", - "calculating": "Feldolgozás" + "calculating": "Számítás alatt.." }, "binary_not_found_modal": { - "title": "A programok nincsenek telepítve", + "title": "Hiányzó programok", "description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden", "instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson" }, @@ -585,6 +604,7 @@ "activity": "Legutóbbi tevékenység", "library": "Könyvtár", "pinned": "Kitűzve", + "sort_by": "Rendezés:", "achievements_earned": "Elért achievementek", "played_recently": "Nemrég játszva", "playtime": "Játszottidő", @@ -654,7 +674,7 @@ "uploading_banner": "Borítókép feltöltése…", "background_image_updated": "Borítókép frissítve", "stats": "Statisztikák", - "achievements": "achievementek", + "achievements": "achievement", "games": "Játékok", "top_percentile": "Top {{percentile}}%", "ranking_updated_weekly": "A rangsor hetente frissül.", @@ -669,7 +689,7 @@ "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", - "karma_description": "Pozitív értékelésekre kapott pontok alapján" + "karma_description": "Pozitív értékelésekkel szerzett pontok" }, "achievement": { "achievement_unlocked": "Achievement feloldva", @@ -678,7 +698,7 @@ "unlocked_at": "Feloldva: {{date}}", "subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges", "new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban", - "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement", "achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}", "hidden_achievement_tooltip": "Ez egy rejtett achievement", "achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 4ea77015..50049140 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -317,6 +317,7 @@ "sort_lowest_score": "Menor Nota", "sort_most_voted": "Mais Votadas", "no_reviews_yet": "Ainda não há avaliações", + "review_played_for": "Jogado por", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", "rating": "Avaliação", "rating_stats": "Avaliação", @@ -349,7 +350,10 @@ "show_translation": "Mostrar tradução", "show_original_translated_from": "Mostrar original (traduzido do {{language}})", "hide_original": "Ocultar original", - "rating_count": "Avaliação" + "rating_count": "Avaliação", + "review_from_blocked_user": "Avaliação de usuário bloqueado", + "show": "Mostrar", + "hide": "Ocultar" }, "activation": { "title": "Ativação", @@ -416,6 +420,9 @@ "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", + "adding": "Adicionando…", + "failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.", + "download_source_already_exists": "Esta URL de fonte de download já existe.", "download_count_zero": "Sem downloads na lista", "download_count_one": "{{countFormatted}} download na lista", "download_count_other": "{{countFormatted}} downloads na lista", @@ -423,7 +430,13 @@ "add_download_source_description": "Insira a URL contendo o arquivo .json", "download_source_up_to_date": "Sincronizada", "download_source_errored": "Falhou", + "download_source_pending_matching": "Importando em breve", + "download_source_matched": "Sincronizada", + "download_source_matching": "Sincronizando", + "download_source_failed": "Erro", + "download_source_no_information": "Sem informações", "sync_download_sources": "Sincronizar", + "download_sources_synced_successfully": "Fontes de download sincronizadas", "removed_download_source": "Fonte removida", "removed_download_sources": "Fontes removidas", "cancel_button_confirmation_delete_all_sources": "Não", @@ -529,7 +542,9 @@ "hidden": "Oculta", "test_notification": "Testar notificação", "notification_preview": "Prévia da Notificação de Conquistas", - "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo" + "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", + "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", + "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo" }, "notifications": { "download_complete": "Download concluído", @@ -682,7 +697,11 @@ "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" + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "user_reviews": "Avaliações", + "loading_reviews": "Carregando avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Excluir avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 962504d4..c8e4586d 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -180,7 +180,11 @@ "download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.", "game_removed_from_favorites": "Jogo removido dos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos", - "create_start_menu_shortcut": "Criar atalho no Menu Iniciar" + "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", + "review_from_blocked_user": "Avaliação de utilizador bloqueado", + "show": "Mostrar", + "hide": "Ocultar", + "review_played_for": "Jogado por" }, "activation": { "title": "Ativação", @@ -252,7 +256,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", @@ -460,7 +470,11 @@ "achievements_unlocked": "Conquistas desbloqueadas", "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Mostre as suas conquistas no perfil", - "show_points_on_profile": "Mostre os seus pontos ganhos no perfil" + "show_points_on_profile": "Mostre os seus pontos ganhos no perfil", + "user_reviews": "Avaliações", + "loading_reviews": "A carregar avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Eliminar avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 886c7d07..2e7c1504 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -212,6 +212,7 @@ "stats": "Статистика", "download_count": "Загрузки", "player_count": "Активные игроки", + "rating_count": "Оценка", "download_error": "Этот вариант загрузки недоступен", "download": "Скачать", "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", @@ -226,6 +227,7 @@ "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "sort_newest": "Сначала новые", "no_reviews_yet": "Пока нет отзывов", + "review_played_for": "Играли", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "sort_oldest": "Сначала старые", "sort_highest_score": "Высший балл", @@ -252,17 +254,6 @@ "would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?", "yes": "Да", "maybe_later": "Возможно позже", - "rating_count": "Оценка", - "delete_review": "Удалить отзыв", - "remove_review": "Удалить отзыв", - "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?", - "delete_review_modal_description": "Это действие нельзя отменить.", - "delete_review_modal_delete_button": "Удалить", - "delete_review_modal_cancel_button": "Отмена", - "show_original": "Показать оригинал", - "show_translation": "Показать перевод", - "show_original_translated_from": "Показать оригинал (переведено с {{language}})", - "hide_original": "Скрыть оригинал", "cloud_save": "Облачное сохранение", "cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве", "backups": "Резервные копии", @@ -360,7 +351,21 @@ "caption": "Субтитры", "audio": "Аудио", "filter_by_source": "Фильтр по источнику", - "no_repacks_found": "Источники для этой игры не найдены" + "no_repacks_found": "Источники для этой игры не найдены", + "show": "Показать", + "hide": "Скрыть", + "delete_review": "Удалить отзыв", + "remove_review": "Удалить отзыв", + "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?", + "delete_review_modal_description": "Это действие нельзя отменить.", + "delete_review_modal_delete_button": "Удалить", + "delete_review_modal_cancel_button": "Отмена", + "vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.", + "show_original": "Показать оригинал", + "show_translation": "Показать перевод", + "show_original_translated_from": "Показать оригинал (переведено с {{language}})", + "hide_original": "Скрыть оригинал", + "review_from_blocked_user": "Отзыв от заблокированного пользователя" }, "activation": { "title": "Активировать Hydra", @@ -427,6 +432,9 @@ "validate_download_source": "Проверить", "remove_download_source": "Удалить", "add_download_source": "Добавить источник", + "adding": "Добавление…", + "failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.", + "download_source_already_exists": "Этот URL источника уже существует.", "download_count_zero": "В списке нет загрузок", "download_count_one": "{{countFormatted}} загрузка в списке", "download_count_other": "{{countFormatted}} загрузок в списке", @@ -434,9 +442,16 @@ "add_download_source_description": "Вставьте ссылку на .json-файл", "download_source_up_to_date": "Обновлён", "download_source_errored": "Ошибка", + "download_source_pending_matching": "Скоро обновится", + "download_source_matched": "Обновлен", + "download_source_matching": "Обновление", + "download_source_failed": "Ошибка", + "download_source_no_information": "Информация отсутствует", "sync_download_sources": "Обновить источники", "removed_download_source": "Источник удален", "removed_download_sources": "Источники удалены", + "removed_all_download_sources": "Все источники удалены", + "download_sources_synced_successfully": "Все источники синхронизированы", "cancel_button_confirmation_delete_all_sources": "Нет", "confirm_button_confirmation_delete_all_sources": "Да, удалить все", "title_confirmation_delete_all_sources": "Удалить все источники", @@ -467,6 +482,7 @@ "seed_after_download_complete": "Раздавать после завершения загрузки", "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", "account": "Аккаунт", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "У вас нет заблокированных пользователей", "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", "manage_subscription": "Управлять подпиской", @@ -540,7 +556,9 @@ "hidden": "Скрытый", "test_notification": "Тестовое уведомление", "notification_preview": "Предварительный просмотр уведомления о достижении", - "enable_friend_start_game_notifications": "Когда друг начинает играть в игру" + "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", + "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", + "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры" }, "notifications": { "download_complete": "Загрузка завершена", @@ -590,6 +608,7 @@ "activity": "Недавняя активность", "library": "Библиотека", "pinned": "Закрепленные", + "sort_by": "Сортировать по:", "achievements_earned": "Заработанные достижения", "played_recently": "Недавно сыгранные", "playtime": "Время игры", @@ -674,7 +693,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов" + "karma_description": "Заработана положительными оценками отзывов", + "user_reviews": "Отзывы", + "loading_reviews": "Загрузка отзывов...", + "no_reviews": "Пока нет отзывов", + "delete_review": "Удалить отзыв" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts index de1d2b1f..0e45f886 100644 --- a/src/main/events/catalogue/get-game-assets.ts +++ b/src/main/events/catalogue/get-game-assets.ts @@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level"; const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours export const getGameAssets = async (objectId: string, shop: GameShop) => { + if (shop === "custom") { + return null; + } + const cachedAssets = await gamesShopAssetsSublevel.get( levelKeys.game(shop, objectId) ); diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index d6d27b9c..1a7fc455 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -26,6 +26,8 @@ const getGameShopDetails = async ( shop: GameShop, language: string ): Promise => { + if (shop === "custom") return null; + if (shop === "steam") { const [cachedData, cachedAssets] = await Promise.all([ gamesShopCacheSublevel.get( diff --git a/src/main/events/catalogue/get-game-stats.ts b/src/main/events/catalogue/get-game-stats.ts index b836531d..b7b7125c 100644 --- a/src/main/events/catalogue/get-game-stats.ts +++ b/src/main/events/catalogue/get-game-stats.ts @@ -10,6 +10,10 @@ const getGameStats = async ( objectId: string, shop: GameShop ) => { + if (shop === "custom") { + return null; + } + const cachedStats = await gamesStatsCacheSublevel.get( levelKeys.game(shop, objectId) ); diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index e51cae3e..bea009cb 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -1,76 +1,50 @@ import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal } from "./helpers"; +import { HydraApi } from "@main/services/hydra-api"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; +import { logger } from "@main/services"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, url: string ) => { - const result = await importDownloadSourceToLocal(url, true); - if (!result) { - throw new Error("Failed to import download source"); - } + try { + const existingSources = await downloadSourcesSublevel.values().all(); + const urlExists = existingSources.some((source) => source.url === url); - // Verify that repacks were actually written to the database (read-after-write) - // This ensures all async operations are complete before proceeding - let repackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - repackCount++; + if (urlExists) { + throw new Error("Download source with this URL already exists"); } - } - await HydraApi.post("/profile/download-sources", { - urls: [url], - }); + const downloadSource = await HydraApi.post( + "/download-sources", + { + url, + }, + { needsAuth: false } + ); - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: result.objectIds, - }, - { needsAuth: false } - ); + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { + try { + await HydraApi.post("/profile/download-sources", { + urls: [url], + }); + } catch (error) { + logger.error("Failed to add download source to profile:", error); + } + } - // Update the source with fingerprint - const updatedSource = await downloadSourcesSublevel.get(`${result.id}`); - if (updatedSource) { - await downloadSourcesSublevel.put(`${result.id}`, { - ...updatedSource, - fingerprint, - updatedAt: new Date(), + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), }); - } - // Final verification: ensure the source with fingerprint is persisted - const finalSource = await downloadSourcesSublevel.get(`${result.id}`); - if (!finalSource || !finalSource.fingerprint) { - throw new Error("Failed to persist download source with fingerprint"); + return downloadSource; + } catch (error) { + logger.error("Failed to add download source:", error); + throw error; } - - // Verify repacks still exist after fingerprint update - let finalRepackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - finalRepackCount++; - } - } - - if (finalRepackCount !== repackCount) { - logger.warn( - `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}` - ); - } else { - logger.info( - `Final verification passed: ${finalRepackCount} repacks confirmed` - ); - } - - return { - ...result, - fingerprint, - }; }; registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/check-download-source-exists.ts b/src/main/events/download-sources/check-download-source-exists.ts deleted file mode 100644 index 36dd88ce..00000000 --- a/src/main/events/download-sources/check-download-source-exists.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; - -const checkDownloadSourceExists = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -): Promise => { - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if (source.url === url) { - return true; - } - } - - return false; -}; - -registerEvent("checkDownloadSourceExists", checkDownloadSourceExists); diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts deleted file mode 100644 index cbf3958f..00000000 --- a/src/main/events/download-sources/delete-all-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteAllDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -) => { - await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]); - - invalidateIdCaches(); -}; - -registerEvent("deleteAllDownloadSources", deleteAllDownloadSources); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts deleted file mode 100644 index 5322b96c..00000000 --- a/src/main/events/download-sources/delete-download-source.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - id: number -) => { - const repacksToDelete: string[] = []; - - for await (const [key, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === id) { - repacksToDelete.push(key); - } - } - - const batch = repacksSublevel.batch(); - for (const key of repacksToDelete) { - batch.del(key); - } - await batch.write(); - - await downloadSourcesSublevel.del(`${id}`); - - invalidateIdCaches(); -}; - -registerEvent("deleteDownloadSource", deleteDownloadSource); diff --git a/src/main/events/download-sources/get-download-sources-list.ts b/src/main/events/download-sources/get-download-sources-list.ts deleted file mode 100644 index db26ad01..00000000 --- a/src/main/events/download-sources/get-download-sources-list.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, DownloadSource } from "@main/level"; - -const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => { - const sources: DownloadSource[] = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - sources.push(source); - } - - // Sort by createdAt descending - sources.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - - return sources; -}; - -registerEvent("getDownloadSourcesList", getDownloadSourcesList); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index bbebd06c..48583d9e 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,8 +1,10 @@ -import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; +import { orderBy } from "lodash-es"; const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get("/profile/download-sources"); + const allSources = await downloadSourcesSublevel.values().all(); + return orderBy(allSources, "createdAt", "desc"); }; registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts deleted file mode 100644 index 2e7489fd..00000000 --- a/src/main/events/download-sources/helpers.ts +++ /dev/null @@ -1,367 +0,0 @@ -import axios from "axios"; -import { z } from "zod"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import crypto from "node:crypto"; -import { logger, ResourceCache } from "@main/services"; - -export const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -export type TitleHashMapping = Record; - -let titleHashMappingCache: TitleHashMapping | null = null; - -export const getTitleHashMapping = async (): Promise => { - if (titleHashMappingCache) { - return titleHashMappingCache; - } - - try { - const cached = - ResourceCache.getCachedData("sources-manifest"); - if (cached) { - titleHashMappingCache = cached; - return cached; - } - - const fetched = await ResourceCache.fetchAndCache( - "sources-manifest", - "https://cdn.losbroxas.org/sources-manifest.json", - 10000 - ); - titleHashMappingCache = fetched; - return fetched; - } catch (error) { - logger.error("Failed to fetch title hash mapping:", error); - return {} as TitleHashMapping; - } -}; - -export const hashTitle = (title: string): string => { - return crypto.createHash("sha256").update(title).digest("hex"); -}; - -export type SteamGamesByLetter = Record; -export type FormattedSteamGame = { - id: string; - name: string; - formattedName: string; -}; -export type FormattedSteamGamesByLetter = Record; - -export const formatName = (name: string) => { - return name - .normalize("NFD") - .replaceAll(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replaceAll(/[^a-z0-9]/g, ""); -}; - -export const formatRepackName = (name: string) => { - return formatName(name.replace("[DL]", "")); -}; - -interface DownloadSource { - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; -} - -const getDownloadSourcesMap = async (): Promise< - Map -> => { - const map = new Map(); - for await (const [key, source] of downloadSourcesSublevel.iterator()) { - map.set(key, source); - } - - return map; -}; - -export const checkUrlExists = async (url: string): Promise => { - const sources = await getDownloadSourcesMap(); - for (const source of sources.values()) { - if (source.url === url) { - return true; - } - } - return false; -}; - -let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null; - -export const getSteamGames = async (): Promise => { - if (steamGamesFormattedCache) { - return steamGamesFormattedCache; - } - - let steamGames: SteamGamesByLetter; - - const cached = ResourceCache.getCachedData( - "steam-games-by-letter" - ); - if (cached) { - steamGames = cached; - } else { - steamGames = await ResourceCache.fetchAndCache( - "steam-games-by-letter", - `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` - ); - } - - const formattedData: FormattedSteamGamesByLetter = {}; - for (const [letter, games] of Object.entries(steamGames)) { - formattedData[letter] = games.map((game) => ({ - ...game, - formattedName: formatName(game.name), - })); - } - - steamGamesFormattedCache = formattedData; - return formattedData; -}; - -export type SublevelIterator = AsyncIterable<[string, { id: number }]>; - -export interface SublevelWithId { - iterator: () => SublevelIterator; -} - -let maxRepackId: number | null = null; -let maxDownloadSourceId: number | null = null; - -export const getNextId = async (sublevel: SublevelWithId): Promise => { - const isRepackSublevel = sublevel === repacksSublevel; - const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel; - - if (isRepackSublevel && maxRepackId !== null) { - return ++maxRepackId; - } - - if (isDownloadSourceSublevel && maxDownloadSourceId !== null) { - return ++maxDownloadSourceId; - } - - let maxId = 0; - for await (const [, value] of sublevel.iterator()) { - if (value.id > maxId) { - maxId = value.id; - } - } - - if (isRepackSublevel) { - maxRepackId = maxId; - } else if (isDownloadSourceSublevel) { - maxDownloadSourceId = maxId; - } - - return maxId + 1; -}; - -export const invalidateIdCaches = () => { - maxRepackId = null; - maxDownloadSourceId = null; -}; - -export const addNewDownloads = async ( - downloadSource: { id: number; name: string }, - downloads: z.infer["downloads"], - steamGames: FormattedSteamGamesByLetter -) => { - const now = new Date(); - const objectIdsOnSource = new Set(); - - let nextRepackId = await getNextId(repacksSublevel); - - const batch = repacksSublevel.batch(); - - const titleHashMapping = await getTitleHashMapping(); - let hashMatchCount = 0; - let fuzzyMatchCount = 0; - let noMatchCount = 0; - - for (const download of downloads) { - let objectIds: string[] = []; - let usedHashMatch = false; - - const titleHash = hashTitle(download.title); - const steamIdsFromHash = titleHashMapping[titleHash]; - - if (steamIdsFromHash && steamIdsFromHash.length > 0) { - hashMatchCount++; - usedHashMatch = true; - - objectIds = steamIdsFromHash.map(String); - } - - if (!usedHashMatch) { - let gamesInSteam: FormattedSteamGame[] = []; - const formattedTitle = formatRepackName(download.title); - - if (formattedTitle && formattedTitle.length > 0) { - const [firstLetter] = formattedTitle; - const games = steamGames[firstLetter] || []; - - gamesInSteam = games.filter((game) => - formattedTitle.startsWith(game.formattedName) - ); - - if (gamesInSteam.length === 0) { - gamesInSteam = games.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - } - - if (gamesInSteam.length === 0) { - for (const letter of Object.keys(steamGames)) { - const letterGames = steamGames[letter] || []; - const matches = letterGames.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - if (matches.length > 0) { - gamesInSteam = matches; - break; - } - } - } - - if (gamesInSteam.length > 0) { - fuzzyMatchCount++; - objectIds = gamesInSteam.map((game) => String(game.id)); - } else { - noMatchCount++; - } - } else { - noMatchCount++; - } - } - - for (const id of objectIds) { - objectIdsOnSource.add(id); - } - - const repack = { - id: nextRepackId++, - objectIds: objectIds, - title: download.title, - uris: download.uris, - fileSize: download.fileSize, - repacker: downloadSource.name, - uploadDate: download.uploadDate, - downloadSourceId: downloadSource.id, - createdAt: now, - updatedAt: now, - }; - - batch.put(`${repack.id}`, repack); - } - - await batch.write(); - - logger.info( - `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}` - ); - - const existingSource = await downloadSourcesSublevel.get( - `${downloadSource.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...existingSource, - objectIds: Array.from(objectIdsOnSource), - }); - } - - return Array.from(objectIdsOnSource); -}; - -export const importDownloadSourceToLocal = async ( - url: string, - throwOnDuplicate = false -) => { - const urlExists = await checkUrlExists(url); - if (urlExists) { - if (throwOnDuplicate) { - throw new Error("Download source with this URL already exists"); - } - return null; - } - - const response = await axios.get>(url); - - const steamGames = await getSteamGames(); - - const now = new Date(); - - const nextId = await getNextId(downloadSourcesSublevel); - - const downloadSource = { - id: nextId, - url, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - objectIds: [], - createdAt: now, - updatedAt: now, - }; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); - - const objectIds = await addNewDownloads( - downloadSource, - response.data.downloads, - steamGames - ); - - // Invalidate ID caches after creating new repacks to prevent ID collisions - invalidateIdCaches(); - - return { - ...downloadSource, - objectIds, - }; -}; - -export const updateDownloadSourcePreservingTimestamp = async ( - existingSource: DownloadSource, - url: string -) => { - const response = await axios.get>(url); - - const updatedSource = { - ...existingSource, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - updatedAt: new Date(), - // Preserve the original createdAt timestamp - }; - - await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource); - - return updatedSource; -}; diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts index bcc66998..9caeaba5 100644 --- a/src/main/events/download-sources/remove-download-source.ts +++ b/src/main/events/download-sources/remove-download-source.ts @@ -1,18 +1,27 @@ import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; const removeDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, - url?: string, - removeAll = false + removeAll = false, + downloadSourceId?: string ) => { const params = new URLSearchParams({ all: removeAll.toString(), }); - if (url) params.set("url", url); + if (downloadSourceId) params.set("downloadSourceId", downloadSourceId); - return HydraApi.delete(`/profile/download-sources?${params.toString()}`); + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { + void HydraApi.delete(`/profile/download-sources?${params.toString()}`); + } + + if (removeAll) { + await downloadSourcesSublevel.clear(); + } else if (downloadSourceId) { + await downloadSourcesSublevel.del(downloadSourceId); + } }; registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/download-sources/sync-download-sources-from-api.ts b/src/main/events/download-sources/sync-download-sources-from-api.ts deleted file mode 100644 index 3cac8819..00000000 --- a/src/main/events/download-sources/sync-download-sources-from-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal, checkUrlExists } from "./helpers"; - -export const syncDownloadSourcesFromApi = async () => { - try { - const apiSources = await HydraApi.get< - { url: string; createdAt: string; updatedAt: string }[] - >("/profile/download-sources"); - - for (const apiSource of apiSources) { - const exists = await checkUrlExists(apiSource.url); - if (!exists) { - await importDownloadSourceToLocal(apiSource.url, false); - } - } - } catch (error) { - logger.error("Failed to sync download sources from API:", error); - } -}; diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts index 88861074..68a6be3f 100644 --- a/src/main/events/download-sources/sync-download-sources.ts +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -1,114 +1,28 @@ +import { HydraApi } from "@main/services"; import { registerEvent } from "../register-event"; -import axios, { AxiosError } from "axios"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import { - invalidateIdCaches, - downloadSourceSchema, - getSteamGames, - addNewDownloads, -} from "./helpers"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; -const syncDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - let newRepacksCount = 0; +const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { + const downloadSources = await downloadSourcesSublevel.values().all(); - try { - const downloadSources: Array<{ - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; - }> = []; - for await (const [, source] of downloadSourcesSublevel.iterator()) { - downloadSources.push(source); - } + const response = await HydraApi.post( + "/download-sources/sync", + { + ids: downloadSources.map((downloadSource) => downloadSource.id), + }, + { needsAuth: false } + ); - const existingRepacks: Array<{ - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; - }> = []; - for await (const [, repack] of repacksSublevel.iterator()) { - existingRepacks.push(repack); - } - - // Handle sources with missing fingerprints individually, don't delete all sources - const sourcesWithFingerprints = downloadSources.filter( - (source) => source.fingerprint - ); - const sourcesWithoutFingerprints = downloadSources.filter( - (source) => !source.fingerprint + for (const downloadSource of response) { + const existingDownloadSource = downloadSources.find( + (source) => source.id === downloadSource.id ); - // For sources without fingerprints, just continue with normal sync - // They will get fingerprints updated later by updateMissingFingerprints - const allSourcesToSync = [ - ...sourcesWithFingerprints, - ...sourcesWithoutFingerprints, - ]; - - for (const downloadSource of allSourcesToSync) { - const headers: Record = {}; - - if (downloadSource.etag) { - headers["If-None-Match"] = downloadSource.etag; - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - const steamGames = await getSteamGames(); - - const repacks = source.downloads.filter( - (download) => - !existingRepacks.some((repack) => repack.title === download.title) - ); - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - etag: response.headers["etag"] || null, - downloadCount: source.downloads.length, - status: DownloadSourceStatus.UpToDate, - }); - - await addNewDownloads(downloadSource, repacks, steamGames); - - newRepacksCount += repacks.length; - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); - } - } - - invalidateIdCaches(); - - return newRepacksCount; - } catch (err) { - return -1; + await downloadSourcesSublevel.put(downloadSource.id, { + ...existingDownloadSource, + ...downloadSource, + }); } }; diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts deleted file mode 100644 index 7fd43c63..00000000 --- a/src/main/events/download-sources/update-missing-fingerprints.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; - -const updateMissingFingerprints = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - const sourcesNeedingFingerprints: Array<{ - id: number; - objectIds: string[]; - }> = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if ( - !source.fingerprint && - source.objectIds && - source.objectIds.length > 0 - ) { - sourcesNeedingFingerprints.push({ - id: source.id, - objectIds: source.objectIds, - }); - } - } - - if (sourcesNeedingFingerprints.length === 0) { - return 0; - } - - logger.info( - `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources` - ); - - await Promise.all( - sourcesNeedingFingerprints.map(async (source) => { - try { - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: source.objectIds, - }, - { needsAuth: false } - ); - - const existingSource = await downloadSourcesSublevel.get( - `${source.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${source.id}`, { - ...existingSource, - fingerprint, - updatedAt: new Date(), - }); - } - } catch (error) { - logger.error( - `Failed to update fingerprint for source ${source.id}:`, - error - ); - } - }) - ); - - return sourcesNeedingFingerprints.length; -}; - -registerEvent("updateMissingFingerprints", updateMissingFingerprints); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts deleted file mode 100644 index 2bc86df7..00000000 --- a/src/main/events/download-sources/validate-download-source.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { registerEvent } from "../register-event"; -import axios from "axios"; -import { z } from "zod"; - -const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -const validateDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => { - const response = await axios.get>(url); - - const { name } = downloadSourceSchema.parse(response.data); - - return { - name, - etag: response.headers["etag"] || null, - downloadCount: response.data.downloads.length, - }; -}; - -registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index c2c51418..0786eb12 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -23,6 +23,7 @@ import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; +import "./library/refresh-library-assets"; import "./library/extract-game-download"; import "./library/open-game"; import "./library/open-game-executable-path"; @@ -69,14 +70,7 @@ import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; -import "./download-sources/update-missing-fingerprints"; -import "./download-sources/delete-download-source"; -import "./download-sources/delete-all-download-sources"; -import "./download-sources/validate-download-source"; import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-list"; -import "./download-sources/check-download-source-exists"; -import "./repacks/get-all-repacks"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index f2f2dd40..6a90087e 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -37,6 +37,7 @@ const addCustomGameToLibrary = async ( logoImageUrl: logoImageUrl || "", logoPosition: null, coverImageUrl: iconUrl || "", + downloadSources: [], }; await gamesShopAssetsSublevel.put(gameKey, assets); diff --git a/src/main/events/library/add-game-to-favorites.ts b/src/main/events/library/add-game-to-favorites.ts index 68c81abb..53985a09 100644 --- a/src/main/events/library/add-game-to-favorites.ts +++ b/src/main/events/library/add-game-to-favorites.ts @@ -13,7 +13,9 @@ const addGameToFavorites = async ( const game = await gamesSublevel.get(gameKey); if (!game) return; - HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {}); + if (shop !== "custom") { + HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {}); + } try { await gamesSublevel.put(gameKey, { diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 6314f83d..f62c60e7 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -4,6 +4,7 @@ import { downloadsSublevel, gamesShopAssetsSublevel, gamesSublevel, + gameAchievementsSublevel, } from "@main/level"; const getLibrary = async (): Promise => { @@ -18,14 +19,32 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = 0; + let achievementCount = 0; + + try { + const achievements = await gameAchievementsSublevel.get(key); + if (achievements) { + achievementCount = achievements.achievements.length; + unlockedAchievementCount = + achievements.unlockedAchievements.length; + } + } catch { + // No achievements data for this game + } + return { id: key, ...game, download: download ?? null, + unlockedAchievementCount, + achievementCount, + // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, - // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: - game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + // Preserve custom image URLs from game if they exist + customIconUrl: game.customIconUrl, + customLogoImageUrl: game.customLogoImageUrl, + customHeroImageUrl: game.customHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/refresh-library-assets.ts b/src/main/events/library/refresh-library-assets.ts new file mode 100644 index 00000000..d8578f1b --- /dev/null +++ b/src/main/events/library/refresh-library-assets.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { mergeWithRemoteGames } from "@main/services"; + +const refreshLibraryAssets = async () => { + await mergeWithRemoteGames(); +}; + +registerEvent("refreshLibraryAssets", refreshLibraryAssets); diff --git a/src/main/events/library/remove-game-from-favorites.ts b/src/main/events/library/remove-game-from-favorites.ts index f06f55ce..7c79cbf4 100644 --- a/src/main/events/library/remove-game-from-favorites.ts +++ b/src/main/events/library/remove-game-from-favorites.ts @@ -13,7 +13,11 @@ const removeGameFromFavorites = async ( const game = await gamesSublevel.get(gameKey); if (!game) return; - HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {}); + if (shop !== "custom") { + HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch( + () => {} + ); + } try { await gamesSublevel.put(gameKey, { diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index fbb60ab2..95133c70 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -84,7 +84,7 @@ const removeGameFromLibrary = async ( await resetShopAssets(gameKey); } - if (game?.remoteId) { + if (game.remoteId) { HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); } diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts deleted file mode 100644 index 6eb83a39..00000000 --- a/src/main/events/repacks/get-all-repacks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { repacksSublevel, GameRepack } from "@main/level"; - -const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => { - const repacks: GameRepack[] = []; - - for await (const [, repack] of repacksSublevel.iterator()) { - if (Array.isArray(repack.objectIds)) { - repacks.push(repack); - } - } - - return repacks; -}; - -registerEvent("getAllRepacks", getAllRepacks); diff --git a/src/main/helpers/migrate-download-sources.ts b/src/main/helpers/migrate-download-sources.ts new file mode 100644 index 00000000..fd627f20 --- /dev/null +++ b/src/main/helpers/migrate-download-sources.ts @@ -0,0 +1,27 @@ +import { downloadSourcesSublevel } from "@main/level"; +import { HydraApi } from "@main/services/hydra-api"; +import { DownloadSource } from "@types"; + +export const migrateDownloadSources = async () => { + const downloadSources = downloadSourcesSublevel.iterator(); + + for await (const [key, value] of downloadSources) { + if (!value.isRemote) { + const downloadSource = await HydraApi.post( + "/download-sources", + { + url: value.url, + }, + { needsAuth: false } + ); + + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + await downloadSourcesSublevel.del(key); + } + } +}; diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts index 59104e3c..b6cdad0b 100644 --- a/src/main/level/sublevels/download-sources.ts +++ b/src/main/level/sublevels/download-sources.ts @@ -1,18 +1,6 @@ import { db } from "../level"; import { levelKeys } from "./keys"; - -export interface DownloadSource { - id: number; - name: string; - url: string; - status: number; - objectIds: string[]; - downloadCount: number; - fingerprint?: string; - etag: string | null; - createdAt: Date; - updatedAt: Date; -} +import type { DownloadSource } from "@types"; export const downloadSourcesSublevel = db.sublevel( levelKeys.downloadSources, diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 7224fc64..3619ae26 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,4 +7,3 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; -export * from "./repacks"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 6faacd52..a28690b2 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,4 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - repacks: "repacks", }; diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts deleted file mode 100644 index 6257665b..00000000 --- a/src/main/level/sublevels/repacks.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from "../level"; -import { levelKeys } from "./keys"; - -export interface GameRepack { - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; -} - -export const repacksSublevel = db.sublevel( - levelKeys.repacks, - { - valueEncoding: "json", - } -); diff --git a/src/main/main.ts b/src/main/main.ts index 5eecb101..6ae927a5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,15 +16,13 @@ import { Ludusavi, Lock, DeckyPlugin, - ResourceCache, + WSClient, } from "@main/services"; +import { migrateDownloadSources } from "./helpers/migrate-download-sources"; export const loadState = async () => { await Lock.acquireLock(); - ResourceCache.initialize(); - await ResourceCache.updateResourcesOnStartup(); - const userPreferences = await db.get( levelKeys.userPreferences, { @@ -53,9 +51,13 @@ export const loadState = async () => { DeckyPlugin.checkAndUpdateIfOutdated(); } - await HydraApi.setupApi().then(() => { + await HydraApi.setupApi().then(async () => { uploadGamesBatch(); - // WSClient.connect(); + void migrateDownloadSources(); + + const { syncDownloadSourcesFromApi } = await import("./services/user"); + void syncDownloadSourcesFromApi(); + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index b862abbe..dd65165a 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -167,6 +167,8 @@ export class AchievementWatcherManager { shop: GameShop, objectId: string ) { + if (shop === "custom") return; + const gameKey = levelKeys.game(shop, objectId); if (this.alreadySyncedGames.get(gameKey)) return; diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index b32b82cf..687fb384 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -27,6 +27,10 @@ export const getGameAchievementData = async ( shop: GameShop, useCachedData: boolean ) => { + if (shop === "custom") { + return []; + } + const gameKey = levelKeys.game(shop, objectId); const cachedAchievements = await gameAchievementsSublevel.get(gameKey); diff --git a/src/main/services/hosters/datanodes.ts b/src/main/services/hosters/datanodes.ts index 29708322..4cfb5242 100644 --- a/src/main/services/hosters/datanodes.ts +++ b/src/main/services/hosters/datanodes.ts @@ -1,6 +1,7 @@ import axios, { AxiosResponse } from "axios"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; +import { logger } from "@main/services"; export class DatanodesApi { private static readonly jar = new CookieJar(); @@ -20,51 +21,42 @@ export class DatanodesApi { await this.jar.setCookie("lang=english;", "https://datanodes.to"); - const payload = new URLSearchParams({ - op: "download2", - id: fileCode, - method_free: "Free Download >>", - dl: "1", - }); + const formData = new FormData(); + formData.append("op", "download2"); + formData.append("id", fileCode); + formData.append("rand", ""); + formData.append("referer", "https://datanodes.to/download"); + formData.append("method_free", "Free Download >>"); + formData.append("method_premium", ""); + formData.append("__dl", "1"); const response: AxiosResponse = await this.session.post( "https://datanodes.to/download", - payload, + formData, { headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + priority: "u=1, i", + "sec-ch-ua": + '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", Referer: "https://datanodes.to/download", - Origin: "https://datanodes.to", - "Content-Type": "application/x-www-form-urlencoded", }, - maxRedirects: 0, - validateStatus: (status: number) => status === 302 || status < 400, } ); - if (response.status === 302) { - return response.headers["location"]; - } - if (typeof response.data === "object" && response.data.url) { return decodeURIComponent(response.data.url); } - const htmlContent = String(response.data); - if (!htmlContent) { - throw new Error("Empty response received"); - } - - const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/; - const downloadLinkMatch = downloadLinkRegex.exec(htmlContent); - if (downloadLinkMatch) { - return downloadLinkMatch[1]; - } - throw new Error("Failed to get the download link"); } catch (error) { - console.error("Error fetching download URL:", error); + logger.error("Error fetching download URL:", error); throw error; } } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 07f81d68..f77a152f 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data"; import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; +import { WSClient } from "./ws"; export interface HydraApiOptions { needsAuth?: boolean; @@ -29,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = false; + private static readonly ADD_LOG_INTERCEPTOR = true; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; @@ -46,7 +47,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(); } @@ -103,12 +104,10 @@ export class HydraApi { await clearGamesRemoteIds(); uploadGamesBatch(); - // WSClient.close(); - // WSClient.connect(); + WSClient.close(); + WSClient.connect(); - const { syncDownloadSourcesFromApi } = await import( - "../events/download-sources/sync-download-sources-from-api" - ); + const { syncDownloadSourcesFromApi } = await import("./user"); syncDownloadSourcesFromApi(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index c6acb18d..dd627ff0 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,4 +19,4 @@ export * from "./library-sync"; export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; -export * from "./resource-cache"; +export * from "./user"; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index a346d3b4..e9ec9612 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api"; import { gamesSublevel, levelKeys } from "@main/level"; export const createGame = async (game: Game) => { + if (game.shop === "custom") { + return; + } + return HydraApi.post(`/profile/games`, { objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0), diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index f7ea2744..92cd66d8 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -60,18 +60,26 @@ export const mergeWithRemoteGames = async () => { const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + // Construct coverImageUrl if not provided by backend (Steam games use predictable pattern) + const coverImageUrl = + game.coverImageUrl || + (game.shop === "steam" + ? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg` + : null); + 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 - coverImageUrl: game.coverImageUrl, + coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, logoImageUrl: game.logoImageUrl, iconUrl: game.iconUrl, logoPosition: game.logoPosition, + downloadSources: game.downloadSources, }); } }) diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 3689b302..a669a363 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -1,12 +1,16 @@ import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; -export const updateGamePlaytime = async ( +export const trackGamePlaytime = async ( game: Game, deltaInMillis: number, lastTimePlayed: Date ) => { - return HydraApi.put(`/profile/games/${game.remoteId}`, { + if (game.shop === "custom") { + return; + } + + return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, { playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), lastTimePlayed, }); diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 06f5f7d8..db5bbee1 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,10 +1,10 @@ import { WindowManager } from "./window-manager"; -import { createGame, updateGamePlaytime } from "./library-sync"; -import type { Game, GameRunning } from "@types"; +import { createGame, trackGamePlaytime } from "./library-sync"; +import type { Game, GameRunning, UserPreferences } from "@types"; import { PythonRPC } from "./python-rpc"; import axios from "axios"; import { ProcessPayload } from "./download/types"; -import { gamesSublevel, levelKeys } from "@main/level"; +import { db, gamesSublevel, levelKeys } from "@main/level"; import { CloudSync } from "./cloud-sync"; import { logger } from "./logger"; import path from "path"; @@ -198,19 +198,32 @@ export const watchProcesses = async () => { function onOpenGame(game: Game) { const now = performance.now(); - AchievementWatcherManager.firstSyncWithRemoteIfNeeded( - game.shop, - game.objectId - ); - gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { lastTick: now, firstTick: now, lastSyncTick: now, }); + // Hide Hydra to tray on game startup if enabled + db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }) + .then((userPreferences) => { + if (userPreferences?.hideToTrayOnGameStart) { + WindowManager.mainWindow?.hide(); + } + }) + .catch(() => {}); + + if (game.shop === "custom") return; + + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); + if (game.remoteId) { - updateGamePlaytime( + trackGamePlaytime( game, game.unsyncedDeltaPlayTimeInMilliseconds ?? 0, new Date() @@ -244,43 +257,46 @@ function onTickGame(game: Game) { const delta = now - gamePlaytime.lastTick; - gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + const updatedGame: Game = { ...game, playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta, lastTimePlayed: new Date(), - }); + }; + + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame); gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastTick: now, }); - if (currentTick % TICKS_TO_UPDATE_API === 0) { + if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") { const deltaToSync = now - gamePlaytime.lastSyncTick + (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); const gamePromise = game.remoteId - ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) + ? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!) : createGame(game); gamePromise .then(() => { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: 0, }); }) .catch(() => { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, }); }) .finally(() => { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, + lastTick: now, lastSyncTick: now, }); }); @@ -288,11 +304,24 @@ function onTickGame(game: Game) { } const onCloseGame = (game: Game) => { + const now = performance.now(); const gamePlaytime = gamesPlaytime.get( levelKeys.game(game.shop, game.objectId) )!; gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId)); + const delta = now - gamePlaytime.lastTick; + + const updatedGame: Game = { + ...game, + playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta, + lastTimePlayed: new Date(), + }; + + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame); + + if (game.shop === "custom") return; + if (game.remoteId) { if (game.automaticCloudSync) { CloudSync.uploadSaveGame( @@ -304,20 +333,20 @@ const onCloseGame = (game: Game) => { } const deltaToSync = - performance.now() - + now - gamePlaytime.lastSyncTick + (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); - return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) + return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!) .then(() => { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: 0, }); }) .catch(() => { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, }); }); diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts deleted file mode 100644 index c59f873d..00000000 --- a/src/main/services/resource-cache.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { app } from "electron"; -import axios from "axios"; -import fs from "node:fs"; -import path from "node:path"; -import { logger } from "./logger"; - -interface CachedResource { - data: T; - etag: string | null; -} - -export class ResourceCache { - private static cacheDir: string; - - static initialize() { - this.cacheDir = path.join(app.getPath("userData"), "resource-cache"); - - if (!fs.existsSync(this.cacheDir)) { - fs.mkdirSync(this.cacheDir, { recursive: true }); - } - } - - private static getCacheFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.json`); - } - - private static getEtagFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.etag`); - } - - private static readCachedResource( - resourceName: string - ): CachedResource | null { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - if (!fs.existsSync(dataPath)) { - return null; - } - - try { - const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T; - const etag = fs.existsSync(etagPath) - ? fs.readFileSync(etagPath, "utf-8") - : null; - - return { data, etag }; - } catch (error) { - logger.error(`Failed to read cached resource ${resourceName}:`, error); - return null; - } - } - - private static writeCachedResource( - resourceName: string, - data: T, - etag: string | null - ): void { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - try { - fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8"); - - if (etag) { - fs.writeFileSync(etagPath, etag, "utf-8"); - } - - logger.info( - `Cached resource ${resourceName} with etag: ${etag || "none"}` - ); - } catch (error) { - logger.error(`Failed to write cached resource ${resourceName}:`, error); - } - } - - static async fetchAndCache( - resourceName: string, - url: string, - timeout: number = 10000 - ): Promise { - const cached = this.readCachedResource(resourceName); - const headers: Record = {}; - - if (cached?.etag) { - headers["If-None-Match"] = cached.etag; - } - - try { - const response = await axios.get(url, { - headers, - timeout, - }); - - const newEtag = response.headers["etag"] || null; - this.writeCachedResource(resourceName, response.data, newEtag); - - return response.data; - } catch (error: unknown) { - const axiosError = error as { - response?: { status?: number }; - message?: string; - }; - - if (axiosError.response?.status === 304 && cached) { - logger.info(`Resource ${resourceName} not modified, using cache`); - return cached.data; - } - - if (cached) { - logger.warn( - `Failed to fetch ${resourceName}, using cached version:`, - axiosError.message || "Unknown error" - ); - return cached.data; - } - - logger.error( - `Failed to fetch ${resourceName} and no cache available:`, - error - ); - throw error; - } - } - - static getCachedData(resourceName: string): T | null { - const cached = this.readCachedResource(resourceName); - return cached?.data || null; - } - - static async updateResourcesOnStartup(): Promise { - logger.info("Starting background resource cache update..."); - - const resources = [ - { - name: "steam-games-by-letter", - url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`, - }, - { - name: "sources-manifest", - url: "https://cdn.losbroxas.org/sources-manifest.json", - }, - ]; - - await Promise.allSettled( - resources.map(async (resource) => { - try { - await this.fetchAndCache(resource.name, resource.url); - } catch (error) { - logger.error(`Failed to update ${resource.name} on startup:`, error); - } - }) - ); - - logger.info("Resource cache update complete"); - } -} diff --git a/src/main/services/steam-250.ts b/src/main/services/steam-250.ts index 5652b0d3..826e528f 100644 --- a/src/main/services/steam-250.ts +++ b/src/main/services/steam-250.ts @@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => { if (!steamGameUrl) return null; return { - title: $title.textContent, + title: $title.getAttribute("data-title") || "", objectId: steamGameUrl.split("/").pop(), } as Steam250Game; }) diff --git a/src/main/services/user/index.ts b/src/main/services/user/index.ts new file mode 100644 index 00000000..b1d8c9b7 --- /dev/null +++ b/src/main/services/user/index.ts @@ -0,0 +1,2 @@ +export * from "./get-user-data"; +export * from "./sync-download-sources"; diff --git a/src/main/services/user/sync-download-sources.ts b/src/main/services/user/sync-download-sources.ts new file mode 100644 index 00000000..ff9819ce --- /dev/null +++ b/src/main/services/user/sync-download-sources.ts @@ -0,0 +1,41 @@ +import { HydraApi, logger } from "../"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; + +export const syncDownloadSourcesFromApi = async () => { + if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) { + return; + } + + try { + const profileSources = await HydraApi.get( + "/profile/download-sources" + ); + + const existingSources = await downloadSourcesSublevel.values().all(); + const existingUrls = new Set(existingSources.map((source) => source.url)); + + for (const downloadSource of profileSources) { + if (!existingUrls.has(downloadSource.url)) { + try { + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + logger.log( + `Synced download source from profile: ${downloadSource.url}` + ); + } catch (error) { + logger.error( + `Failed to sync download source ${downloadSource.url}:`, + error + ); + } + } + } + } catch (error) { + logger.error("Failed to sync download sources from API:", error); + } +}; diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 7055fc09..b11b4a9b 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -25,6 +25,7 @@ import type { } from "@types"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; import { isStaging } from "@main/constants"; +import { logger } from "./logger"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -54,21 +55,25 @@ export class WindowManager { show: false, }; + private static formatVersionNumber(version: string) { + return version.replaceAll(".", "-"); + } + private static async loadWindowURL(window: BrowserWindow, hash: string = "") { // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`); - } else if (import.meta.env.MAIN_VITE_RENDERER_URL) { + } else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) { // Try to load from remote URL in production try { await window.loadURL( - `${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}` + `https://release-v${this.formatVersionNumber(app.getVersion())}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` ); } catch (error) { // Fall back to local file if remote URL fails - console.error( - "Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:", + logger.error( + "Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:", error ); window.loadFile(path.join(__dirname, "../renderer/index.html"), { @@ -284,12 +289,6 @@ export class WindowManager { } } - private static loadNotificationWindowURL() { - if (this.notificationWindow) { - this.loadWindowURL(this.notificationWindow, "achievement-notification"); - } - } - private static readonly NOTIFICATION_WINDOW_WIDTH = 360; private static readonly NOTIFICATION_WINDOW_HEIGHT = 140; @@ -297,46 +296,58 @@ export class WindowManager { position: AchievementCustomNotificationPosition | undefined ) { const display = screen.getPrimaryDisplay(); - const { width, height } = display.workAreaSize; + const { + x: displayX, + y: displayY, + width: displayWidth, + height: displayHeight, + } = display.bounds; if (position === "bottom-left") { return { - x: 0, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, }; } if (position === "bottom-center") { return { - x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, }; } if (position === "bottom-right") { return { - x: width - this.NOTIFICATION_WINDOW_WIDTH, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, + }; + } + + if (position === "top-left") { + return { + x: displayX, + y: displayY, }; } if (position === "top-center") { return { - x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, - y: 0, + x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2, + y: displayY, }; } if (position === "top-right") { return { - x: width - this.NOTIFICATION_WINDOW_WIDTH, - y: 0, + x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH, + y: displayY, }; } return { - x: 0, - y: 0, + x: displayX, + y: displayY, }; } @@ -382,7 +393,7 @@ export class WindowManager { this.notificationWindow.setIgnoreMouseEvents(true); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); - this.loadNotificationWindowURL(); + this.loadWindowURL(this.notificationWindow, "achievement-notification"); if (!app.isPackaged || isStaging) { this.notificationWindow.webContents.openDevTools(); @@ -462,6 +473,7 @@ export class WindowManager { editorWindow.once("ready-to-show", () => { editorWindow.show(); + this.mainWindow?.webContents.openDevTools(); if (!app.isPackaged || isStaging) { editorWindow.webContents.openDevTools(); } @@ -469,11 +481,12 @@ export class WindowManager { editorWindow.webContents.on("before-input-event", (_event, input) => { if (input.key === "F12") { - editorWindow.webContents.toggleDevTools(); + this.mainWindow?.webContents.toggleDevTools(); } }); editorWindow.on("close", () => { + this.mainWindow?.webContents.closeDevTools(); this.editorWindows.delete(themeId); }); } diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index c9b006d5..7b0ed536 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -7,7 +7,7 @@ interface ImportMetaEnv { readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_WS_URL: string; - readonly MAIN_VITE_RENDERER_URL: string; + readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; readonly ELECTRON_RENDERER_URL: string; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 52fbcba5..11c16510 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ addDownloadSource: (url: string) => ipcRenderer.invoke("addDownloadSource", url), - updateMissingFingerprints: () => - ipcRenderer.invoke("updateMissingFingerprints"), removeDownloadSource: (url: string, removeAll?: boolean) => ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - deleteDownloadSource: (id: number) => - ipcRenderer.invoke("deleteDownloadSource", id), - deleteAllDownloadSources: () => - ipcRenderer.invoke("deleteAllDownloadSources"), - validateDownloadSource: (url: string) => - ipcRenderer.invoke("validateDownloadSource", url), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), - getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"), - checkDownloadSourceExists: (url: string) => - ipcRenderer.invoke("checkDownloadSourceExists", url), - getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"), /* Library */ toggleAutomaticCloudSync: ( @@ -208,6 +196,7 @@ contextBridge.exposeInMainWorld("electron", { verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), + refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), openGameInstaller: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstaller", shop, objectId), openGameInstallerPath: (shop: GameShop, objectId: string) => diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 2fedda61..67cffe83 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -5,7 +5,7 @@ } ::-webkit-scrollbar { - width: 9px; + width: 4px; background-color: globals.$dark-background-color; } diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 74a2a97e..1ab76381 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -7,7 +7,6 @@ import { useAppSelector, useDownload, useLibrary, - useRepacks, useToast, useUserDetails, } from "@renderer/hooks"; @@ -20,7 +19,6 @@ import { setUserDetails, setProfileBackground, setGameRunning, - setIsImportingSources, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; @@ -40,8 +38,6 @@ export function App() { const { t } = useTranslation("app"); - const { updateRepacks } = useRepacks(); - const { clearDownload, setLastPacket } = useDownload(); const { @@ -199,36 +195,6 @@ export function App() { }); }, [dispatch, draggingDisabled]); - useEffect(() => { - (async () => { - dispatch(setIsImportingSources(true)); - - try { - // Initial repacks load - await updateRepacks(); - - // Sync all local sources (check for updates) - const newRepacksCount = await window.electron.syncDownloadSources(); - - if (newRepacksCount > 0) { - window.electron.publishNewRepacksNotification(newRepacksCount); - } - - // Update fingerprints for sources that don't have them - await window.electron.updateMissingFingerprints(); - - // Update repacks AFTER all syncing and fingerprint updates are complete - await updateRepacks(); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still update repacks even if sync fails - await updateRepacks(); - } finally { - dispatch(setIsImportingSources(false)); - } - })(); - }, [updateRepacks, dispatch]); - const loadAndApplyTheme = useCallback(async () => { const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme?.code) { @@ -313,7 +279,11 @@ export function App() {
-
+
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 5752ba19..edea8d50 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -1,5 +1,5 @@ import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; -import type { GameStats } from "@types"; +import type { GameStats, ShopAssets } from "@types"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; @@ -8,15 +8,15 @@ import "./game-card.scss"; import { useTranslation } from "react-i18next"; import { Badge } from "../badge/badge"; import { StarRating } from "../star-rating/star-rating"; -import { useCallback, useState, useMemo } from "react"; -import { useFormat, useRepacks } from "@renderer/hooks"; +import { useCallback, useState } from "react"; +import { useFormat } from "@renderer/hooks"; export interface GameCardProps extends React.DetailedHTMLProps< React.ButtonHTMLAttributes, HTMLButtonElement > { - game: any; + game: ShopAssets; } const shopIcon = { @@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const [stats, setStats] = useState(null); - const { getRepacksForObjectId } = useRepacks(); - const repacks = getRepacksForObjectId(game.objectId); - - const uniqueRepackers = Array.from( - new Set(repacks.map((repack) => repack.repacker)) - ); - const handleHover = useCallback(() => { if (!stats) { window.electron.getGameStats(game.objectId, game.shop).then((stats) => { @@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const { numberFormatter } = useFormat(); - const firstThreeRepackers = useMemo( - () => uniqueRepackers.slice(0, 3), - [uniqueRepackers] - ); - const remainingCount = useMemo( - () => uniqueRepackers.length - 3, - [uniqueRepackers] - ); - return ( )} - {rightContent} - {hintContent} ); } ); - TextField.displayName = "TextField"; diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index b94c94d7..abc359e9 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -98,6 +98,11 @@ export function CloudSyncContextProvider({ ); const getGameArtifacts = useCallback(async () => { + if (shop === "custom") { + setArtifacts([]); + return; + } + const params = new URLSearchParams({ objectId, shop, diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 14e5d587..bc1a6351 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,11 +1,4 @@ -import { - createContext, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; import { getSteamLanguage } from "@renderer/helpers"; @@ -13,11 +6,11 @@ import { useAppDispatch, useAppSelector, useDownload, - useRepacks, useUserDetails, } from "@renderer/hooks"; import type { + GameRepack, GameShop, GameStats, LibraryGame, @@ -84,12 +77,7 @@ export function GameDetailsContextProvider({ const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); - - const { getRepacksForObjectId } = useRepacks(); - - const repacks = useMemo(() => { - return getRepacksForObjectId(objectId); - }, [getRepacksForObjectId, objectId]); + const [repacks, setRepacks] = useState([]); const { i18n } = useTranslation("game_details"); const location = useLocation(); @@ -142,10 +130,12 @@ export function GameDetailsContextProvider({ } }); - window.electron.getGameStats(objectId, shop).then((result) => { - if (abortController.signal.aborted) return; - setStats(result); - }); + if (shop !== "custom") { + window.electron.getGameStats(objectId, shop).then((result) => { + if (abortController.signal.aborted) return; + setStats(result); + }); + } const assetsPromise = window.electron.getGameAssets(objectId, shop); @@ -167,7 +157,7 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - if (userDetails) { + if (userDetails && shop !== "custom") { window.electron .getUnlockedAchievements(objectId, shop) .then((achievements) => { @@ -287,19 +277,6 @@ export function GameDetailsContextProvider({ } }, [location]); - const lastDownloadedOption = useMemo(() => { - if (game?.download) { - const repack = repacks.find((repack) => - repack.uris.some((uri) => uri.includes(game.download!.uri)) - ); - - if (!repack) return null; - return repack; - } - - return null; - }, [game?.download, repacks]); - useEffect(() => { const unsubscribe = window.electron.onUpdateAchievements( objectId, @@ -315,6 +292,36 @@ export function GameDetailsContextProvider({ }; }, [objectId, shop, userDetails]); + useEffect(() => { + if (shop === "custom") return; + + const fetchDownloadSources = async () => { + try { + const sources = await window.electron.getDownloadSources(); + + const params = { + take: 100, + skip: 0, + downloadSourceIds: sources.map((source) => source.id), + }; + + const downloads = await window.electron.hydraApi.get( + `/games/${shop}/${objectId}/download-sources`, + { + params, + needsAuth: false, + } + ); + + setRepacks(downloads); + } catch (error) { + console.error("Failed to fetch download sources:", error); + } + }; + + fetchDownloadSources(); + }, [shop, objectId]); + const getDownloadsPath = async () => { if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; return window.electron.getDefaultDownloadsPath(); @@ -359,7 +366,7 @@ export function GameDetailsContextProvider({ stats, achievements, hasNSFWContentBlocked, - lastDownloadedOption, + lastDownloadedOption: null, setHasNSFWContentBlocked, selectGameExecutable, updateGame, diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index d5118792..e58904b4 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -20,12 +20,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -36,12 +39,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -68,6 +74,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -99,7 +108,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -121,18 +136,74 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + 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 && response.library.length > 0) { + setLibraryGames((prev) => { + const existingIds = new Set(prev.map((game) => game.objectId)); + const newGames = response.library.filter( + (game) => !existingIds.has(game.objectId) + ); + return [...prev, ...newGames]; + }); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -204,6 +275,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -217,12 +290,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 957cf3f6..3d15b4d7 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -31,8 +31,6 @@ import type { Game, DiskUsage, DownloadSource, - DownloadSourceValidationResult, - GameRepack, } from "@types"; import type { AxiosProgressEvent } from "axios"; @@ -161,6 +159,7 @@ declare global { ) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; + refreshLibraryAssets: () => Promise; openGameInstaller: (shop: GameShop, objectId: string) => Promise; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; @@ -210,20 +209,12 @@ declare global { /* Download sources */ addDownloadSource: (url: string) => Promise; - updateMissingFingerprints: () => Promise; - removeDownloadSource: (url: string, removeAll?: boolean) => Promise; - getDownloadSources: () => Promise< - Pick[] - >; - deleteDownloadSource: (id: number) => Promise; - deleteAllDownloadSources: () => Promise; - validateDownloadSource: ( - url: string - ) => Promise; - syncDownloadSources: () => Promise; - getDownloadSourcesList: () => Promise; - checkDownloadSourceExists: (url: string) => Promise; - getAllRepacks: () => Promise; + removeDownloadSource: ( + removeAll = false, + downloadSourceId?: string + ) => Promise; + getDownloadSources: () => Promise; + syncDownloadSources: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/features/download-sources-slice.ts b/src/renderer/src/features/download-sources-slice.ts deleted file mode 100644 index 52e58d26..00000000 --- a/src/renderer/src/features/download-sources-slice.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -export interface DownloadSourcesState { - isImporting: boolean; -} - -const initialState: DownloadSourcesState = { - isImporting: false, -}; - -export const downloadSourcesSlice = createSlice({ - name: "downloadSources", - initialState, - reducers: { - setIsImportingSources: (state, action) => { - state.isImporting = action.payload; - }, - }, -}); - -export const { setIsImportingSources } = downloadSourcesSlice.actions; diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index 3b602cff..a7e64e1f 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -6,6 +6,4 @@ export * from "./toast-slice"; export * from "./user-details-slice"; export * from "./game-running.slice"; export * from "./subscription-slice"; -export * from "./repacks-slice"; -export * from "./download-sources-slice"; export * from "./catalogue-search"; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 8140e0cd..73733e2b 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -5,5 +5,4 @@ export * from "./use-toast"; export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; -export * from "./use-repacks"; export * from "./use-feature"; diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 1d0aeb57..675f5013 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -2,6 +2,7 @@ import axios from "axios"; import { useCallback, useEffect, useState } from "react"; import { useAppDispatch } from "./redux"; import { setGenres, setTags } from "@renderer/features"; +import type { DownloadSource } from "@types"; export const externalResourcesInstance = axios.create({ baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, @@ -12,6 +13,7 @@ export function useCatalogue() { const [steamPublishers, setSteamPublishers] = useState([]); const [steamDevelopers, setSteamDevelopers] = useState([]); + const [downloadSources, setDownloadSources] = useState([]); const getSteamUserTags = useCallback(() => { externalResourcesInstance.get("/steam-user-tags.json").then((response) => { @@ -37,17 +39,25 @@ export function useCatalogue() { }); }, []); + const getDownloadSources = useCallback(() => { + window.electron.getDownloadSources().then((results) => { + setDownloadSources(results.filter((source) => !!source.fingerprint)); + }); + }, []); + useEffect(() => { getSteamUserTags(); getSteamGenres(); getSteamPublishers(); getSteamDevelopers(); + getDownloadSources(); }, [ getSteamUserTags, getSteamGenres, getSteamPublishers, getSteamDevelopers, + getDownloadSources, ]); - return { steamPublishers, steamDevelopers }; + return { steamPublishers, downloadSources, steamDevelopers }; } diff --git a/src/renderer/src/hooks/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts deleted file mode 100644 index c024aaa4..00000000 --- a/src/renderer/src/hooks/use-repacks.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { setRepacks } from "@renderer/features"; -import { useCallback } from "react"; -import { RootState } from "@renderer/store"; -import { useSelector } from "react-redux"; -import { useAppDispatch } from "./redux"; - -export function useRepacks() { - const dispatch = useAppDispatch(); - const repacks = useSelector((state: RootState) => state.repacks.value); - - const getRepacksForObjectId = useCallback( - (objectId: string) => { - return repacks.filter((repack) => repack.objectIds.includes(objectId)); - }, - [repacks] - ); - - const updateRepacks = useCallback(async () => { - const repacks = await window.electron.getAllRepacks(); - dispatch( - setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds))) - ); - }, [dispatch]); - - return { getRepacksForObjectId, updateRepacks }; -} diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts index 7cd22224..59ffd6af 100644 --- a/src/renderer/src/hooks/use-section-collapse.ts +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -3,12 +3,14 @@ import { useState, useCallback } from "react"; interface SectionCollapseState { pinned: boolean; library: boolean; + reviews: boolean; } export function useSectionCollapse() { const [collapseState, setCollapseState] = useState({ pinned: false, library: false, + reviews: false, }); const toggleSection = useCallback((section: keyof SectionCollapseState) => { @@ -23,5 +25,6 @@ export function useSectionCollapse() { toggleSection, isPinnedCollapsed: collapseState.pinned, isLibraryCollapsed: collapseState.library, + isReviewsCollapsed: collapseState.reviews, }; } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a1b5f7d0..84c7f815 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings"; import Profile from "./pages/profile/profile"; import Achievements from "./pages/achievements/achievements"; import ThemeEditor from "./pages/theme-editor/theme-editor"; +import Library from "./pages/library/library"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; console.log = logger.log; @@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( }> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index 07bcf3ff..b9eb3c24 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -1,4 +1,8 @@ -import type { CatalogueSearchResult, DownloadSource } from "@types"; +import type { + CatalogueSearchResult, + CatalogueSearchPayload, + DownloadSource, +} from "@types"; import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -29,13 +33,12 @@ export default function Catalogue() { const abortControllerRef = useRef(null); const cataloguePageRef = useRef(null); - const { steamDevelopers, steamPublishers } = useCatalogue(); + const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue(); - const { steamGenres, steamUserTags } = useAppSelector( + const { steamGenres, steamUserTags, filters, page } = useAppSelector( (state) => state.catalogueSearch ); - const [downloadSources, setDownloadSources] = useState([]); const [isLoading, setIsLoading] = useState(true); const [results, setResults] = useState([]); @@ -44,31 +47,46 @@ export default function Catalogue() { const { formatNumber } = useFormat(); - const { filters, page } = useAppSelector((state) => state.catalogueSearch); - const dispatch = useAppDispatch(); const { t, i18n } = useTranslation("catalogue"); const debouncedSearch = useRef( - debounce(async (filters, pageSize, offset) => { - const abortController = new AbortController(); - abortControllerRef.current = abortController; + debounce( + async ( + filters: CatalogueSearchPayload, + downloadSources: DownloadSource[], + pageSize: number, + offset: number + ) => { + const abortController = new AbortController(); + abortControllerRef.current = abortController; - const response = await window.electron.hydraApi.post<{ - edges: CatalogueSearchResult[]; - count: number; - }>("/catalogue/search", { - data: { ...filters, take: pageSize, skip: offset }, - needsAuth: false, - }); + const requestData = { + ...filters, + take: pageSize, + skip: offset, + downloadSourceIds: downloadSources.map( + (downloadSource) => downloadSource.id + ), + }; - if (abortController.signal.aborted) return; + const response = await window.electron.hydraApi.post<{ + edges: CatalogueSearchResult[]; + count: number; + }>("/catalogue/search", { + data: requestData, + needsAuth: false, + }); - setResults(response.edges); - setItemsCount(response.count); - setIsLoading(false); - }, 500) + if (abortController.signal.aborted) return; + + setResults(response.edges); + setItemsCount(response.count); + setIsLoading(false); + }, + 500 + ) ).current; const decodeHTML = (s: string) => @@ -79,18 +97,17 @@ export default function Catalogue() { setIsLoading(true); abortControllerRef.current?.abort(); - debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE); + debouncedSearch( + filters, + downloadSources, + PAGE_SIZE, + (page - 1) * PAGE_SIZE + ); return () => { debouncedSearch.cancel(); }; - }, [filters, page, debouncedSearch]); - - useEffect(() => { - window.electron.getDownloadSourcesList().then((sources) => { - setDownloadSources(sources.filter((source) => !!source.fingerprint)); - }); - }, []); + }, [filters, downloadSources, page, debouncedSearch]); const language = i18n.language.split("-")[0]; @@ -168,7 +185,7 @@ export default function Catalogue() { value: publisher, })), ]; - }, [filters, steamUserTags, steamGenresMapping, language, downloadSources]); + }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]); const filterSections = useMemo(() => { return [ diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index ecfe0f73..4583afd3 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -1,6 +1,6 @@ import { Badge } from "@renderer/components"; import { buildGameDetailsPath } from "@renderer/helpers"; -import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useLibrary } from "@renderer/hooks"; import { useMemo, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; @@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) { const { steamGenres } = useAppSelector((state) => state.catalogueSearch); - const { getRepacksForObjectId } = useRepacks(); - - const repacks = getRepacksForObjectId(game.objectId); - const [isAddingToLibrary, setIsAddingToLibrary] = useState(false); const [added, setAdded] = useState(false); @@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) { } }; - const uniqueRepackers = useMemo(() => { - return Array.from(new Set(repacks.map((repack) => repack.repacker))); - }, [repacks]); - const genres = useMemo(() => { return game.genres?.map((genre) => { const index = steamGenres["en"]?.findIndex( @@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) { {genres.join(", ")}
- {uniqueRepackers.map((repacker) => ( - {repacker} + {game.downloadSources.map((sourceName) => ( + {sourceName} ))}
diff --git a/src/renderer/src/pages/catalogue/pagination.scss b/src/renderer/src/pages/catalogue/pagination.scss index 141dfe54..cac10211 100644 --- a/src/renderer/src/pages/catalogue/pagination.scss +++ b/src/renderer/src/pages/catalogue/pagination.scss @@ -1,3 +1,5 @@ +@use "../../scss/globals.scss"; + .pagination { display: flex; gap: 4px; @@ -18,4 +20,31 @@ font-size: 16px; } } + + &__page-input { + box-sizing: border-box; + width: 40px; + min-width: 40px; + max-width: 40px; + min-height: 40px; + border-radius: 8px; + border: solid 1px globals.$border-color; + background-color: transparent; + color: globals.$muted-color; + text-align: center; + font-size: 12px; + padding: 0 6px; + outline: none; + } + + &__double-chevron { + display: flex; + align-items: center; + justify-content: center; + font-size: 0; // remove whitespace node width between SVGs + } + + &__double-chevron > svg + svg { + margin-left: -8px; // pull the second chevron closer + } } diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index dfae6164..ecc2afe3 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -1,8 +1,53 @@ import { Button } from "@renderer/components/button/button"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useFormat } from "@renderer/hooks/use-format"; +import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent, KeyboardEvent, RefObject } from "react"; import "./pagination.scss"; +interface JumpControlProps { + isOpen: boolean; + value: string; + totalPages: number; + inputRef: RefObject; + onOpen: () => void; + onClose: () => void; + onChange: (e: ChangeEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; +} + +function JumpControl({ + isOpen, + value, + totalPages, + inputRef, + onOpen, + onClose, + onChange, + onKeyDown, +}: JumpControlProps) { + return isOpen ? ( + + ) : ( + + ); +} + interface PaginationProps { page: number; totalPages: number; @@ -13,23 +58,104 @@ export function Pagination({ page, totalPages, onPageChange, -}: PaginationProps) { +}: Readonly) { const { formatNumber } = useFormat(); + const [isJumpOpen, setIsJumpOpen] = useState(false); + const [jumpValue, setJumpValue] = useState(""); + const jumpInputRef = useRef(null); + + useEffect(() => { + if (isJumpOpen) { + setJumpValue(""); + setTimeout(() => jumpInputRef.current?.focus(), 0); + } + }, [isJumpOpen, page]); + if (totalPages <= 1) return null; const visiblePages = 3; + const isLastThree = totalPages > 3 && page >= totalPages - 2; let startPage = Math.max(1, page - 1); let endPage = startPage + visiblePages - 1; - if (endPage > totalPages) { + if (isLastThree) { + startPage = Math.max(1, totalPages - 2); + endPage = totalPages; + } else if (endPage > totalPages) { endPage = totalPages; startPage = Math.max(1, endPage - visiblePages + 1); } + const onJumpChange = (e: ChangeEvent) => { + const raw = e.target.value; + const digitsOnly = raw.replaceAll(/\D+/g, ""); + if (digitsOnly === "") { + setJumpValue(""); + return; + } + const num = Number.parseInt(digitsOnly, 10); + if (Number.isNaN(num)) { + setJumpValue(""); + return; + } + if (num < 1) { + setJumpValue("1"); + return; + } + if (num > totalPages) { + setJumpValue(String(totalPages)); + return; + } + setJumpValue(String(num)); + }; + + const onJumpKeyDown = (e: KeyboardEvent) => { + const controlKeys = [ + "Backspace", + "Delete", + "Tab", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + ]; + + if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) { + return; + } + + if (e.key === "Enter") { + const sanitized = jumpValue.replaceAll(/\D+/g, ""); + if (sanitized.trim() === "") return; + const parsed = Number.parseInt(sanitized, 10); + if (Number.isNaN(parsed)) return; + const target = Math.max(1, Math.min(totalPages, parsed)); + onPageChange(target); + setIsJumpOpen(false); + } else if (e.key === "Escape") { + setIsJumpOpen(false); + } else if (!/^\d$/.test(e.key)) { + e.preventDefault(); + } + }; + return (
+ {startPage > 1 && ( + + )} + - {page > 2 && ( + {isLastThree && startPage > 1 && ( <> - -
- ... -
+ setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + /> )} @@ -70,11 +201,18 @@ export function Pagination({ ))} - {page < totalPages - 1 && ( + {!isLastThree && page < totalPages - 1 && ( <> -
- ... -
+ setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + /> + + {endPage < totalPages && ( + + )}
); } diff --git a/src/renderer/src/pages/game-details/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 ab51a212..63c4c974 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -228,7 +228,7 @@ export function GameDetailsContent() { )} - {game?.shop !== "custom" && shop && objectId && ( + {shop !== "custom" && shop && objectId && ( - {game?.shop !== "custom" && } + {shop !== "custom" && } diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index c1b8b551..6bc28c10 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -103,7 +103,6 @@ export default function GameDetails() { automaticallyExtract: boolean ) => { const response = await startDownload({ - repackId: repack.id, objectId: objectId!, title: gameTitle, downloader, diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx index f70c84b2..2dfd8864 100644 --- a/src/renderer/src/pages/game-details/game-reviews.tsx +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -117,7 +117,7 @@ export function GameReviews({ }); const checkUserReview = useCallback(async () => { - if (!objectId || !userDetailsId) return; + if (!objectId || !userDetailsId || shop === "custom") return; try { const response = await window.electron.hydraApi.get<{ @@ -147,7 +147,7 @@ export function GameReviews({ const loadReviews = useCallback( async (reset = false) => { - if (!objectId) return; + if (!objectId || shop === "custom") return; if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -163,7 +163,6 @@ export function GameReviews({ take: "20", skip: skip.toString(), sortBy: reviewsSortBy, - language: i18n.language, }); const response = await window.electron.hydraApi.get( diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss index 6bd63320..fd071eec 100644 --- a/src/renderer/src/pages/game-details/hero.scss +++ b/src/renderer/src/pages/game-details/hero.scss @@ -146,6 +146,8 @@ $hero-height: 350px; &__game-logo { width: 200px; align-self: flex-end; + object-fit: contain; + object-position: left bottom; @media (min-width: 768px) { width: 250px; @@ -153,6 +155,7 @@ $hero-height: 350px; @media (min-width: 1024px) { width: 300px; + max-height: 150px; } } @@ -228,44 +231,50 @@ $hero-height: 350px; } &__randomizer-button { - padding: calc(globals.$spacing-unit * 1.5); - background-color: rgba(0, 0, 0, 0.6); + position: fixed; + bottom: calc(globals.$spacing-unit * 5); + right: calc(globals.$spacing-unit * 2); + z-index: 100; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); + background-color: rgba(255, 255, 255, 0.08); 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; + gap: globals.$spacing-unit; color: globals.$muted-color; border: solid 1px globals.$border-color; - box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); + box-shadow: + 0px 0px 10px 0px rgba(0, 0, 0, 0.8), + 0px 2px 8px 0px rgba(255, 255, 255, 0.1); animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); + overflow: visible; - &:active { - opacity: 0.9; + &:disabled { + opacity: globals.$disabled-opacity; + cursor: not-allowed; } &:hover { - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(255, 255, 255, 0.12); color: globals.$body-color; } } &__stars-icon-container { - width: 20px; + width: 16px; height: 16px; - display: flex; - align-items: center; - justify-content: center; position: relative; } &__stars-icon { - width: 26px; + width: 70px; position: absolute; - top: -3px; + top: -28px; + left: -27px; } } diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 7551a31e..306e8647 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -54,7 +54,7 @@ export function RepacksModal({ {} ); - const { repacks, game } = useContext(gameDetailsContext); + const { game, repacks } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -88,6 +88,15 @@ export function RepacksModal({ }); }, [repacks, isFeatureEnabled, Feature]); + useEffect(() => { + const fetchDownloadSources = async () => { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + }; + + fetchDownloadSources(); + }, []); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -103,23 +112,13 @@ export function RepacksModal({ ); }, [repacks, hashesInDebrid]); - useEffect(() => { - window.electron.getDownloadSourcesList().then((sources) => { - const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker)); - const filteredSources = sources.filter( - (s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint - ); - setDownloadSources(filteredSources); - }); - }, [sortedRepacks]); - useEffect(() => { const term = filterTerm.trim().toLowerCase(); const byTerm = sortedRepacks.filter((repack) => { if (!term) return true; const lowerTitle = repack.title.toLowerCase(); - const lowerRepacker = repack.repacker.toLowerCase(); + const lowerRepacker = repack.downloadSourceName.toLowerCase(); return lowerTitle.includes(term) || lowerRepacker.includes(term); }); @@ -130,7 +129,7 @@ export function RepacksModal({ (src) => src.fingerprint && selectedFingerprints.includes(src.fingerprint) && - src.name === repack.repacker + src.name === repack.downloadSourceName ); }); @@ -281,7 +280,7 @@ export function RepacksModal({ )}

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

diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss index d4f2d38c..64657bfd 100644 --- a/src/renderer/src/pages/game-details/review-item.scss +++ b/src/renderer/src/pages/game-details/review-item.scss @@ -8,11 +8,23 @@ &__review-header { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); margin-bottom: calc(globals.$spacing-unit * 1.5); } + &__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + &__review-header-bottom { + display: flex; + justify-content: flex-start; + align-items: center; + } + &__review-user { display: flex; align-items: center; @@ -22,7 +34,13 @@ &__review-user-info { display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 0.25); + gap: calc(globals.$spacing-unit * 0.45); + } + + &__review-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); } &__review-display-name { @@ -157,28 +175,28 @@ &__review-score-stars { display: flex; align-items: center; - gap: 2px; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 500; + } + + &__review-right { + display: flex; + flex-direction: column; + align-items: flex-end; } &__review-star { - color: #666666; + color: rgba(255, 255, 255, 0.7); 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; - } + color: rgba(255, 255, 255, 0.7); } &--empty { @@ -198,6 +216,24 @@ font-size: globals.$small-font-size; } + &__review-playtime { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 0; + + svg { + color: rgba(255, 255, 255, 0.6); + } + } + &__review-content { color: globals.$body-color; line-height: 1.5; diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index f5e3528a..09d91df8 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -7,9 +7,10 @@ import { useState } from "react"; import type { GameReview } from "@types"; import { sanitizeHtml } from "@shared"; -import { useDate } from "@renderer/hooks"; +import { useDate, useFormat } from "@renderer/hooks"; import { formatNumber } from "@renderer/helpers"; import { Avatar } from "@renderer/components"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import "./review-item.scss"; @@ -29,13 +30,6 @@ interface ReviewItemProps { ) => void; } -const getScoreColorClass = (score: number): string => { - if (score >= 1 && score <= 2) return "game-details__review-score--red"; - if (score >= 3 && score <= 3) return "game-details__review-score--yellow"; - if (score >= 4 && score <= 5) return "game-details__review-score--green"; - return ""; -}; - const getRatingText = (score: number, t: (key: string) => string): string => { switch (score) { case 1: @@ -68,28 +62,22 @@ export function ReviewItem({ const navigate = useNavigate(); const { t, i18n } = useTranslation("game_details"); const { formatDistance } = useDate(); + const { numberFormatter } = useFormat(); const [showOriginal, setShowOriginal] = useState(false); - // Check if this is the user's own review const isOwnReview = userDetailsId === review.user.id; - // Helper to get base language code (e.g., "pt" from "pt-BR") - const getBaseLanguage = (lang: string) => lang.split("-")[0]; + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; - // Check if the review is in a different language (comparing base language codes) const isDifferentLanguage = getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); - // Check if translation is available and needed (but not for own reviews) const needsTranslation = - !isOwnReview && - isDifferentLanguage && - review.translations && - review.translations[i18n.language]; + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; - // Get the full language name using Intl.DisplayNames - const getLanguageName = (languageCode: string) => { + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; try { const displayNames = new Intl.DisplayNames([i18n.language], { type: "language", @@ -100,6 +88,20 @@ export function ReviewItem({ } }; + // Format playtime similar to hero panel + const formatPlayTime = (playTimeInSeconds: number) => { + const minutes = playTimeInSeconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }; + // Determine which content to show - always show original for own reviews const displayContent = needsTranslation ? review.translations[i18n.language] @@ -109,12 +111,12 @@ export function ReviewItem({ return (
- Review from blocked user —{" "} + {t("review_from_blocked_user")}
@@ -124,54 +126,61 @@ export function ReviewItem({ return (
-
- -
+
+
-
- - {formatDistance(new Date(review.createdAt), new Date(), { - addSuffix: true, - })} +
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
-
- {[1, 2, 3, 4, 5].map((starValue) => ( - - ))} +
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
@@ -323,7 +332,7 @@ export function ReviewItem({ className="game-details__blocked-review-hide-link" onClick={() => onToggleVisibility(review.id)} > - Hide + {t("hide")} )}
diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index 40bf181d..b8f632a6 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -40,14 +40,20 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const params = new URLSearchParams({ - take: "12", - skip: "0", - }); + const downloadSources = await window.electron.getDownloadSources(); + + const params = { + take: 12, + skip: 0, + downloadSourceIds: downloadSources.map((source) => source.id), + }; const catalogue = await window.electron.hydraApi.get( - `/catalogue/${category}?${params.toString()}`, - { needsAuth: false } + `/catalogue/${category}`, + { + params, + needsAuth: false, + } ); setCatalogue((prev) => ({ ...prev, [category]: catalogue })); diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss new file mode 100644 index 00000000..6f309662 --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.scss @@ -0,0 +1,63 @@ +@use "../../scss/globals.scss"; + +.library-filter-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; /* prevent label and count from wrapping */ + border: 1px solid rgba(0, 0, 0, 0.06); + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: #000; + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + + .library-filter-options__count { + background: #ebebeb; + color: rgba(0, 0, 0, 0.9); + } + } + } + + &__label { + font-weight: 500; + white-space: nowrap; + } + + &__count { + background: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.95); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + transition: all ease 0.2s; + } +} diff --git a/src/renderer/src/pages/library/filter-options.tsx b/src/renderer/src/pages/library/filter-options.tsx new file mode 100644 index 00000000..572ebd35 --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import "./filter-options.scss"; + +export type FilterOption = "all" | "favourited" | "new" | "top10"; + +interface FilterOptionsProps { + filterBy: FilterOption; + onFilterChange: (filterBy: FilterOption) => void; + allGamesCount: number; + favouritedCount: number; + newGamesCount: number; + top10Count: number; +} + +export function FilterOptions({ + filterBy, + onFilterChange, + allGamesCount, + favouritedCount, + newGamesCount, + top10Count, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+ + + + +
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss new file mode 100644 index 00000000..3fceac03 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,295 @@ +@use "../../scss/globals.scss"; + +.library-game-card-large { + width: 100%; + height: 300px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all ease 0.2s; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + text-align: left; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 74%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + + &:hover { + transform: scale(1.01); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.2) 50%, + rgba(0, 0, 0, 0.3) 100% + ); + z-index: 1; + } + + &__overlay { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: calc(globals.$spacing-unit * 2); + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + } + + &__menu-button { + align-self: flex-start; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.95); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.25); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } + + &__logo-container { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + &__logo { + max-height: 120px; + max-width: 400px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 28px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); + } + + &__info-bar { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + justify-content: flex-end; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.95); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 14px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 12px; + flex: 1 1 auto; + min-width: 0; + } + + &__achievement-header { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + } + + &__achievement-count { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + } + + &__action-button { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all ease 0.2s; + flex: 0 0 auto; + + &:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: scale(1.05); + } + + &:active { + transform: scale(0.98); + } + } + + &:hover &__menu-button { + opacity: 1; + transform: scale(1); + } + + &__action-icon--downloading { + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx new file mode 100644 index 00000000..5628fe10 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,279 @@ +import { LibraryGame } from "@types"; +import { useDownload, useFormat } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + PlayIcon, + DownloadIcon, + ClockIcon, + AlertFillIcon, + ThreeBarsIcon, + TrophyIcon, + XIcon, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card-large.scss"; + +interface LibraryGameCardLargeProps { + game: LibraryGame; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function LibraryGameCardLarge({ + game, +}: Readonly) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const { lastPacket } = useDownload(); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + }>({ visible: false, position: { x: 0, y: 0 } }); + + const isGameDownloading = + game?.download?.status === "active" && lastPacket?.gameId === game?.id; + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = () => { + navigate(buildGameDetailsPath(game)); + }; + + const { + handlePlayGame, + handleOpenDownloadOptions, + handleCloseGame, + isGameRunning, + } = useGameActions(game); + + const handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (isGameRunning) { + try { + await handleCloseGame(); + } catch (e) { + console.error(e); + } + return; + } + try { + await handlePlayGame(); + } catch (err) { + console.error(err); + try { + handleOpenDownloadOptions(); + } catch (e) { + console.error(e); + } + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMenuButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setContextMenu({ + visible: true, + position: { + x: e.currentTarget.getBoundingClientRect().right, + y: e.currentTarget.getBoundingClientRect().bottom, + }, + }); + }; + + const handleCloseContextMenu = () => { + setContextMenu({ visible: false, position: { x: 0, y: 0 } }); + }; + + // Use libraryHeroImageUrl as background, fallback to libraryImageUrl + const backgroundImage = getImageWithCustomPriority( + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl + ); + + // For logo, check if logoImageUrl exists (similar to game details page) + const logoImage = game.logoImageUrl; + + return ( + <> + +
+ +
+ {logoImage ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ +
+ {/* Achievements section */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} + + +
+
+ + + + ); +} diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss new file mode 100644 index 00000000..aa957d12 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,289 @@ +@use "../../scss/globals.scss"; + +.library-game-card { + &__wrapper { + cursor: pointer; + transition: all ease 0.2s; + box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); + width: 100%; + aspect-ratio: 3 / 4; + position: relative; + border: none; + background: none; + padding: 0; + border-radius: 4px; + overflow: hidden; + display: block; + container-type: inline-size; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.02); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + } + + &__overlay { + position: absolute; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + height: 100%; + width: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 5%, transparent 100%); + padding: 8px; + z-index: 2; + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &-long { + display: inline; + font-size: 12px; + } + + &-short { + display: none; + font-size: 12px; + } + + // When the card is narrow (less than 140px), show short format + @container (max-width: 140px) { + &-long { + display: none; + } + + &-short { + display: inline; + } + } + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; + opacity: 0; + transform: translateY(8px); + transition: all ease 0.2s; + pointer-events: none; + width: 100%; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-header { + display: flex; + align-items: center; + gap: 6px; + justify-content: space-between; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + margin-top: 8px; + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + position: relative; + } + + &__achievement-count { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + } + + &__action-button { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.2); + border-radius: 4px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); + } + + &:active { + transform: scale(0.95); + } + } + + &__menu-button { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.8); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.25); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } + + &__wrapper:hover &__action-button, + &__wrapper:hover &__menu-button { + opacity: 1; + transform: scale(1); + } + + &__wrapper:hover &__achievements { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + &__action-icon { + &--downloading { + animation: pulse 1.5s ease-in-out infinite; + } + } + + &__game-image { + object-fit: cover; + border-radius: 4px; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + display: block; + top: 0; + left: 0; + z-index: 0; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Force fixed size for compact grid cells so cards render at 220x320 */ +.library__games-grid--compact .library-game-card__wrapper { + width: 215px; + height: 320px; + aspect-ratio: unset; +} diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx new file mode 100644 index 00000000..a9f2aba2 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,202 @@ +import { LibraryGame } from "@types"; +import { useFormat } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + ClockIcon, + AlertFillIcon, + ThreeBarsIcon, + TrophyIcon, +} from "@primer/octicons-react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { Tooltip } from "react-tooltip"; +import { useTranslation } from "react-i18next"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card.scss"; + +interface LibraryGameCardProps { + game: LibraryGame; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +export function LibraryGameCard({ + game, + onMouseEnter, + onMouseLeave, +}: Readonly) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const [isTooltipHovered, setIsTooltipHovered] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + }>({ visible: false, position: { x: 0, y: 0 } }); + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = () => { + navigate(buildGameDetailsPath(game)); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMenuButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setContextMenu({ + visible: true, + position: { + x: e.currentTarget.getBoundingClientRect().right, + y: e.currentTarget.getBoundingClientRect().bottom, + }, + }); + }; + + const handleCloseContextMenu = () => { + setContextMenu({ visible: false, position: { x: 0, y: 0 } }); + }; + + const coverImage = + game.coverImageUrl ?? + game.libraryImageUrl ?? + game.libraryHeroImageUrl ?? + game.iconUrl ?? + undefined; + + return ( + <> + +
+ + {/* Achievements section - shown on hover */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} +
+ + {game.title} + + setIsTooltipHovered(true)} + afterHide={() => setIsTooltipHovered(false)} + /> + + + ); +} diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss new file mode 100644 index 00000000..9b660a45 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,207 @@ +@use "../../scss/globals.scss"; + +.library { + &__content { + padding: calc(globals.$spacing-unit * 3); + height: 100%; + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + align-items: flex-start; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + &__page-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + width: 100%; + } + + &__page-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } + + &__controls-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: calc(globals.$spacing-unit * 2); + } + + &__controls-left { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__controls-right { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__header-controls { + display: flex; + flex-direction: column; + align-items: end; + gap: calc(globals.$spacing-unit * 1); + &__left { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + } + } + &__header-title { + font-size: 20px; + font-weight: 700; + } + &__filter-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + &__separator { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.1); + border: none; + margin: 0; + } + + &__count { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 8px 16px; + } + + &__count-label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + } + + &__count-number { + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 600; + } + + &__no-games { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 4); + } + + &__telescope-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__games-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Grid view - larger cards + &--grid { + grid-template-columns: repeat(2, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(4, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(6, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(8, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(12, 1fr); + } + } + + // Compact view - smaller cards + &--compact { + grid-template-columns: repeat(auto-fill, 215px); + grid-auto-rows: 320px; + justify-content: start; + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + /* keep same pattern for very large screens */ + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(auto-fill, 210px); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..1cafc870 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLibrary, useAppDispatch } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { TelescopeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { LibraryGameCard } from "./library-game-card"; +// detailed view removed — keep file if needed later +import { LibraryGameCardLarge } from "./library-game-card-large"; +import { ViewOptions, ViewMode } from "./view-options"; +import { FilterOptions, FilterOption } from "./filter-options"; +import { SearchBar } from "./search-bar"; +import "./library.scss"; + +export default function Library() { + const { library, updateLibrary } = useLibrary(); + type ElectronAPI = { + refreshLibraryAssets?: () => Promise; + onLibraryBatchComplete?: (cb: () => void) => () => void; + }; + + const [viewMode, setViewMode] = useState("compact"); + const [filterBy, setFilterBy] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const dispatch = useAppDispatch(); + const { t } = useTranslation("library"); + + useEffect(() => { + dispatch(setHeaderTitle(t("library"))); + const electron = (globalThis as unknown as { electron?: ElectronAPI }) + .electron; + let unsubscribe: () => void = () => undefined; + if (electron?.refreshLibraryAssets) { + electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); + if (electron.onLibraryBatchComplete) { + unsubscribe = electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + } + } else { + updateLibrary(); + } + + return () => { + unsubscribe(); + }; + }, [dispatch, t, updateLibrary]); + + const handleOnMouseEnterGameCard = () => { + // Optional: pause animations if needed + }; + + const handleOnMouseLeaveGameCard = () => { + // Optional: resume animations if needed + }; + + const filteredLibrary = useMemo(() => { + let filtered; + + switch (filterBy) { + case "favourited": + filtered = library.filter((game) => game.favorite); + break; + case "new": + filtered = library.filter( + (game) => (game.playTimeInMilliseconds || 0) === 0 + ); + break; + case "top10": + filtered = library + .slice() + .sort( + (a, b) => + (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) + ) + .slice(0, 10); + break; + case "all": + default: + filtered = library; + } + + if (!searchQuery.trim()) return filtered; + + const queryLower = searchQuery.toLowerCase(); + return filtered.filter((game) => { + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); + }, [library, filterBy, searchQuery]); + + // No sorting for now — rely on filteredLibrary + const sortedLibrary = filteredLibrary; + + // Calculate counts for filters + const allGamesCount = library.length; + const favouritedCount = library.filter((game) => game.favorite).length; + const newGamesCount = library.filter( + (game) => (game.playTimeInMilliseconds || 0) === 0 + ).length; + const top10Count = Math.min(10, library.length); + + const hasGames = library.length > 0; + + return ( +
+ {hasGames && ( +
+
+
+ +
+ +
+ + +
+
+
+ )} + + {!hasGames && ( +
+
+ +
+

{t("no_games_title")}

+

{t("no_games_description")}

+
+ )} + + {hasGames && viewMode === "large" && ( +
+ {sortedLibrary.map((game) => ( + + ))} +
+ )} + + {hasGames && viewMode !== "large" && ( +
    + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/library/search-bar.scss b/src/renderer/src/pages/library/search-bar.scss new file mode 100644 index 00000000..6b09c683 --- /dev/null +++ b/src/renderer/src/pages/library/search-bar.scss @@ -0,0 +1,75 @@ +.search-bar { + display: flex; + align-items: center; + + &__container { + height: 32px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + transition: all 0.2s ease; + width: 250px; + + &:focus-within { + width: 300px; + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.2); + } + } + + &__icon { + color: rgba(255, 255, 255, 0.75); + flex-shrink: 0; + transition: color 0.2s ease; + } + + &__input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-family: inherit; + min-width: 0; + + &::placeholder { + color: rgba(255, 255, 255, 0.6); + } + + &:focus ~ .search-bar__icon { + color: rgba(255, 255, 255, 0.7); + } + } + + &__clear { + flex-shrink: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.65); + font-size: 18px; + font-weight: bold; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + } + + &:active { + background: rgba(255, 255, 255, 0.15); + } + } +} diff --git a/src/renderer/src/pages/library/search-bar.tsx b/src/renderer/src/pages/library/search-bar.tsx new file mode 100644 index 00000000..5a2da667 --- /dev/null +++ b/src/renderer/src/pages/library/search-bar.tsx @@ -0,0 +1,44 @@ +import { SearchIcon } from "@primer/octicons-react"; +import { FC, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import "./search-bar.scss"; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; +} + +export const SearchBar: FC = ({ value, onChange }) => { + const { t } = useTranslation(); + const inputRef = useRef(null); + + const handleClear = () => { + onChange(""); + inputRef.current?.focus(); + }; + + return ( +
+
+ + onChange(e.target.value)} + /> + {value && ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss new file mode 100644 index 00000000..77bfc10e --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,55 @@ +@use "../../scss/globals.scss"; + +.library-view-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + white-space: nowrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: none; + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; + + &:hover { + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.06); + } + + &.active { + color: rgba(0, 0, 0, 0.9); + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + } + } +} diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx new file mode 100644 index 00000000..905fac58 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.tsx @@ -0,0 +1,45 @@ +import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./view-options.scss"; + +export type ViewMode = "grid" | "compact" | "large"; + +interface ViewOptionsProps { + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; +} + +export function ViewOptions({ + viewMode, + onViewModeChange, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+
+ + + +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/library-tab.tsx b/src/renderer/src/pages/profile/profile-content/library-tab.tsx new file mode 100644 index 00000000..1bc78c05 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/library-tab.tsx @@ -0,0 +1,178 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { TelescopeIcon } from "@primer/octicons-react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useFormat } from "@renderer/hooks"; +import type { UserGame } from "@types"; +import { SortOptions } from "./sort-options"; +import { UserLibraryGameCard } from "./user-library-game-card"; +import "./profile-content.scss"; + +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + +interface LibraryTabProps { + sortBy: SortOption; + onSortChange: (sortBy: SortOption) => void; + pinnedGames: UserGame[]; + libraryGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; + statsIndex: number; + userStats: { libraryCount: number } | null; + animatedGameIdsRef: React.MutableRefObject>; + onLoadMore: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + isMe: boolean; +} + +export function LibraryTab({ + sortBy, + onSortChange, + pinnedGames, + libraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, + statsIndex, + userStats, + animatedGameIdsRef, + onLoadMore, + onMouseEnter, + onMouseLeave, + isMe, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const hasGames = libraryGames.length > 0; + const hasPinnedGames = pinnedGames.length > 0; + const hasAnyGames = hasGames || hasPinnedGames; + + return ( + + {hasAnyGames && ( + + )} + + {!hasAnyGames && ( +
+
+ +
+

{t("no_recent_activity_title")}

+ {isMe &&

{t("no_recent_activity_description")}

} +
+ )} + + {hasAnyGames && ( +
+ {hasPinnedGames && ( +
+
+
+

{t("pinned")}

+ + {pinnedGames.length} + +
+
+ +
    + {pinnedGames?.map((game) => ( +
  • + +
  • + ))} +
+
+ )} + + {hasGames && ( +
+
+
+

{t("library")}

+ {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
+
+ + +
    + {libraryGames?.map((game, index) => { + const hasAnimated = animatedGameIdsRef.current.has( + game.objectId + ); + const isNewGame = !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add(game.objectId); + } + }} + > + + + ); + })} +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 4cce8d23..4f4cf7ba 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,40 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); &--active { color: white; - border-bottom-color: #c9aa71; } } + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -175,6 +201,246 @@ backdrop-filter: blur(10px); } } + + &__tab-panels { + display: block; + } + } +} + +// Reviews minimal styles +.user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + +.user-reviews__empty { + text-align: center; + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.6); +} + +.user-reviews__list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 4); +} + +.user-reviews__review-item { + border-radius: 8px; +} + +.user-reviews__review-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.user-reviews__review-header-bottom { + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-game { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__game-icon { + width: 24px; + height: 24px; + object-fit: cover; +} + +.user-reviews__game-info { + display: flex; + flex-direction: column; +} + +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__game-title { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + cursor: pointer; + text-align: left; + + &--clickable:hover { + text-decoration: underline; + } +} + +.user-reviews__review-date { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; +} + +.user-reviews__review-score-stars { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 500; +} + +.user-reviews__review-star { + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; + + &--filled { + color: rgba(255, 255, 255, 0.7); + + svg { + fill: currentColor; + } + } +} + +.user-reviews__review-score-text { + font-weight: 500; +} + +.user-reviews__review-playtime { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + max-width: 100%; +} + +.user-reviews__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); + } +} + +.user-reviews__review-actions { + margin-top: calc(globals.$spacing-unit * 2); + padding-top: calc(globals.$spacing-unit); + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-votes { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__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 { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); + + svg { + fill: white; + } + } +} + +.user-reviews__delete-review-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 6px; + padding: 6px 10px; + color: #f44336; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: rgba(244, 67, 54, 0.4); + color: #ff7961; } &__images-section { 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 830c6a84..186ec439 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,7 +1,14 @@ import { userProfileContext } from "@renderer/context"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat } from "@renderer/hooks"; +import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon, @@ -9,6 +16,7 @@ import { SearchIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; @@ -25,10 +33,65 @@ import { GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal"; +import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { ProfileTabs } from "./profile-tabs"; +import { LibraryTab } from "./library-tab"; +import { ReviewsTab } from "./reviews-tab"; +import { AnimatePresence } from "framer-motion"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface UserReviewsResponse { + totalCount: number; + reviews: UserReview[]; +} + +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 ProfileContent() { const { userProfile, @@ -37,7 +100,11 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); @@ -46,11 +113,34 @@ export function ProfileContent() { alt: string; } | null>(null); const statsAnimation = useRef(-1); - const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); + + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + + // User reviews state + const [reviews, setReviews] = useState([]); + const [reviewsTotalCount, setReviewsTotalCount] = useState(0); + const [isLoadingReviews, setIsLoadingReviews] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [reviewToDelete, setReviewToDelete] = useState(null); const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const formatPlayTime = (playTimeInSeconds: number) => { + const minutes = playTimeInSeconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }; useEffect(() => { dispatch(setHeaderTitle("")); @@ -62,10 +152,201 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + // When sortBy changes, clear animated games so all games animate in + if (currentSortByRef.current !== sortBy) { + animatedGameIdsRef.current.clear(); + currentSortByRef.current = sortBy; + } + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const animatedGameIdsRef = useRef>(new Set()); + const currentSortByRef = useRef(sortBy); + + const handleLoadMore = useCallback(() => { + if ( + activeTab === "library" && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + ]); + + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + + useEffect(() => { + if (userProfile?.id) { + fetchUserReviews(); + } + }, [userProfile?.id]); + + const fetchUserReviews = async () => { + if (!userProfile?.id) return; + + setIsLoadingReviews(true); + try { + const response = await window.electron.hydraApi.get( + `/users/${userProfile.id}/reviews`, + { needsAuth: true } + ); + setReviews(response.reviews); + setReviewsTotalCount(response.totalCount); + } catch (error) { + // Error handling for fetching reviews + } finally { + setIsLoadingReviews(false); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + try { + const reviewToDeleteObj = reviews.find( + (review) => review.id === reviewId + ); + if (!reviewToDeleteObj) return; + + await window.electron.hydraApi.delete( + `/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}` + ); + // Remove the review from the local state + setReviews((prev) => prev.filter((review) => review.id !== reviewId)); + setReviewsTotalCount((prev) => prev - 1); + } catch (error) { + console.error("Failed to delete review:", error); + } + }; + + const handleDeleteClick = (reviewId: string) => { + setReviewToDelete(reviewId); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = () => { + if (reviewToDelete) { + handleDeleteReview(reviewToDelete); + setReviewToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setReviewToDelete(null); + }; + + const handleVoteReview = async (reviewId: string, isUpvote: boolean) => { + if (votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const review = reviews.find((r) => r.id === reviewId); + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const wasUpvoted = review.hasUpvoted; + const wasDownvoted = review.hasDownvoted; + + // Optimistic update + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + + let newUpvotes = r.upvotes; + let newDownvotes = r.downvotes; + let newHasUpvoted = r.hasUpvoted; + let newHasDownvoted = r.hasDownvoted; + + if (isUpvote) { + if (wasUpvoted) { + // Remove upvote + newUpvotes--; + newHasUpvoted = false; + } else { + // Add upvote + newUpvotes++; + newHasUpvoted = true; + if (wasDownvoted) { + // Remove downvote if it was downvoted + newDownvotes--; + newHasDownvoted = false; + } + } + } else if (wasDownvoted) { + // Remove downvote + newDownvotes--; + newHasDownvoted = false; + } else { + // Add downvote + newDownvotes++; + newHasDownvoted = true; + if (wasUpvoted) { + // Remove upvote if it was upvoted + newUpvotes--; + newHasUpvoted = false; + } + } + + return { + ...r, + upvotes: newUpvotes, + downvotes: newDownvotes, + hasUpvoted: newHasUpvoted, + hasDownvoted: newHasDownvoted, + }; + }) + ); + + try { + const endpoint = isUpvote ? "upvote" : "downvote"; + await window.electron.hydraApi.put( + `/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}` + ); + } catch (error) { + console.error("Failed to vote on review:", error); + + // Rollback optimistic update on error + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + return { + ...r, + upvotes: review.upvotes, + downvotes: review.downvotes, + hasUpvoted: review.hasUpvoted, + hasDownvoted: review.hasDownvoted, + }; + }) + ); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); + } + }; + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -106,8 +387,6 @@ export function ProfileContent() { }; }, [setStatsIndex, isAnimationRunning]); - const { numberFormatter } = useFormat(); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -321,9 +600,46 @@ export function ProfileContent() { ))}
+ + +
+ + {activeTab === "library" && ( + )} -
- )} + + {activeTab === "reviews" && ( + + )} + +
{shouldShowRightContent && ( @@ -335,6 +651,12 @@ export function ProfileContent() { )} + + ); }, [ @@ -347,9 +669,15 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + sortBy, + activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx new file mode 100644 index 00000000..bea569e7 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -0,0 +1,252 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useNavigate } from "react-router-dom"; +import { ClockIcon } from "@primer/octicons-react"; +import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { GameShop } from "@types"; +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ProfileReviewItemProps { + review: UserReview; + isOwnReview: boolean; + isVoting: boolean; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ProfileReviewItem({ + review, + isOwnReview, + isVoting, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const navigate = useNavigate(); + const { formatDistance } = useDate(); + const { t } = useTranslation("user_profile"); + const { t: tGameDetails, i18n } = useTranslation("game_details"); + const [showOriginal, setShowOriginal] = useState(false); + + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; + + const isDifferentLanguage = + getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); + + const needsTranslation = + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; + + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; + try { + const displayNames = new Intl.DisplayNames([i18n.language], { + type: "language", + }); + return displayNames.of(languageCode) || languageCode.toUpperCase(); + } catch { + return languageCode.toUpperCase(); + } + }; + + const displayContent = needsTranslation + ? review.translations[i18n.language] + : review.reviewHtml; + + return ( + +
+
+
+
+
+ {review.game.title} + +
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
+
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
+
+ +
+
+ {needsTranslation && ( + <> + + {showOriginal && ( +
+ )} + + )} +
+ +
+
+ onVote(review.id, true)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + + + onVote(review.id, false)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
+ + {isOwnReview && ( + + )} +
+ + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx new file mode 100644 index 00000000..bc76f40c --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import "./profile-content.scss"; + +interface ProfileTabsProps { + activeTab: "library" | "reviews"; + reviewsTotalCount: number; + onTabChange: (tab: "library" | "reviews") => void; +} + +export function ProfileTabs({ + activeTab, + reviewsTotalCount, + onTabChange, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( +
+
+ + {activeTab === "library" && ( + + )} +
+
+ + {activeTab === "reviews" && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx new file mode 100644 index 00000000..afcc417b --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -0,0 +1,96 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; +import { ProfileReviewItem } from "./profile-review-item"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ReviewsTabProps { + reviews: UserReview[]; + isLoadingReviews: boolean; + votingReviews: Set; + userDetailsId?: string; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ReviewsTab({ + reviews, + isLoadingReviews, + votingReviews, + userDetailsId, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( + + {isLoadingReviews && ( +
{t("loading_reviews")}
+ )} + {!isLoadingReviews && reviews.length === 0 && ( +
+

{t("no_reviews", "No reviews yet")}

+
+ )} + {!isLoadingReviews && reviews.length > 0 && ( +
+ {reviews.map((review) => { + const isOwnReview = userDetailsId === review.user.id; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index 61640536..76bd25a9 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -36,6 +36,7 @@ box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); width: 100%; position: relative; + overflow: hidden; &:before { content: ""; @@ -193,8 +194,28 @@ border-radius: 4px; width: 100%; height: 100%; - min-width: 100%; - min-height: 100%; + display: block; + } + + &__cover-placeholder { + position: relative; + width: 100%; + padding-bottom: 150%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } } &__achievements-progress { 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 72b48a8c..81db6334 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 @@ -2,7 +2,7 @@ import { UserGame } from "@types"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useFormat, useToast } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { useCallback, useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { buildGameAchievementPath, buildGameDetailsPath, @@ -15,6 +15,7 @@ import { AlertFillIcon, PinIcon, PinSlashIcon, + ImageIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -44,6 +45,11 @@ export function UserLibraryGameCard({ const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isPinning, setIsPinning] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + setImageError(false); + }, [game.coverImageUrl]); const getStatsItemCount = useCallback(() => { let statsCount = 1; @@ -233,11 +239,18 @@ export function UserLibraryGameCard({ )}
- {game.title} + {imageError || !game.coverImageUrl ? ( +
+ +
+ ) : ( + {game.title} setImageError(true)} + /> + )} ) { - const [url, setUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation("settings"); @@ -48,77 +45,43 @@ export function AddDownloadSourceModal({ resolver: yupResolver(schema), }); - const [validationResult, setValidationResult] = - useState(null); - const { sourceUrl } = useContext(settingsContext); - const dispatch = useAppDispatch(); + const onSubmit = async (values: FormValues) => { + setIsLoading(true); - const onSubmit = useCallback( - async (values: FormValues) => { - const exists = await window.electron.checkDownloadSourceExists( - values.url - ); + try { + await window.electron.addDownloadSource(values.url); - if (exists) { - setError("url", { - type: "server", - message: t("source_already_exists"), - }); + onClose(); + onAddDownloadSource(); + } catch (error) { + logger.error("Failed to add download source:", error); + const errorMessage = + error instanceof Error && error.message.includes("already exists") + ? t("download_source_already_exists") + : t("failed_add_download_source"); - return; - } - - const validationResult = await window.electron.validateDownloadSource( - values.url - ); - - setValidationResult(validationResult); - setUrl(values.url); - }, - [setError, t] - ); + setError("url", { + type: "server", + message: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; useEffect(() => { setValue("url", ""); clearErrors(); setIsLoading(false); - setValidationResult(null); if (sourceUrl) { setValue("url", sourceUrl); - handleSubmit(onSubmit)(); } - }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); - - const handleAddDownloadSource = async () => { - if (validationResult) { - setIsLoading(true); - dispatch(setIsImportingSources(true)); - - try { - // Single call that handles: import → API sync → fingerprint - await window.electron.addDownloadSource(url); - - // Close modal and update UI - onClose(); - onAddDownloadSource(); - } catch (error) { - console.error("Failed to add download source:", error); - setError("url", { - type: "server", - message: "Failed to import source. Please try again.", - }); - } finally { - setIsLoading(false); - dispatch(setIsImportingSources(false)); - } - } - }; + }, [visible, clearErrors, setValue, sourceUrl]); const handleClose = () => { - // Prevent closing while importing if (isLoading) return; onClose(); }; @@ -132,49 +95,32 @@ export function AddDownloadSourceModal({ clickOutsideToClose={!isLoading} >
- + + +
- } - /> - - {validationResult && ( -
-
-

{validationResult?.name}

- - {t("found_download_option", { - count: validationResult?.downloadCount, - countFormatted: - validationResult?.downloadCount.toLocaleString(), - })} - -
- - + +
- )} +
); diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx index 9cf35541..f2825cca 100644 --- a/src/renderer/src/pages/settings/settings-account.tsx +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -201,7 +201,7 @@ export function SettingsAccount() {
-

Hydra Cloud

+

{t("hydra_cloud")}

{getHydraCloudSectionContent().description}
diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index 6f75dc34..42a73d9d 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() { extractFilesByDefault: true, enableSteamAchievements: false, enableAchievementScreenshots: false, + autoplayGameTrailers: true, + hideToTrayOnGameStart: false, }); const { t } = useTranslation("settings"); @@ -51,6 +53,8 @@ export function SettingsBehavior() { userPreferences.enableSteamAchievements ?? false, enableAchievementScreenshots: userPreferences.enableAchievementScreenshots ?? false, + autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true, + hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false, }); } }, [userPreferences]); @@ -78,6 +82,16 @@ export function SettingsBehavior() { } /> + + handleChange({ + hideToTrayOnGameStart: !form.hideToTrayOnGameStart, + }) + } + /> + {showRunAtStartup && ( )} + + handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers }) + } + /> + { - await window.electron - .getDownloadSourcesList() - .then((sources) => { - setDownloadSources(sources); - }) - .finally(() => { - setIsFetchingSources(false); - }); - }; - - useEffect(() => { - getDownloadSources(); - }, []); - useEffect(() => { if (sourceUrl) setShowAddDownloadSourceModal(true); }, [sourceUrl]); + useEffect(() => { + const fetchDownloadSources = async () => { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + }; + + fetchDownloadSources(); + }, []); + + useEffect(() => { + const hasPendingOrMatchingSource = downloadSources.some( + (source) => + source.status === DownloadSourceStatus.PendingMatching || + source.status === DownloadSourceStatus.Matching + ); + + if (!hasPendingOrMatchingSource || !downloadSources.length) { + return; + } + + const intervalId = setInterval(async () => { + try { + await window.electron.syncDownloadSources(); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + } catch (error) { + logger.error("Failed to fetch download sources:", error); + } + }, 5000); + + return () => clearInterval(intervalId); + }, [downloadSources]); + const handleRemoveSource = async (downloadSource: DownloadSource) => { setIsRemovingDownloadSource(true); try { - await window.electron.deleteDownloadSource(downloadSource.id); - await window.electron.removeDownloadSource(downloadSource.url); - + await window.electron.removeDownloadSource(false, downloadSource.id); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); showSuccessToast(t("removed_download_source")); - await getDownloadSources(); - updateRepacks(); + } catch (error) { + logger.error("Failed to remove download source:", error); } finally { setIsRemovingDownloadSource(false); } @@ -86,53 +102,47 @@ export function SettingsDownloadSources() { setIsRemovingDownloadSource(true); try { - await window.electron.deleteAllDownloadSources(); - await window.electron.removeDownloadSource("", true); - - showSuccessToast(t("removed_download_sources")); - await getDownloadSources(); - setShowConfirmationDeleteAllSourcesModal(false); - updateRepacks(); + await window.electron.removeDownloadSource(true); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + showSuccessToast(t("removed_all_download_sources")); + } catch (error) { + logger.error("Failed to remove all download sources:", error); } finally { setIsRemovingDownloadSource(false); + setShowConfirmationDeleteAllSourcesModal(false); } }; const handleAddDownloadSource = async () => { - // Refresh sources list and repacks after import completes - await getDownloadSources(); - - // Force repacks update to ensure UI reflects new data - await updateRepacks(); - - showSuccessToast(t("added_download_source")); + try { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + } catch (error) { + logger.error("Failed to refresh download sources:", error); + } }; const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); - try { - // Sync local sources (check for updates) await window.electron.syncDownloadSources(); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); - // Refresh sources and repacks AFTER sync completes - await getDownloadSources(); - await updateRepacks(); - - showSuccessToast(t("download_sources_synced")); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still refresh the UI even if sync fails - await getDownloadSources(); - await updateRepacks(); + showSuccessToast(t("download_sources_synced_successfully")); } finally { setIsSyncingDownloadSources(false); } }; const statusTitle = { - [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"), - [DownloadSourceStatus.Errored]: t("download_source_errored"), + [DownloadSourceStatus.PendingMatching]: t( + "download_source_pending_matching" + ), + [DownloadSourceStatus.Matched]: t("download_source_matched"), + [DownloadSourceStatus.Matching]: t("download_source_matching"), + [DownloadSourceStatus.Failed]: t("download_source_failed"), }; const handleModalClose = () => { @@ -142,7 +152,7 @@ export function SettingsDownloadSources() { const navigateToCatalogue = (fingerprint?: string) => { if (!fingerprint) { - console.error("Cannot navigate: fingerprint is undefined"); + logger.error("Cannot navigate: fingerprint is undefined"); return; } @@ -180,8 +190,7 @@ export function SettingsDownloadSources() { disabled={ !downloadSources.length || isSyncingDownloadSources || - isRemovingDownloadSource || - isFetchingSources + isRemovingDownloadSource } onClick={syncDownloadSources} > @@ -197,8 +206,7 @@ export function SettingsDownloadSources() { disabled={ isRemovingDownloadSource || isSyncingDownloadSources || - !downloadSources.length || - isFetchingSources + !downloadSources.length } > @@ -209,11 +217,7 @@ export function SettingsDownloadSources() { type="button" theme="outline" onClick={() => setShowAddDownloadSourceModal(true)} - disabled={ - isSyncingDownloadSources || - isFetchingSources || - isRemovingDownloadSource - } + disabled={isSyncingDownloadSources || isRemovingDownloadSource} > {t("add_download_source")} @@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
    {downloadSources.map((downloadSource) => { + const isPendingOrMatching = + downloadSource.status === DownloadSourceStatus.PendingMatching || + downloadSource.status === DownloadSourceStatus.Matching; + return (
  • {downloadSource.name}

    - {statusTitle[downloadSource.status]} + + {isPendingOrMatching && ( + + )} + {statusTitle[downloadSource.status]} +
    diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx index 42ba6ad9..db3a29a3 100644 --- a/src/renderer/src/pages/settings/settings-real-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx @@ -133,7 +133,7 @@ export function SettingsRealDebrid() { {t("save_changes")} } - placeholder="API Token" + placeholder={t("api_token")} hint={ diff --git a/src/renderer/src/pages/settings/settings-torbox.tsx b/src/renderer/src/pages/settings/settings-torbox.tsx index 610dc942..46c8e2f9 100644 --- a/src/renderer/src/pages/settings/settings-torbox.tsx +++ b/src/renderer/src/pages/settings/settings-torbox.tsx @@ -116,7 +116,7 @@ export function SettingsTorBox() { onChange={(event) => setForm({ ...form, torBoxApiToken: event.target.value }) } - placeholder="API Token" + placeholder={t("api_token")} rightContent={