Merge branch 'main' into feat/souvenirs-for-achievements

This commit is contained in:
Moyase
2025-11-09 17:46:08 +02:00
committed by GitHub
125 changed files with 4703 additions and 1878 deletions

37
.cursorrules Normal file
View File

@@ -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).

View File

@@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL= MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE= RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -6,23 +6,37 @@ concurrency:
on: on:
push: push:
branches: main branches:
- release/**
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
BRANCH_NAME: ${{ github.ref_name }}
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.18.0 node-version: 22.21.0
cache: "yarn"
- name: Enable Corepack (Yarn)
run: corepack enable
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts run: yarn install --frozen-lockfile --ignore-scripts
- name: Build Renderer - name: Build Renderer
run: yarn build run: yarn build
@@ -36,5 +50,5 @@ jobs:
run: | run: |
npx --yes wrangler@3 pages deploy out/renderer \ npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \ --project-name="hydra" \
--commit-dirty=true \ --branch "$BRANCH_NAME" \
--branch="main" --commit-dirty

View File

@@ -1,11 +1,12 @@
name: Build name: Build
on:
pull_request:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
on: pull_request
jobs: jobs:
build: build:
strategy: strategy:
@@ -22,7 +23,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.18.3 node-version: 22.21.0
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
@@ -38,6 +39,12 @@ jobs:
- name: Build with cx_Freeze - name: Build with cx_Freeze
run: python python_rpc/setup.py build 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 - name: Build Linux
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.18.3 node-version: 22.21.0
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile run: yarn --frozen-lockfile

View File

@@ -6,7 +6,8 @@ concurrency:
on: on:
push: push:
branches: [main] branches:
- release/**
jobs: jobs:
build: build:
@@ -23,7 +24,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.18.3 node-version: 22.21.0
- name: Install dependencies - name: Install dependencies
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
@@ -39,6 +40,12 @@ jobs:
- name: Build with cx_Freeze - name: Build with cx_Freeze
run: python python_rpc/setup.py build 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 - name: Build Linux
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
@@ -55,7 +62,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} 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 - name: Build Windows
if: matrix.os == 'windows-2022' if: matrix.os == 'windows-2022'
@@ -72,7 +79,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} 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 - name: Create artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -95,9 +95,12 @@ jobs:
- name: Update PKGBUILD and .SRCINFO - name: Update PKGBUILD and .SRCINFO
if: steps.check-update.outputs.update_needed == 'true' if: steps.check-update.outputs.update_needed == 'true'
run: | run: |
# sleeps for 1 minute to be sure GH updated the release info
sleep 60
# Update pkgver in PKGBUILD # Update pkgver in PKGBUILD
cd hydra-launcher-bin cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}" NEW_VERSION="${{ steps.get-version.outputs.version }}"
NEW_VERSION="${NEW_VERSION#v}"
echo "Updating PKGBUILD pkgver to $NEW_VERSION" echo "Updating PKGBUILD pkgver to $NEW_VERSION"
@@ -137,6 +140,9 @@ jobs:
COMMIT_MSG="v${{ steps.get-version.outputs.version }}" COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG" 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 git push origin master
echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}" echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}"
fi fi

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.1", "version": "3.7.4",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -75,6 +75,7 @@
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-infinite-scroll-component": "^6.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1", "react-redux": "^9.1.1",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
@@ -90,8 +91,7 @@
"winreg": "^1.2.5", "winreg": "^1.2.5",
"ws": "^8.18.1", "ws": "^8.18.1",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0", "yup": "^1.5.0"
"zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.705.0", "@aws-sdk/client-s3": "^3.705.0",
@@ -116,9 +116,9 @@
"@types/winreg": "^1.2.36", "@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"electron": "^33.4.11", "electron": "^37.7.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^3.0.0", "electron-vite": "^4.0.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
@@ -130,7 +130,7 @@
"sass-embedded": "^1.80.6", "sass-embedded": "^1.80.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "5.4.20", "vite": "5.4.21",
"vite-plugin-svgr": "^4.5.0" "vite-plugin-svgr": "^4.5.0"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -13,6 +13,7 @@
}, },
"sidebar": { "sidebar": {
"catalogue": "Catalogue", "catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads", "downloads": "Downloads",
"settings": "Settings", "settings": "Settings",
"my_library": "My library", "my_library": "My library",
@@ -94,6 +95,7 @@
"search": "Search games", "search": "Search games",
"home": "Home", "home": "Home",
"catalogue": "Catalogue", "catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Search results", "search_results": "Search results",
"settings": "Settings", "settings": "Settings",
@@ -223,6 +225,7 @@
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less", "show_less": "Show less",
"reviews": "Reviews", "reviews": "Reviews",
"review_played_for": "Played for",
"leave_a_review": "Leave a Review", "leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...", "write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest", "sort_newest": "Newest",
@@ -361,7 +364,10 @@
"show_original": "Show original", "show_original": "Show original",
"show_translation": "Show translation", "show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})", "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": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@@ -429,6 +435,9 @@
"validate_download_source": "Validate", "validate_download_source": "Validate",
"remove_download_source": "Remove", "remove_download_source": "Remove",
"add_download_source": "Add source", "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_zero": "No download options",
"download_count_one": "{{countFormatted}} download option", "download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options", "download_count_other": "{{countFormatted}} download options",
@@ -436,9 +445,16 @@
"add_download_source_description": "Insert the URL of the .json file", "add_download_source_description": "Insert the URL of the .json file",
"download_source_up_to_date": "Up-to-date", "download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored", "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", "sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed", "removed_download_source": "Download source removed",
"removed_download_sources": "Download sources 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", "cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Yes, delete everything", "confirm_button_confirmation_delete_all_sources": "Yes, delete everything",
"title_confirmation_delete_all_sources": "Delete all download sources", "title_confirmation_delete_all_sources": "Delete all download sources",
@@ -469,6 +485,7 @@
"seed_after_download_complete": "Seed after download complete", "seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them", "show_hidden_achievement_description": "Show hidden achievements description before unlocking them",
"account": "Account", "account": "Account",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "You have no blocked users", "no_users_blocked": "You have no blocked users",
"subscription_active_until": "Your Hydra Cloud is active until {{date}}", "subscription_active_until": "Your Hydra Cloud is active until {{date}}",
"manage_subscription": "Manage subscription", "manage_subscription": "Manage subscription",
@@ -544,7 +561,9 @@
"hidden": "Hidden", "hidden": "Hidden",
"test_notification": "Test notification", "test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview", "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": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@@ -680,7 +699,32 @@
"game_added_to_pinned": "Game added to pinned", "game_added_to_pinned": "Game added to pinned",
"karma": "Karma", "karma": "Karma",
"karma_count": "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": {
"achievement_unlocked": "Achievement unlocked", "achievement_unlocked": "Achievement unlocked",

View File

@@ -325,6 +325,7 @@
"maybe_later": "Tal vez después", "maybe_later": "Tal vez después",
"no_repacks_found": "Sin fuentes encontradas para este juego", "no_repacks_found": "Sin fuentes encontradas para este juego",
"no_reviews_yet": "Sin reseñas aún", "no_reviews_yet": "Sin reseñas aún",
"review_played_for": "Jugado por",
"properties": "Propiedades", "properties": "Propiedades",
"rating": "Calificación", "rating": "Calificación",
"rating_count": "Calificación", "rating_count": "Calificación",
@@ -361,7 +362,10 @@
"you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego", "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"language": "Idioma", "language": "Idioma",
"caption": "Subtítulo", "caption": "Subtítulo",
"audio": "Audio" "audio": "Audio",
"review_from_blocked_user": "Reseña de usuario bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
}, },
"activation": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",
@@ -541,7 +545,9 @@
"notification_preview": "Probar notificación de logro", "notification_preview": "Probar notificación de logro",
"debrid": "Debrid", "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.", "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": { "notifications": {
"download_complete": "Descarga completada", "download_complete": "Descarga completada",
@@ -676,7 +682,11 @@
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas", "karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:", "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": {
"achievement_unlocked": "Logro desbloqueado", "achievement_unlocked": "Logro desbloqueado",

View File

@@ -8,7 +8,7 @@
"no_results": "Nincs találat", "no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...", "start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott", "hot": "Most felkapott",
"weekly": "📅 A hét felkapott játékai", "weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott" "achievements": "🏆 Achievement támogatott"
}, },
"sidebar": { "sidebar": {
@@ -26,7 +26,7 @@
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
"need_help": "Elakadtál?", "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", "playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása", "add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása", "show_playable_only_tooltip": "Csak játszható játék mutatása",
@@ -224,7 +224,7 @@
"show_less": "Mutass kevesebbet", "show_less": "Mutass kevesebbet",
"reviews": "Vélemények", "reviews": "Vélemények",
"leave_a_review": "Hagyd itt a véleményed", "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", "sort_newest": "Legújabb",
"no_reviews_yet": "Még nem lett vélemény megosztva", "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!", "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", "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?", "would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?",
"yes": "Igen", "yes": "Igen",
"maybe_later": "Talán Később", "maybe_later": "Talán később",
"cloud_save": "Mentés felhőben", "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", "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", "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_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_description": "Ez a lépés nem vonható vissza.",
"delete_review_modal_delete_button": "Törlés", "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": { "activation": {
"title": "Hydra Aktiválása", "title": "Hydra Aktiválása",
"installation_id": "Telepítési Azonosító:", "installation_id": "Telepítési Azonosító:",
"enter_activation_code": "Írd be az aktiválási kódod", "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", "activate": "Aktiválás",
"loading": "Töltés…" "loading": "Töltés…"
}, },
@@ -386,7 +391,7 @@
"download_in_progress": "Folyamatban lévő", "download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések", "queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett", "downloads_completed": "Befejezett",
"queued": "Várakozási sorban", "queued": "Várakozásban",
"no_downloads_title": "Oly üres..", "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.", "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…", "checking_files": "Fájlok ellenőrzése…",
@@ -419,20 +424,30 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése", "save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve", "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", "validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás", "remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása", "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_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció", "download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{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", "add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész", "download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt", "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", "sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva", "removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források 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", "cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent", "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", "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_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás", "import": "Importálás",
"importing": "Importálás...",
"public": "Publikus", "public": "Publikus",
"private": "Privát", "private": "Privát",
"friends_only": "Csak barátok", "friends_only": "Csak barátok",
@@ -462,6 +478,7 @@
"seed_after_download_complete": "Letöltés utáni seedelés", "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", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók", "account": "Fiók",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nincsenek letiltott felhasználóid", "no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése", "manage_subscription": "Előfizetés kezelése",
@@ -498,14 +515,14 @@
"cancel": "Mégsem", "cancel": "Mégsem",
"appearance": "Megjelenés", "appearance": "Megjelenés",
"debrid": "Debrid", "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", "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_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", "torbox_account_linked": "TorBox fiók összekapcsolva",
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod", "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", "create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva", "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": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}", "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", "error_importing_theme": "Hiba lépett fel a téma importálása közben",
@@ -535,7 +552,9 @@
"hidden": "Rejtett", "hidden": "Rejtett",
"test_notification": "Értesítés tesztelése", "test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete", "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": { "notifications": {
"download_complete": "Letöltés befejezve", "download_complete": "Letöltés befejezve",
@@ -563,10 +582,10 @@
"available_one": "Elérhető", "available_one": "Elérhető",
"available_other": "Elérhető", "available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés", "no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás" "calculating": "Számítás alatt.."
}, },
"binary_not_found_modal": { "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", "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" "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", "activity": "Legutóbbi tevékenység",
"library": "Könyvtár", "library": "Könyvtár",
"pinned": "Kitűzve", "pinned": "Kitűzve",
"sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek", "achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva", "played_recently": "Nemrég játszva",
"playtime": "Játszottidő", "playtime": "Játszottidő",
@@ -654,7 +674,7 @@
"uploading_banner": "Borítókép feltöltése…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák", "stats": "Statisztikák",
"achievements": "achievementek", "achievements": "achievement",
"games": "Játékok", "games": "Játékok",
"top_percentile": "Top {{percentile}}%", "top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.", "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", "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma", "karma": "Karma",
"karma_count": "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": {
"achievement_unlocked": "Achievement feloldva", "achievement_unlocked": "Achievement feloldva",
@@ -678,7 +698,7 @@
"unlocked_at": "Feloldva: {{date}}", "unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges", "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", "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}}", "achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}",
"hidden_achievement_tooltip": "Ez egy rejtett achievement", "hidden_achievement_tooltip": "Ez egy rejtett achievement",
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el", "achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",

View File

@@ -317,6 +317,7 @@
"sort_lowest_score": "Menor Nota", "sort_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas", "sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações", "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!", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação", "rating": "Avaliação",
"rating_stats": "Avaliação", "rating_stats": "Avaliação",
@@ -349,7 +350,10 @@
"show_translation": "Mostrar tradução", "show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})", "show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original", "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": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -416,6 +420,9 @@
"validate_download_source": "Validar", "validate_download_source": "Validar",
"remove_download_source": "Remover", "remove_download_source": "Remover",
"add_download_source": "Adicionar fonte", "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_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista", "download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista", "download_count_other": "{{countFormatted}} downloads na lista",
@@ -423,7 +430,13 @@
"add_download_source_description": "Insira a URL contendo o arquivo .json", "add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada", "download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou", "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", "sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida", "removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas", "removed_download_sources": "Fontes removidas",
"cancel_button_confirmation_delete_all_sources": "Não", "cancel_button_confirmation_delete_all_sources": "Não",
@@ -529,7 +542,9 @@
"hidden": "Oculta", "hidden": "Oculta",
"test_notification": "Testar notificação", "test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas", "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": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@@ -682,7 +697,11 @@
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações", "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": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",

View File

@@ -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.", "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_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos 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": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -252,7 +256,13 @@
"add_download_source_description": "Insere o URL que contém o ficheiro .json", "add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada", "download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou", "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", "sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida", "removed_download_source": "Fonte removida",
"cancel_button_confirmation_delete_all_sources": "Não", "cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, apague tudo", "confirm_button_confirmation_delete_all_sources": "Sim, apague tudo",
@@ -460,7 +470,11 @@
"achievements_unlocked": "Conquistas desbloqueadas", "achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos", "earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Mostre as suas conquistas no perfil", "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": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",

View File

@@ -212,6 +212,7 @@
"stats": "Статистика", "stats": "Статистика",
"download_count": "Загрузки", "download_count": "Загрузки",
"player_count": "Активные игроки", "player_count": "Активные игроки",
"rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен", "download_error": "Этот вариант загрузки недоступен",
"download": "Скачать", "download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -226,6 +227,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые", "sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов", "no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые", "sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл", "sort_highest_score": "Высший балл",
@@ -252,17 +254,6 @@
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?", "would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да", "yes": "Да",
"maybe_later": "Возможно позже", "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": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве", "cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии", "backups": "Резервные копии",
@@ -360,7 +351,21 @@
"caption": "Субтитры", "caption": "Субтитры",
"audio": "Аудио", "audio": "Аудио",
"filter_by_source": "Фильтр по источнику", "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": { "activation": {
"title": "Активировать Hydra", "title": "Активировать Hydra",
@@ -427,6 +432,9 @@
"validate_download_source": "Проверить", "validate_download_source": "Проверить",
"remove_download_source": "Удалить", "remove_download_source": "Удалить",
"add_download_source": "Добавить источник", "add_download_source": "Добавить источник",
"adding": "Добавление…",
"failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.",
"download_source_already_exists": "Этот URL источника уже существует.",
"download_count_zero": "В списке нет загрузок", "download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке", "download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке", "download_count_other": "{{countFormatted}} загрузок в списке",
@@ -434,9 +442,16 @@
"add_download_source_description": "Вставьте ссылку на .json-файл", "add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён", "download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка", "download_source_errored": "Ошибка",
"download_source_pending_matching": "Скоро обновится",
"download_source_matched": "Обновлен",
"download_source_matching": "Обновление",
"download_source_failed": "Ошибка",
"download_source_no_information": "Информация отсутствует",
"sync_download_sources": "Обновить источники", "sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален", "removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены", "removed_download_sources": "Источники удалены",
"removed_all_download_sources": "Все источники удалены",
"download_sources_synced_successfully": "Все источники синхронизированы",
"cancel_button_confirmation_delete_all_sources": "Нет", "cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все", "confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"title_confirmation_delete_all_sources": "Удалить все источники", "title_confirmation_delete_all_sources": "Удалить все источники",
@@ -467,6 +482,7 @@
"seed_after_download_complete": "Раздавать после завершения загрузки", "seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт", "account": "Аккаунт",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "У вас нет заблокированных пользователей", "no_users_blocked": "У вас нет заблокированных пользователей",
"subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}",
"manage_subscription": "Управлять подпиской", "manage_subscription": "Управлять подпиской",
@@ -540,7 +556,9 @@
"hidden": "Скрытый", "hidden": "Скрытый",
"test_notification": "Тестовое уведомление", "test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении", "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": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",
@@ -590,6 +608,7 @@
"activity": "Недавняя активность", "activity": "Недавняя активность",
"library": "Библиотека", "library": "Библиотека",
"pinned": "Закрепленные", "pinned": "Закрепленные",
"sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения", "achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные", "played_recently": "Недавно сыгранные",
"playtime": "Время игры", "playtime": "Время игры",
@@ -674,7 +693,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные", "game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма", "karma": "Карма",
"karma_count": "карма", "karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов" "karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Достижение разблокировано", "achievement_unlocked": "Достижение разблокировано",

View File

@@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => { export const getGameAssets = async (objectId: string, shop: GameShop) => {
if (shop === "custom") {
return null;
}
const cachedAssets = await gamesShopAssetsSublevel.get( const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId) levelKeys.game(shop, objectId)
); );

View File

@@ -26,6 +26,8 @@ const getGameShopDetails = async (
shop: GameShop, shop: GameShop,
language: string language: string
): Promise<ShopDetailsWithAssets | null> => { ): Promise<ShopDetailsWithAssets | null> => {
if (shop === "custom") return null;
if (shop === "steam") { if (shop === "steam") {
const [cachedData, cachedAssets] = await Promise.all([ const [cachedData, cachedAssets] = await Promise.all([
gamesShopCacheSublevel.get( gamesShopCacheSublevel.get(

View File

@@ -10,6 +10,10 @@ const getGameStats = async (
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => { ) => {
if (shop === "custom") {
return null;
}
const cachedStats = await gamesStatsCacheSublevel.get( const cachedStats = await gamesStatsCacheSublevel.get(
levelKeys.game(shop, objectId) levelKeys.game(shop, objectId)
); );

View File

@@ -1,76 +1,50 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; import { HydraApi } from "@main/services/hydra-api";
import { HydraApi, logger } from "@main/services"; import { downloadSourcesSublevel } from "@main/level";
import { importDownloadSourceToLocal } from "./helpers"; import type { DownloadSource } from "@types";
import { logger } from "@main/services";
const addDownloadSource = async ( const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
url: string url: string
) => { ) => {
const result = await importDownloadSourceToLocal(url, true); try {
if (!result) { const existingSources = await downloadSourcesSublevel.values().all();
throw new Error("Failed to import download source"); const urlExists = existingSources.some((source) => source.url === url);
}
// Verify that repacks were actually written to the database (read-after-write) if (urlExists) {
// This ensures all async operations are complete before proceeding throw new Error("Download source with this URL already exists");
let repackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
repackCount++;
} }
}
await HydraApi.post("/profile/download-sources", { const downloadSource = await HydraApi.post<DownloadSource>(
urls: [url], "/download-sources",
}); {
url,
},
{ needsAuth: false }
);
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
"/download-sources", try {
{ await HydraApi.post("/profile/download-sources", {
objectIds: result.objectIds, urls: [url],
}, });
{ needsAuth: false } } catch (error) {
); logger.error("Failed to add download source to profile:", error);
}
}
// Update the source with fingerprint await downloadSourcesSublevel.put(downloadSource.id, {
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`); ...downloadSource,
if (updatedSource) { isRemote: true,
await downloadSourcesSublevel.put(`${result.id}`, { createdAt: new Date().toISOString(),
...updatedSource,
fingerprint,
updatedAt: new Date(),
}); });
}
// Final verification: ensure the source with fingerprint is persisted return downloadSource;
const finalSource = await downloadSourcesSublevel.get(`${result.id}`); } catch (error) {
if (!finalSource || !finalSource.fingerprint) { logger.error("Failed to add download source:", error);
throw new Error("Failed to persist download source with fingerprint"); 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); registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -1,17 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
const checkDownloadSourceExists = async (
_event: Electron.IpcMainInvokeEvent,
url: string
): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -1,8 +1,10 @@
import { HydraApi } from "@main/services"; import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { 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); registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -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<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"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<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
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<string, DownloadSource>
> => {
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<boolean> => {
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<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"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<number> => {
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<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
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<z.infer<typeof downloadSourceSchema>>(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<z.infer<typeof downloadSourceSchema>>(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;
};

View File

@@ -1,18 +1,27 @@
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const removeDownloadSource = async ( const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
url?: string, removeAll = false,
removeAll = false downloadSourceId?: string
) => { ) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
all: removeAll.toString(), 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); registerEvent("removeDownloadSource", removeDownloadSource);

View File

@@ -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);
}
};

View File

@@ -1,114 +1,28 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios"; import { downloadSourcesSublevel } from "@main/level";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; import type { DownloadSource } from "@types";
import { DownloadSourceStatus } from "@shared";
import {
invalidateIdCaches,
downloadSourceSchema,
getSteamGames,
addNewDownloads,
} from "./helpers";
const syncDownloadSources = async ( const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
_event: Electron.IpcMainInvokeEvent const downloadSources = await downloadSourcesSublevel.values().all();
): Promise<number> => {
let newRepacksCount = 0;
try { const response = await HydraApi.post<DownloadSource[]>(
const downloadSources: Array<{ "/download-sources/sync",
id: number; {
url: string; ids: downloadSources.map((downloadSource) => downloadSource.id),
name: string; },
etag: string | null; { needsAuth: false }
status: number; );
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
downloadSources.push(source);
}
const existingRepacks: Array<{ for (const downloadSource of response) {
id: number; const existingDownloadSource = downloadSources.find(
title: string; (source) => source.id === downloadSource.id
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 sources without fingerprints, just continue with normal sync await downloadSourcesSublevel.put(downloadSource.id, {
// They will get fingerprints updated later by updateMissingFingerprints ...existingDownloadSource,
const allSourcesToSync = [ ...downloadSource,
...sourcesWithFingerprints, });
...sourcesWithoutFingerprints,
];
for (const downloadSource of allSourcesToSync) {
const headers: Record<string, string> = {};
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;
} }
}; };

View File

@@ -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<number> => {
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);

View File

@@ -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<z.infer<typeof downloadSourceSchema>>(url);
const { name } = downloadSourceSchema.parse(response.data);
return {
name,
etag: response.headers["etag"] || null,
downloadCount: response.data.downloads.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -23,6 +23,7 @@ import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
import "./library/get-game-by-object-id"; import "./library/get-game-by-object-id";
import "./library/get-library"; import "./library/get-library";
import "./library/refresh-library-assets";
import "./library/extract-game-download"; import "./library/extract-game-download";
import "./library/open-game"; import "./library/open-game";
import "./library/open-game-executable-path"; 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-real-debrid";
import "./user-preferences/authenticate-torbox"; import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source"; 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/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/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";

View File

@@ -37,6 +37,7 @@ const addCustomGameToLibrary = async (
logoImageUrl: logoImageUrl || "", logoImageUrl: logoImageUrl || "",
logoPosition: null, logoPosition: null,
coverImageUrl: iconUrl || "", coverImageUrl: iconUrl || "",
downloadSources: [],
}; };
await gamesShopAssetsSublevel.put(gameKey, assets); await gamesShopAssetsSublevel.put(gameKey, assets);

View File

@@ -13,7 +13,9 @@ const addGameToFavorites = async (
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
if (!game) return; if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {}); if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
}
try { try {
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {

View File

@@ -4,6 +4,7 @@ import {
downloadsSublevel, downloadsSublevel,
gamesShopAssetsSublevel, gamesShopAssetsSublevel,
gamesSublevel, gamesSublevel,
gameAchievementsSublevel,
} from "@main/level"; } from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => { const getLibrary = async (): Promise<LibraryGame[]> => {
@@ -18,14 +19,32 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key); const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.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 { return {
id: key, id: key,
...game, ...game,
download: download ?? null, download: download ?? null,
unlockedAchievementCount,
achievementCount,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets, ...gameAssets,
// Ensure compatibility with LibraryGame type // Preserve custom image URLs from game if they exist
libraryHeroImageUrl: customIconUrl: game.customIconUrl,
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
} as LibraryGame; } as LibraryGame;
}) })
); );

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { mergeWithRemoteGames } from "@main/services";
const refreshLibraryAssets = async () => {
await mergeWithRemoteGames();
};
registerEvent("refreshLibraryAssets", refreshLibraryAssets);

View File

@@ -13,7 +13,11 @@ const removeGameFromFavorites = async (
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
if (!game) return; if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {}); if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(
() => {}
);
}
try { try {
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {

View File

@@ -84,7 +84,7 @@ const removeGameFromLibrary = async (
await resetShopAssets(gameKey); await resetShopAssets(gameKey);
} }
if (game?.remoteId) { if (game.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} }

View File

@@ -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);

View File

@@ -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<DownloadSource>(
"/download-sources",
{
url: value.url,
},
{ needsAuth: false }
);
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
await downloadSourcesSublevel.del(key);
}
}
};

View File

@@ -1,18 +1,6 @@
import { db } from "../level"; import { db } from "../level";
import { levelKeys } from "./keys"; import { levelKeys } from "./keys";
import type { DownloadSource } from "@types";
export interface DownloadSource {
id: number;
name: string;
url: string;
status: number;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>( export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources, levelKeys.downloadSources,

View File

@@ -7,4 +7,3 @@ export * from "./game-achievements";
export * from "./keys"; export * from "./keys";
export * from "./themes"; export * from "./themes";
export * from "./download-sources"; export * from "./download-sources";
export * from "./repacks";

View File

@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState", screenState: "screenState",
rpcPassword: "rpcPassword", rpcPassword: "rpcPassword",
downloadSources: "downloadSources", downloadSources: "downloadSources",
repacks: "repacks",
}; };

View File

@@ -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<string, GameRepack>(
levelKeys.repacks,
{
valueEncoding: "json",
}
);

View File

@@ -16,15 +16,13 @@ import {
Ludusavi, Ludusavi,
Lock, Lock,
DeckyPlugin, DeckyPlugin,
ResourceCache, WSClient,
} from "@main/services"; } from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
export const loadState = async () => { export const loadState = async () => {
await Lock.acquireLock(); await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
@@ -53,9 +51,13 @@ export const loadState = async () => {
DeckyPlugin.checkAndUpdateIfOutdated(); DeckyPlugin.checkAndUpdateIfOutdated();
} }
await HydraApi.setupApi().then(() => { await HydraApi.setupApi().then(async () => {
uploadGamesBatch(); uploadGamesBatch();
// WSClient.connect(); void migrateDownloadSources();
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
WSClient.connect();
}); });
const downloads = await downloadsSublevel const downloads = await downloadsSublevel

View File

@@ -167,6 +167,8 @@ export class AchievementWatcherManager {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) { ) {
if (shop === "custom") return;
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return; if (this.alreadySyncedGames.get(gameKey)) return;

View File

@@ -27,6 +27,10 @@ export const getGameAchievementData = async (
shop: GameShop, shop: GameShop,
useCachedData: boolean useCachedData: boolean
) => { ) => {
if (shop === "custom") {
return [];
}
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
const cachedAchievements = await gameAchievementsSublevel.get(gameKey); const cachedAchievements = await gameAchievementsSublevel.get(gameKey);

View File

@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support"; import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie"; import { CookieJar } from "tough-cookie";
import { logger } from "@main/services";
export class DatanodesApi { export class DatanodesApi {
private static readonly jar = new CookieJar(); private static readonly jar = new CookieJar();
@@ -20,51 +21,42 @@ export class DatanodesApi {
await this.jar.setCookie("lang=english;", "https://datanodes.to"); await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({ const formData = new FormData();
op: "download2", formData.append("op", "download2");
id: fileCode, formData.append("id", fileCode);
method_free: "Free Download >>", formData.append("rand", "");
dl: "1", 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( const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download", "https://datanodes.to/download",
payload, formData,
{ {
headers: { headers: {
"User-Agent": accept: "*/*",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", "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", 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) { if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(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"); throw new Error("Failed to get the download link");
} catch (error) { } catch (error) {
console.error("Error fetching download URL:", error); logger.error("Error fetching download URL:", error);
throw error; throw error;
} }
} }

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level"; import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels"; import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types"; import type { Auth, User } from "@types";
import { WSClient } from "./ws";
export interface HydraApiOptions { export interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@@ -29,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance; private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes 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) { private static secondsToMilliseconds(seconds: number) {
return seconds * 1000; return seconds * 1000;
@@ -46,7 +47,7 @@ export class HydraApi {
return this.userAuth.authToken !== ""; return this.userAuth.authToken !== "";
} }
private static hasActiveSubscription() { public static hasActiveSubscription() {
const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0); const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0);
return expiresAt > new Date(); return expiresAt > new Date();
} }
@@ -103,12 +104,10 @@ export class HydraApi {
await clearGamesRemoteIds(); await clearGamesRemoteIds();
uploadGamesBatch(); uploadGamesBatch();
// WSClient.close(); WSClient.close();
// WSClient.connect(); WSClient.connect();
const { syncDownloadSourcesFromApi } = await import( const { syncDownloadSourcesFromApi } = await import("./user");
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi(); syncDownloadSourcesFromApi();
} }
} }

View File

@@ -19,4 +19,4 @@ export * from "./library-sync";
export * from "./wine"; export * from "./wine";
export * from "./lock"; export * from "./lock";
export * from "./decky-plugin"; export * from "./decky-plugin";
export * from "./resource-cache"; export * from "./user";

View File

@@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level"; import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => { export const createGame = async (game: Game) => {
if (game.shop === "custom") {
return;
}
return HydraApi.post(`/profile/games`, { return HydraApi.post(`/profile/games`, {
objectId: game.objectId, objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),

View File

@@ -60,18 +60,26 @@ export const mergeWithRemoteGames = async () => {
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); 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, { await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(), updatedAt: Date.now(),
...localGameShopAsset, ...localGameShopAsset,
shop: game.shop, shop: game.shop,
objectId: game.objectId, objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl, coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl, libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl, logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl, iconUrl: game.iconUrl,
logoPosition: game.logoPosition, logoPosition: game.logoPosition,
downloadSources: game.downloadSources,
}); });
} }
}) })

View File

@@ -1,12 +1,16 @@
import type { Game } from "@types"; import type { Game } from "@types";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async ( export const trackGamePlaytime = async (
game: Game, game: Game,
deltaInMillis: number, deltaInMillis: number,
lastTimePlayed: Date 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), playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed, lastTimePlayed,
}); });

View File

@@ -1,10 +1,10 @@
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync"; import { createGame, trackGamePlaytime } from "./library-sync";
import type { Game, GameRunning } from "@types"; import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc"; import { PythonRPC } from "./python-rpc";
import axios from "axios"; import axios from "axios";
import { ProcessPayload } from "./download/types"; import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level"; import { db, gamesSublevel, levelKeys } from "@main/level";
import { CloudSync } from "./cloud-sync"; import { CloudSync } from "./cloud-sync";
import { logger } from "./logger"; import { logger } from "./logger";
import path from "path"; import path from "path";
@@ -198,19 +198,32 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) { function onOpenGame(game: Game) {
const now = performance.now(); const now = performance.now();
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now, lastTick: now,
firstTick: now, firstTick: now,
lastSyncTick: now, lastSyncTick: now,
}); });
// Hide Hydra to tray on game startup if enabled
db.get<string, UserPreferences | null>(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) { if (game.remoteId) {
updateGamePlaytime( trackGamePlaytime(
game, game,
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0, game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
new Date() new Date()
@@ -244,43 +257,46 @@ function onTickGame(game: Game) {
const delta = now - gamePlaytime.lastTick; const delta = now - gamePlaytime.lastTick;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { const updatedGame: Game = {
...game, ...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta, playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(), lastTimePlayed: new Date(),
}); };
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime, ...gamePlaytime,
lastTick: now, lastTick: now,
}); });
if (currentTick % TICKS_TO_UPDATE_API === 0) { if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") {
const deltaToSync = const deltaToSync =
now - now -
gamePlaytime.lastSyncTick + gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
const gamePromise = game.remoteId const gamePromise = game.remoteId
? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) ? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
: createGame(game); : createGame(game);
gamePromise gamePromise
.then(() => { .then(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game, ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0, unsyncedDeltaPlayTimeInMilliseconds: 0,
}); });
}) })
.catch(() => { .catch(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game, ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
}); });
}) })
.finally(() => { .finally(() => {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime, ...gamePlaytime,
lastTick: now,
lastSyncTick: now, lastSyncTick: now,
}); });
}); });
@@ -288,11 +304,24 @@ function onTickGame(game: Game) {
} }
const onCloseGame = (game: Game) => { const onCloseGame = (game: Game) => {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get( const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId) levelKeys.game(game.shop, game.objectId)
)!; )!;
gamesPlaytime.delete(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.remoteId) {
if (game.automaticCloudSync) { if (game.automaticCloudSync) {
CloudSync.uploadSaveGame( CloudSync.uploadSaveGame(
@@ -304,20 +333,20 @@ const onCloseGame = (game: Game) => {
} }
const deltaToSync = const deltaToSync =
performance.now() - now -
gamePlaytime.lastSyncTick + gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
.then(() => { .then(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game, ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0, unsyncedDeltaPlayTimeInMilliseconds: 0,
}); });
}) })
.catch(() => { .catch(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game, ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
}); });
}); });

View File

@@ -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<T = unknown> {
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<T = unknown>(
resourceName: string
): CachedResource<T> | 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<T = unknown>(
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<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(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<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
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");
}
}

View File

@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
if (!steamGameUrl) return null; if (!steamGameUrl) return null;
return { return {
title: $title.textContent, title: $title.getAttribute("data-title") || "",
objectId: steamGameUrl.split("/").pop(), objectId: steamGameUrl.split("/").pop(),
} as Steam250Game; } as Steam250Game;
}) })

View File

@@ -0,0 +1,2 @@
export * from "./get-user-data";
export * from "./sync-download-sources";

View File

@@ -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<DownloadSource[]>(
"/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);
}
};

View File

@@ -25,6 +25,7 @@ import type {
} from "@types"; } from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants"; import { isStaging } from "@main/constants";
import { logger } from "./logger";
export class WindowManager { export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null; public static mainWindow: Electron.BrowserWindow | null = null;
@@ -54,21 +55,25 @@ export class WindowManager {
show: false, show: false,
}; };
private static formatVersionNumber(version: string) {
return version.replaceAll(".", "-");
}
private static async loadWindowURL(window: BrowserWindow, hash: string = "") { private static async loadWindowURL(window: BrowserWindow, hash: string = "") {
// HMR for renderer base on electron-vite cli. // HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production. // Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`); 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 to load from remote URL in production
try { try {
await window.loadURL( 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) { } catch (error) {
// Fall back to local file if remote URL fails // Fall back to local file if remote URL fails
console.error( logger.error(
"Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:", "Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:",
error error
); );
window.loadFile(path.join(__dirname, "../renderer/index.html"), { 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_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140; private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
@@ -297,46 +296,58 @@ export class WindowManager {
position: AchievementCustomNotificationPosition | undefined position: AchievementCustomNotificationPosition | undefined
) { ) {
const display = screen.getPrimaryDisplay(); const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize; const {
x: displayX,
y: displayY,
width: displayWidth,
height: displayHeight,
} = display.bounds;
if (position === "bottom-left") { if (position === "bottom-left") {
return { return {
x: 0, x: displayX,
y: height - this.NOTIFICATION_WINDOW_HEIGHT, y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
}; };
} }
if (position === "bottom-center") { if (position === "bottom-center") {
return { return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: height - this.NOTIFICATION_WINDOW_HEIGHT, y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
}; };
} }
if (position === "bottom-right") { if (position === "bottom-right") {
return { return {
x: width - this.NOTIFICATION_WINDOW_WIDTH, x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: height - this.NOTIFICATION_WINDOW_HEIGHT, y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-left") {
return {
x: displayX,
y: displayY,
}; };
} }
if (position === "top-center") { if (position === "top-center") {
return { return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: 0, y: displayY,
}; };
} }
if (position === "top-right") { if (position === "top-right") {
return { return {
x: width - this.NOTIFICATION_WINDOW_WIDTH, x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: 0, y: displayY,
}; };
} }
return { return {
x: 0, x: displayX,
y: 0, y: displayY,
}; };
} }
@@ -382,7 +393,7 @@ export class WindowManager {
this.notificationWindow.setIgnoreMouseEvents(true); this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL(); this.loadWindowURL(this.notificationWindow, "achievement-notification");
if (!app.isPackaged || isStaging) { if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools(); this.notificationWindow.webContents.openDevTools();
@@ -462,6 +473,7 @@ export class WindowManager {
editorWindow.once("ready-to-show", () => { editorWindow.once("ready-to-show", () => {
editorWindow.show(); editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) { if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools(); editorWindow.webContents.openDevTools();
} }
@@ -469,11 +481,12 @@ export class WindowManager {
editorWindow.webContents.on("before-input-event", (_event, input) => { editorWindow.webContents.on("before-input-event", (_event, input) => {
if (input.key === "F12") { if (input.key === "F12") {
editorWindow.webContents.toggleDevTools(); this.mainWindow?.webContents.toggleDevTools();
} }
}); });
editorWindow.on("close", () => { editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId); this.editorWindows.delete(themeId);
}); });
} }

View File

@@ -7,7 +7,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_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; readonly ELECTRON_RENDERER_URL: string;
} }

View File

@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */ /* Download sources */
addDownloadSource: (url: string) => addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url), ipcRenderer.invoke("addDownloadSource", url),
updateMissingFingerprints: () =>
ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) => removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll), ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), 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"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
checkDownloadSourceExists: (url: string) =>
ipcRenderer.invoke("checkDownloadSourceExists", url),
getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */ /* Library */
toggleAutomaticCloudSync: ( toggleAutomaticCloudSync: (
@@ -208,6 +196,7 @@ contextBridge.exposeInMainWorld("electron", {
verifyExecutablePathInUse: (executablePath: string) => verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"), getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>

View File

@@ -5,7 +5,7 @@
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 9px; width: 4px;
background-color: globals.$dark-background-color; background-color: globals.$dark-background-color;
} }

View File

@@ -7,7 +7,6 @@ import {
useAppSelector, useAppSelector,
useDownload, useDownload,
useLibrary, useLibrary,
useRepacks,
useToast, useToast,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails, setUserDetails,
setProfileBackground, setProfileBackground,
setGameRunning, setGameRunning,
setIsImportingSources,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const { const {
@@ -199,36 +195,6 @@ export function App() {
}); });
}, [dispatch, draggingDisabled]); }, [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 loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme(); const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) { if (activeTheme?.code) {
@@ -313,7 +279,11 @@ export function App() {
<article className="container"> <article className="container">
<Header /> <Header />
<section ref={contentRef} className="container__content"> <section
ref={contentRef}
id="scrollableDiv"
className="container__content"
>
<Outlet /> <Outlet />
</section> </section>
</article> </article>

View File

@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; 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"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating"; import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react"; import { useCallback, useState } from "react";
import { useFormat, useRepacks } from "@renderer/hooks"; import { useFormat } from "@renderer/hooks";
export interface GameCardProps export interface GameCardProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
> { > {
game: any; game: ShopAssets;
} }
const shopIcon = { const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
const handleHover = useCallback(() => { const handleHover = useCallback(() => {
if (!stats) { if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => { window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const firstThreeRepackers = useMemo(
() => uniqueRepackers.slice(0, 3),
[uniqueRepackers]
);
const remainingCount = useMemo(
() => uniqueRepackers.length - 3,
[uniqueRepackers]
);
return ( return (
<button <button
{...props} {...props}
@@ -75,18 +59,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
<p className="game-card__title">{game.title}</p> <p className="game-card__title">{game.title}</p>
</div> </div>
{uniqueRepackers.length > 0 ? ( {game.downloadSources.length > 0 ? (
<ul className="game-card__download-options"> <ul className="game-card__download-options">
{firstThreeRepackers.map((repacker) => ( {game.downloadSources.slice(0, 3).map((sourceName) => (
<li key={repacker}> <li key={sourceName}>
<Badge>{repacker}</Badge> <Badge>{sourceName}</Badge>
</li> </li>
))} ))}
{remainingCount > 0 && ( {game.downloadSources.length > 3 && (
<li> <li>
<Badge> <Badge>
+{remainingCount}{" "} +{game.downloadSources.length - 3}{" "}
{t("game_card:available", { count: remainingCount })} {t("game_card:available", {
count: game.downloadSources.length - 3,
})}
</Badge> </Badge>
</li> </li>
)} )}

View File

@@ -70,8 +70,10 @@ export function GameContextMenu({
onClick: () => { onClick: () => {
if (isGameRunning) { if (isGameRunning) {
void handleCloseGame(); void handleCloseGame();
} else { } else if (canPlay) {
void handlePlayGame(); void handlePlayGame();
} else {
handleOpenDownloadOptions();
} }
}, },
disabled: isDeleting, disabled: isDeleting,

View File

@@ -24,7 +24,7 @@
background-color: globals.$background-color; background-color: globals.$background-color;
display: inline-flex; display: inline-flex;
transition: all ease 0.2s; transition: all ease 0.2s;
width: 200px; width: 300px;
align-items: center; align-items: center;
border-radius: 8px; border-radius: 8px;
border: solid 1px globals.$border-color; border: solid 1px globals.$border-color;
@@ -35,7 +35,7 @@
} }
&--focused { &--focused {
width: 250px; width: 350px;
border-color: #dadbe1; border-color: #dadbe1;
} }
} }

View File

@@ -13,6 +13,7 @@ import cn from "classnames";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
"/catalogue": "catalogue", "/catalogue": "catalogue",
"/library": "library",
"/downloads": "downloads", "/downloads": "downloads",
"/settings": "settings", "/settings": "settings",
}; };
@@ -41,6 +42,8 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle; if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results"); if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]); return t(pathTitle[location.pathname]);
@@ -60,7 +63,7 @@ export function Header() {
}; };
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
dispatch(setFilters({ title: value })); dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) { if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue"); navigate("/catalogue");

View File

@@ -3,6 +3,7 @@ import {
DownloadIcon, DownloadIcon,
GearIcon, GearIcon,
HomeIcon, HomeIcon,
BookIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
export const routes = [ export const routes = [
@@ -16,6 +17,11 @@ export const routes = [
nameKey: "catalogue", nameKey: "catalogue",
render: () => <AppsIcon />, render: () => <AppsIcon />,
}, },
{
path: "/library",
nameKey: "library",
render: () => <BookIcon />,
},
{ {
path: "/downloads", path: "/downloads",
nameKey: "downloads", nameKey: "downloads",

View File

@@ -1,9 +1,7 @@
import React, { useId, useMemo, useState } from "react"; import React, { useId, useState } from "react";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cn from "classnames"; import cn from "classnames";
import "./text-field.scss"; import "./text-field.scss";
export interface TextFieldProps export interface TextFieldProps
@@ -42,44 +40,30 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
) => { ) => {
const id = useId(); const id = useId();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const { t } = useTranslation("forms"); const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password"; const showPasswordToggleButton = props.type === "password";
const inputType =
const inputType = useMemo(() => { props.type === "password" && isPasswordVisible
if (props.type === "password" && isPasswordVisible) return "text"; ? "text"
return props.type ?? "text"; : (props.type ?? "text");
}, [props.type, isPasswordVisible]); const hintContent = error ? (
<small className="text-field-container__error-label">{error}</small>
const hintContent = useMemo(() => { ) : hint ? (
if (error) <small>{hint}</small>
return ( ) : null;
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => { const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true); setIsFocused(true);
if (props.onFocus) props.onFocus(event); props.onFocus?.(event);
}; };
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => { const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false); setIsFocused(false);
if (props.onBlur) props.onBlur(event); props.onBlur?.(event);
}; };
const hasError = !!error; const hasError = !!error;
return ( return (
<div className="text-field-container" {...containerProps}> <div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>} {label && <label htmlFor={id}>{label}</label>}
<div className="text-field-container__text-field-wrapper"> <div className="text-field-container__text-field-wrapper">
<div <div
className={cn( className={cn(
@@ -104,7 +88,6 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
onBlur={handleBlur} onBlur={handleBlur}
type={inputType} type={inputType}
/> />
{showPasswordToggleButton && ( {showPasswordToggleButton && (
<button <button
type="button" type="button"
@@ -120,14 +103,11 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
</button> </button>
)} )}
</div> </div>
{rightContent} {rightContent}
</div> </div>
{hintContent} {hintContent}
</div> </div>
); );
} }
); );
TextField.displayName = "TextField"; TextField.displayName = "TextField";

View File

@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
); );
const getGameArtifacts = useCallback(async () => { const getGameArtifacts = useCallback(async () => {
if (shop === "custom") {
setArtifacts([]);
return;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
objectId, objectId,
shop, shop,

View File

@@ -1,11 +1,4 @@
import { import { createContext, useCallback, useEffect, useRef, useState } from "react";
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers"; import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch, useAppDispatch,
useAppSelector, useAppSelector,
useDownload, useDownload,
useRepacks,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import type { import type {
GameRepack,
GameShop, GameShop,
GameStats, GameStats,
LibraryGame, LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false); const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { getRepacksForObjectId } = useRepacks();
const repacks = useMemo(() => {
return getRepacksForObjectId(objectId);
}, [getRepacksForObjectId, objectId]);
const { i18n } = useTranslation("game_details"); const { i18n } = useTranslation("game_details");
const location = useLocation(); const location = useLocation();
@@ -142,10 +130,12 @@ export function GameDetailsContextProvider({
} }
}); });
window.electron.getGameStats(objectId, shop).then((result) => { if (shop !== "custom") {
if (abortController.signal.aborted) return; window.electron.getGameStats(objectId, shop).then((result) => {
setStats(result); if (abortController.signal.aborted) return;
}); setStats(result);
});
}
const assetsPromise = window.electron.getGameAssets(objectId, shop); const assetsPromise = window.electron.getGameAssets(objectId, shop);
@@ -167,7 +157,7 @@ export function GameDetailsContextProvider({
setIsLoading(false); setIsLoading(false);
}); });
if (userDetails) { if (userDetails && shop !== "custom") {
window.electron window.electron
.getUnlockedAchievements(objectId, shop) .getUnlockedAchievements(objectId, shop)
.then((achievements) => { .then((achievements) => {
@@ -287,19 +277,6 @@ export function GameDetailsContextProvider({
} }
}, [location]); }, [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(() => { useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements( const unsubscribe = window.electron.onUpdateAchievements(
objectId, objectId,
@@ -315,6 +292,36 @@ export function GameDetailsContextProvider({
}; };
}, [objectId, shop, userDetails]); }, [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<GameRepack[]>(
`/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 () => { const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath(); return window.electron.getDefaultDownloadsPath();
@@ -359,7 +366,7 @@ export function GameDetailsContextProvider({
stats, stats,
achievements, achievements,
hasNSFWContentBlocked, hasNSFWContentBlocked,
lastDownloadedOption, lastDownloadedOption: null,
setHasNSFWContentBlocked, setHasNSFWContentBlocked,
selectGameExecutable, selectGameExecutable,
updateGame, updateGame,

View File

@@ -20,12 +20,15 @@ export interface UserProfileContext {
isMe: boolean; isMe: boolean;
userStats: UserStats | null; userStats: UserStats | null;
getUserProfile: () => Promise<void>; getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>; getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise<void>;
loadMoreLibraryGames: (sortBy?: string) => Promise<boolean>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>; setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string; backgroundImage: string;
badges: Badge[]; badges: Badge[];
libraryGames: UserGame[]; libraryGames: UserGame[];
pinnedGames: UserGame[]; pinnedGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
} }
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -36,12 +39,15 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false, isMe: false,
userStats: null, userStats: null,
getUserProfile: async () => {}, getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {}, getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {},
loadMoreLibraryGames: async (_sortBy?: string) => false,
setSelectedBackgroundImage: () => {}, setSelectedBackgroundImage: () => {},
backgroundImage: "", backgroundImage: "",
badges: [], badges: [],
libraryGames: [], libraryGames: [],
pinnedGames: [], pinnedGames: [],
hasMoreLibraryGames: false,
isLoadingLibraryGames: false,
}); });
const { Provider } = userProfileContext; const { Provider } = userProfileContext;
@@ -68,6 +74,9 @@ export function UserProfileContextProvider({
DEFAULT_USER_PROFILE_BACKGROUND DEFAULT_USER_PROFILE_BACKGROUND
); );
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); 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; const isMe = userDetails?.id === userProfile?.id;
@@ -99,7 +108,13 @@ export function UserProfileContextProvider({
}, [userId]); }, [userId]);
const getUserLibraryGames = useCallback( const getUserLibraryGames = useCallback(
async (sortBy?: string) => { async (sortBy?: string, reset = true) => {
if (reset) {
setLibraryPage(0);
setHasMoreLibraryGames(true);
setIsLoadingLibraryGames(true);
}
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("take", "12"); params.append("take", "12");
@@ -121,18 +136,74 @@ export function UserProfileContextProvider({
if (response) { if (response) {
setLibraryGames(response.library); setLibraryGames(response.library);
setPinnedGames(response.pinnedGames); setPinnedGames(response.pinnedGames);
setHasMoreLibraryGames(response.library.length === 12);
} else { } else {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} }
} catch (error) { } catch (error) {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} finally {
setIsLoadingLibraryGames(false);
} }
}, },
[userId] [userId]
); );
const loadMoreLibraryGames = useCallback(
async (sortBy?: string): Promise<boolean> => {
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 () => { const getUserProfile = useCallback(async () => {
getUserStats(); getUserStats();
getUserLibraryGames(); getUserLibraryGames();
@@ -204,6 +275,8 @@ export function UserProfileContextProvider({
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
setLibraryPage(0);
setHasMoreLibraryGames(true);
getUserProfile(); getUserProfile();
getBadges(); getBadges();
@@ -217,12 +290,15 @@ export function UserProfileContextProvider({
isMe, isMe,
getUserProfile, getUserProfile,
getUserLibraryGames, getUserLibraryGames,
loadMoreLibraryGames,
setSelectedBackgroundImage, setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(), backgroundImage: getBackgroundImageUrl(),
userStats, userStats,
badges, badges,
libraryGames, libraryGames,
pinnedGames, pinnedGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
}} }}
> >
{children} {children}

View File

@@ -31,8 +31,6 @@ import type {
Game, Game,
DiskUsage, DiskUsage,
DownloadSource, DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -161,6 +159,7 @@ declare global {
) => Promise<void>; ) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>; verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -210,20 +209,12 @@ declare global {
/* Download sources */ /* Download sources */
addDownloadSource: (url: string) => Promise<DownloadSource>; addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>; removeDownloadSource: (
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>; removeAll = false,
getDownloadSources: () => Promise< downloadSourceId?: string
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[] ) => Promise<void>;
>; getDownloadSources: () => Promise<DownloadSource[]>;
deleteDownloadSource: (id: number) => Promise<void>; syncDownloadSources: () => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>; getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -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;

View File

@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice"; export * from "./user-details-slice";
export * from "./game-running.slice"; export * from "./game-running.slice";
export * from "./subscription-slice"; export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search"; export * from "./catalogue-search";

View File

@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux"; export * from "./redux";
export * from "./use-user-details"; export * from "./use-user-details";
export * from "./use-format"; export * from "./use-format";
export * from "./use-repacks";
export * from "./use-feature"; export * from "./use-feature";

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux"; import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features"; import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({ export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState<string[]>([]); const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]); const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const getSteamUserTags = useCallback(() => { const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => { 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(() => { useEffect(() => {
getSteamUserTags(); getSteamUserTags();
getSteamGenres(); getSteamGenres();
getSteamPublishers(); getSteamPublishers();
getSteamDevelopers(); getSteamDevelopers();
getDownloadSources();
}, [ }, [
getSteamUserTags, getSteamUserTags,
getSteamGenres, getSteamGenres,
getSteamPublishers, getSteamPublishers,
getSteamDevelopers, getSteamDevelopers,
getDownloadSources,
]); ]);
return { steamPublishers, steamDevelopers }; return { steamPublishers, downloadSources, steamDevelopers };
} }

View File

@@ -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 };
}

View File

@@ -3,12 +3,14 @@ import { useState, useCallback } from "react";
interface SectionCollapseState { interface SectionCollapseState {
pinned: boolean; pinned: boolean;
library: boolean; library: boolean;
reviews: boolean;
} }
export function useSectionCollapse() { export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({ const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false, pinned: false,
library: false, library: false,
reviews: false,
}); });
const toggleSection = useCallback((section: keyof SectionCollapseState) => { const toggleSection = useCallback((section: keyof SectionCollapseState) => {
@@ -23,5 +25,6 @@ export function useSectionCollapse() {
toggleSection, toggleSection,
isPinnedCollapsed: collapseState.pinned, isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library, isLibraryCollapsed: collapseState.library,
isReviewsCollapsed: collapseState.reviews,
}; };
} }

View File

@@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile"; import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements"; import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log; console.log = logger.log;
@@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route element={<App />}> <Route element={<App />}>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} /> <Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} /> <Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} /> <Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@@ -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 { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null); const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue(); const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector( const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch (state) => state.catalogueSearch
); );
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<CatalogueSearchResult[]>([]); const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -44,31 +47,46 @@ export default function Catalogue() {
const { formatNumber } = useFormat(); const { formatNumber } = useFormat();
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t, i18n } = useTranslation("catalogue"); const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef( const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => { debounce(
const abortController = new AbortController(); async (
abortControllerRef.current = abortController; filters: CatalogueSearchPayload,
downloadSources: DownloadSource[],
pageSize: number,
offset: number
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await window.electron.hydraApi.post<{ const requestData = {
edges: CatalogueSearchResult[]; ...filters,
count: number; take: pageSize,
}>("/catalogue/search", { skip: offset,
data: { ...filters, take: pageSize, skip: offset }, downloadSourceIds: downloadSources.map(
needsAuth: false, (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); if (abortController.signal.aborted) return;
setItemsCount(response.count);
setIsLoading(false); setResults(response.edges);
}, 500) setItemsCount(response.count);
setIsLoading(false);
},
500
)
).current; ).current;
const decodeHTML = (s: string) => const decodeHTML = (s: string) =>
@@ -79,18 +97,17 @@ export default function Catalogue() {
setIsLoading(true); setIsLoading(true);
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE); debouncedSearch(
filters,
downloadSources,
PAGE_SIZE,
(page - 1) * PAGE_SIZE
);
return () => { return () => {
debouncedSearch.cancel(); debouncedSearch.cancel();
}; };
}, [filters, page, debouncedSearch]); }, [filters, downloadSources, page, debouncedSearch]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, []);
const language = i18n.language.split("-")[0]; const language = i18n.language.split("-")[0];
@@ -168,7 +185,7 @@ export default function Catalogue() {
value: publisher, value: publisher,
})), })),
]; ];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]); }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => { const filterSections = useMemo(() => {
return [ return [

View File

@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components"; import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers"; 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 { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch); const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false); const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = 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(() => { const genres = useMemo(() => {
return game.genres?.map((genre) => { return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex( const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
<span className="game-item__genres">{genres.join(", ")}</span> <span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers"> <div className="game-item__repackers">
{uniqueRepackers.map((repacker) => ( {game.downloadSources.map((sourceName) => (
<Badge key={repacker}>{repacker}</Badge> <Badge key={sourceName}>{sourceName}</Badge>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,3 +1,5 @@
@use "../../scss/globals.scss";
.pagination { .pagination {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -18,4 +20,31 @@
font-size: 16px; 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
}
} }

View File

@@ -1,8 +1,53 @@
import { Button } from "@renderer/components/button/button"; import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format"; import { useFormat } from "@renderer/hooks/use-format";
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import "./pagination.scss"; import "./pagination.scss";
interface JumpControlProps {
isOpen: boolean;
value: string;
totalPages: number;
inputRef: RefObject<HTMLInputElement>;
onOpen: () => void;
onClose: () => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
}
function JumpControl({
isOpen,
value,
totalPages,
inputRef,
onOpen,
onClose,
onChange,
onKeyDown,
}: JumpControlProps) {
return isOpen ? (
<input
ref={inputRef}
type="text"
min={1}
max={totalPages}
inputMode="numeric"
pattern="[0-9]*"
className="pagination__page-input"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onClose}
aria-label="Go to page"
/>
) : (
<Button theme="outline" className="pagination__button" onClick={onOpen}>
...
</Button>
);
}
interface PaginationProps { interface PaginationProps {
page: number; page: number;
totalPages: number; totalPages: number;
@@ -13,23 +58,104 @@ export function Pagination({
page, page,
totalPages, totalPages,
onPageChange, onPageChange,
}: PaginationProps) { }: Readonly<PaginationProps>) {
const { formatNumber } = useFormat(); const { formatNumber } = useFormat();
const [isJumpOpen, setIsJumpOpen] = useState(false);
const [jumpValue, setJumpValue] = useState<string>("");
const jumpInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isJumpOpen) {
setJumpValue("");
setTimeout(() => jumpInputRef.current?.focus(), 0);
}
}, [isJumpOpen, page]);
if (totalPages <= 1) return null; if (totalPages <= 1) return null;
const visiblePages = 3; const visiblePages = 3;
const isLastThree = totalPages > 3 && page >= totalPages - 2;
let startPage = Math.max(1, page - 1); let startPage = Math.max(1, page - 1);
let endPage = startPage + visiblePages - 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; endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1); startPage = Math.max(1, endPage - visiblePages + 1);
} }
const onJumpChange = (e: ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 ( return (
<div className="pagination"> <div className="pagination">
{startPage > 1 && (
<Button
theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronLeftIcon />
<ChevronLeftIcon />
</span>
</Button>
)}
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
@@ -39,20 +165,25 @@ export function Pagination({
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </Button>
{page > 2 && ( {isLastThree && startPage > 1 && (
<> <>
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button" className="pagination__button"
disabled={page === 1} onClick={() => onPageChange(1)}
> >
{1} {formatNumber(1)}
</Button> </Button>
<JumpControl
<div className="pagination__ellipsis"> isOpen={isJumpOpen}
<span className="pagination__ellipsis-text">...</span> value={jumpValue}
</div> totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
</> </>
)} )}
@@ -70,11 +201,18 @@ export function Pagination({
</Button> </Button>
))} ))}
{page < totalPages - 1 && ( {!isLastThree && page < totalPages - 1 && (
<> <>
<div className="pagination__ellipsis"> <JumpControl
<span className="pagination__ellipsis-text">...</span> isOpen={isJumpOpen}
</div> value={jumpValue}
totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
<Button <Button
theme="outline" theme="outline"
@@ -95,6 +233,19 @@ export function Pagination({
> >
<ChevronRightIcon /> <ChevronRightIcon />
</Button> </Button>
{endPage < totalPages && (
<Button
theme="outline"
onClick={() => onPageChange(totalPages)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronRightIcon />
<ChevronRightIcon />
</span>
</Button>
)}
</div> </div>
); );
} }

View File

@@ -7,11 +7,16 @@ import {
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks";
import "./gallery-slider.scss"; import "./gallery-slider.scss";
export function GallerySlider() { export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext); const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const autoplayEnabled = userPreferences?.autoplayGameTrailers !== false;
const hasScreenshots = shopDetails && shopDetails.screenshots?.length; const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
@@ -164,7 +169,7 @@ export function GallerySlider() {
poster={item.poster} poster={item.poster}
loop loop
muted muted
autoPlay autoPlay={autoplayEnabled}
tabIndex={-1} tabIndex={-1}
> >
<source src={item.videoSrc} /> <source src={item.videoSrc} />

View File

@@ -228,7 +228,7 @@ export function GameDetailsContent() {
</button> </button>
)} )}
{game?.shop !== "custom" && shop && objectId && ( {shop !== "custom" && shop && objectId && (
<GameReviews <GameReviews
shop={shop} shop={shop}
objectId={objectId} objectId={objectId}
@@ -241,7 +241,7 @@ export function GameDetailsContent() {
)} )}
</div> </div>
{game?.shop !== "custom" && <Sidebar />} {shop !== "custom" && <Sidebar />}
</div> </div>
</section> </section>

View File

@@ -103,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean automaticallyExtract: boolean
) => { ) => {
const response = await startDownload({ const response = await startDownload({
repackId: repack.id,
objectId: objectId!, objectId: objectId!,
title: gameTitle, title: gameTitle,
downloader, downloader,

View File

@@ -117,7 +117,7 @@ export function GameReviews({
}); });
const checkUserReview = useCallback(async () => { const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return; if (!objectId || !userDetailsId || shop === "custom") return;
try { try {
const response = await window.electron.hydraApi.get<{ const response = await window.electron.hydraApi.get<{
@@ -147,7 +147,7 @@ export function GameReviews({
const loadReviews = useCallback( const loadReviews = useCallback(
async (reset = false) => { async (reset = false) => {
if (!objectId) return; if (!objectId || shop === "custom") return;
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
@@ -163,7 +163,6 @@ export function GameReviews({
take: "20", take: "20",
skip: skip.toString(), skip: skip.toString(),
sortBy: reviewsSortBy, sortBy: reviewsSortBy,
language: i18n.language,
}); });
const response = await window.electron.hydraApi.get( const response = await window.electron.hydraApi.get(

View File

@@ -146,6 +146,8 @@ $hero-height: 350px;
&__game-logo { &__game-logo {
width: 200px; width: 200px;
align-self: flex-end; align-self: flex-end;
object-fit: contain;
object-position: left bottom;
@media (min-width: 768px) { @media (min-width: 768px) {
width: 250px; width: 250px;
@@ -153,6 +155,7 @@ $hero-height: 350px;
@media (min-width: 1024px) { @media (min-width: 1024px) {
width: 300px; width: 300px;
max-height: 150px;
} }
} }
@@ -228,44 +231,50 @@ $hero-height: 350px;
} }
&__randomizer-button { &__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5); position: fixed;
background-color: rgba(0, 0, 0, 0.6); 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); backdrop-filter: blur(20px);
border-radius: 8px; border-radius: 8px;
transition: all ease 0.2s; transition: all ease 0.2s;
cursor: pointer; cursor: pointer;
min-height: 40px; min-height: 40px;
min-width: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color; color: globals.$muted-color;
border: solid 1px globals.$border-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); animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
overflow: visible;
&:active { &:disabled {
opacity: 0.9; opacity: globals.$disabled-opacity;
cursor: not-allowed;
} }
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(255, 255, 255, 0.12);
color: globals.$body-color; color: globals.$body-color;
} }
} }
&__stars-icon-container { &__stars-icon-container {
width: 20px; width: 16px;
height: 16px; height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative; position: relative;
} }
&__stars-icon { &__stars-icon {
width: 26px; width: 70px;
position: absolute; position: absolute;
top: -3px; top: -28px;
left: -27px;
} }
} }

View File

@@ -54,7 +54,7 @@ export function RepacksModal({
{} {}
); );
const { repacks, game } = useContext(gameDetailsContext); const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
}); });
}, [repacks, isFeatureEnabled, Feature]); }, [repacks, isFeatureEnabled, Feature]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
const sortedRepacks = useMemo(() => { const sortedRepacks = useMemo(() => {
return orderBy( return orderBy(
repacks, repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
); );
}, [repacks, hashesInDebrid]); }, [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(() => { useEffect(() => {
const term = filterTerm.trim().toLowerCase(); const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => { const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true; if (!term) return true;
const lowerTitle = repack.title.toLowerCase(); const lowerTitle = repack.title.toLowerCase();
const lowerRepacker = repack.repacker.toLowerCase(); const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term); return lowerTitle.includes(term) || lowerRepacker.includes(term);
}); });
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) => (src) =>
src.fingerprint && src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) && selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker src.name === repack.downloadSourceName
); );
}); });
@@ -281,7 +280,7 @@ export function RepacksModal({
)} )}
<p className="repacks-modal__repack-info"> <p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "} {repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""} {repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p> </p>

View File

@@ -8,11 +8,23 @@
&__review-header { &__review-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1.5); 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 { &__review-user {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -22,7 +34,13 @@
&__review-user-info { &__review-user-info {
display: flex; display: flex;
flex-direction: column; 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 { &__review-display-name {
@@ -157,28 +175,28 @@
&__review-score-stars { &__review-score-stars {
display: flex; display: flex;
align-items: center; 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 { &__review-star {
color: #666666; color: rgba(255, 255, 255, 0.7);
transition: color 0.2s ease; transition: color 0.2s ease;
cursor: default; cursor: default;
&--filled { &--filled {
color: #ffffff; color: rgba(255, 255, 255, 0.7);
&.game-details__review-score--red {
color: #fca5a5;
}
&.game-details__review-score--yellow {
color: #fcd34d;
}
&.game-details__review-score--green {
color: #86efac;
}
} }
&--empty { &--empty {
@@ -198,6 +216,24 @@
font-size: globals.$small-font-size; 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 { &__review-content {
color: globals.$body-color; color: globals.$body-color;
line-height: 1.5; line-height: 1.5;

View File

@@ -7,9 +7,10 @@ import { useState } from "react";
import type { GameReview } from "@types"; import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared"; import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks"; import { useDate, useFormat } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers"; import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components"; import { Avatar } from "@renderer/components";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./review-item.scss"; import "./review-item.scss";
@@ -29,13 +30,6 @@ interface ReviewItemProps {
) => void; ) => 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 => { const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) { switch (score) {
case 1: case 1:
@@ -68,28 +62,22 @@ export function ReviewItem({
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details"); const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate(); const { formatDistance } = useDate();
const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false); const [showOriginal, setShowOriginal] = useState(false);
// Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id; const isOwnReview = userDetailsId === review.user.id;
// Helper to get base language code (e.g., "pt" from "pt-BR") const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
const getBaseLanguage = (lang: string) => lang.split("-")[0];
// Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage = const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
// Check if translation is available and needed (but not for own reviews)
const needsTranslation = const needsTranslation =
!isOwnReview && !isOwnReview && isDifferentLanguage && review.translations[i18n.language];
isDifferentLanguage &&
review.translations &&
review.translations[i18n.language];
// Get the full language name using Intl.DisplayNames const getLanguageName = (languageCode: string | null) => {
const getLanguageName = (languageCode: string) => { if (!languageCode) return "";
try { try {
const displayNames = new Intl.DisplayNames([i18n.language], { const displayNames = new Intl.DisplayNames([i18n.language], {
type: "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 // Determine which content to show - always show original for own reviews
const displayContent = needsTranslation const displayContent = needsTranslation
? review.translations[i18n.language] ? review.translations[i18n.language]
@@ -109,12 +111,12 @@ export function ReviewItem({
return ( return (
<div className="game-details__review-item"> <div className="game-details__review-item">
<div className="game-details__blocked-review-simple"> <div className="game-details__blocked-review-simple">
Review from blocked user {" "} {t("review_from_blocked_user")}
<button <button
className="game-details__blocked-review-show-link" className="game-details__blocked-review-show-link"
onClick={() => onToggleVisibility(review.id)} onClick={() => onToggleVisibility(review.id)}
> >
Show {t("show")}
</button> </button>
</div> </div>
</div> </div>
@@ -124,54 +126,61 @@ export function ReviewItem({
return ( return (
<div className="game-details__review-item"> <div className="game-details__review-item">
<div className="game-details__review-header"> <div className="game-details__review-header">
<div className="game-details__review-user"> <div className="game-details__review-header-top">
<button <div className="game-details__review-user">
onClick={() => navigate(`/profile/${review.user.id}`)}
title={review.user.displayName}
>
<Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</button>
<div className="game-details__review-user-info">
<button <button
className="game-details__review-display-name game-details__review-display-name--clickable" onClick={() => navigate(`/profile/${review.user.id}`)}
onClick={() => title={review.user.displayName}
review.user.id && navigate(`/profile/${review.user.id}`)
}
> >
{review.user.displayName || "Anonymous"} <Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</button> </button>
<div className="game-details__review-date"> <div className="game-details__review-user-info">
<ClockIcon size={12} /> <button
{formatDistance(new Date(review.createdAt), new Date(), { className="game-details__review-display-name game-details__review-display-name--clickable"
addSuffix: true, onClick={() =>
})} review.user.id && navigate(`/profile/${review.user.id}`)
}
>
{review.user.displayName || "Anonymous"}
</button>
</div> </div>
</div> </div>
<div className="game-details__review-date">
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</div>
</div> </div>
<div <div className="game-details__review-header-bottom">
className="game-details__review-score-stars" <div className="game-details__review-meta-row">
title={getRatingText(review.score, t)} <div
> className="game-details__review-score-stars"
{[1, 2, 3, 4, 5].map((starValue) => ( title={getRatingText(review.score, t)}
<Star >
key={starValue} <Star
size={20} size={12}
fill={starValue <= review.score ? "currentColor" : "none"} className="game-details__review-star game-details__review-star--filled"
className={`game-details__review-star ${ />
starValue <= review.score <span className="game-details__review-score-text">
? "game-details__review-star--filled" {review.score}/5
: "game-details__review-star--empty" </span>
} ${ </div>
starValue <= review.score {Boolean(
? getScoreColorClass(review.score) review.playTimeInSeconds && review.playTimeInSeconds > 0
: "" ) && (
}`} <div className="game-details__review-playtime">
/> <ClockIcon size={12} />
))} <span>
{t("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div> </div>
</div> </div>
<div> <div>
@@ -323,7 +332,7 @@ export function ReviewItem({
className="game-details__blocked-review-hide-link" className="game-details__blocked-review-hide-link"
onClick={() => onToggleVisibility(review.id)} onClick={() => onToggleVisibility(review.id)}
> >
Hide {t("hide")}
</button> </button>
)} )}
</div> </div>

View File

@@ -40,14 +40,20 @@ export default function Home() {
setCurrentCatalogueCategory(category); setCurrentCatalogueCategory(category);
setIsLoading(true); setIsLoading(true);
const params = new URLSearchParams({ const downloadSources = await window.electron.getDownloadSources();
take: "12",
skip: "0", const params = {
}); take: 12,
skip: 0,
downloadSourceIds: downloadSources.map((source) => source.id),
};
const catalogue = await window.electron.hydraApi.get<ShopAssets[]>( const catalogue = await window.electron.hydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`, `/catalogue/${category}`,
{ needsAuth: false } {
params,
needsAuth: false,
}
); );
setCatalogue((prev) => ({ ...prev, [category]: catalogue })); setCatalogue((prev) => ({ ...prev, [category]: catalogue }));

View File

@@ -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;
}
}

View File

@@ -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<FilterOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-filter-options__container">
<button
className={`library-filter-options__option ${filterBy === "all" ? "active" : ""}`}
onClick={() => onFilterChange("all")}
>
<span className="library-filter-options__label">{t("all_games")}</span>
<span className="library-filter-options__count">{allGamesCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "favourited" ? "active" : ""}`}
onClick={() => onFilterChange("favourited")}
>
<span className="library-filter-options__label">
{t("Favourite Games")}
</span>
<span className="library-filter-options__count">{favouritedCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "new" ? "active" : ""}`}
onClick={() => onFilterChange("new")}
>
<span className="library-filter-options__label">{t("new_games")}</span>
<span className="library-filter-options__count">{newGamesCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "top10" ? "active" : ""}`}
onClick={() => onFilterChange("top10")}
>
<span className="library-filter-options__label">
{t("Most Played")}
</span>
<span className="library-filter-options__count">{top10Count}</span>
</button>
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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<LibraryGameCardLargeProps>) {
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 (
<>
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div
className="library-game-card-large__background"
style={{ backgroundImage: `url(${backgroundImage})` }}
/>
<div className="library-game-card-large__gradient" />
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
<button
type="button"
className="library-game-card-large__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
)}
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
<button
type="button"
className="library-game-card-large__action-button"
onClick={handleActionClick}
>
{(() => {
if (isGameDownloading) {
return (
<>
<DownloadIcon
size={16}
className="library-game-card-large__action-icon--downloading"
/>
{t("downloading")}
</>
);
}
if (isGameRunning) {
return (
<>
<XIcon size={16} />
{t("close")}
</>
);
}
if (game.executablePath) {
return (
<>
<PlayIcon size={16} />
{t("play")}
</>
);
}
return (
<>
<DownloadIcon size={16} />
{t("download")}
</>
);
})()}
</button>
</div>
</div>
</button>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -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;
}

View File

@@ -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<LibraryGameCardProps>) {
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 (
<>
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={isTooltipHovered ? undefined : game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div
className="library-game-card__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
<button
type="button"
className="library-game-card__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
{/* Achievements section - shown on hover */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
/>
</button>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -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);
}
}
}
}

View File

@@ -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<unknown>;
onLibraryBatchComplete?: (cb: () => void) => () => void;
};
const [viewMode, setViewMode] = useState<ViewMode>("compact");
const [filterBy, setFilterBy] = useState<FilterOption>("all");
const [searchQuery, setSearchQuery] = useState<string>("");
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 (
<section className="library__content">
{hasGames && (
<div className="library__page-header">
<div className="library__controls-row">
<div className="library__controls-left">
<FilterOptions
filterBy={filterBy}
onFilterChange={setFilterBy}
allGamesCount={allGamesCount}
favouritedCount={favouritedCount}
newGamesCount={newGamesCount}
top10Count={top10Count}
/>
</div>
<div className="library__controls-right">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<ViewOptions viewMode={viewMode} onViewModeChange={setViewMode} />
</div>
</div>
</div>
)}
{!hasGames && (
<div className="library__no-games">
<div className="library__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_games_title")}</h2>
<p>{t("no_games_description")}</p>
</div>
)}
{hasGames && viewMode === "large" && (
<div className="library__games-list library__games-list--large">
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
/>
))}
</div>
)}
{hasGames && viewMode !== "large" && (
<ul className={`library__games-grid library__games-grid--${viewMode}`}>
{sortedLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}
>
<LibraryGameCard
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More