Merge branch 'main' into feat/library

This commit is contained in:
Chubby Granny Chaser
2025-11-02 20:23:44 +00:00
committed by GitHub
108 changed files with 3445 additions and 2861 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=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

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

View File

@@ -1,11 +1,12 @@
name: Build
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:
build:
strategy:
@@ -22,7 +23,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.3
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -38,11 +39,15 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
@@ -98,5 +103,4 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.pacman
dist/*.AppImage

View File

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

View File

@@ -6,7 +6,8 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
@@ -23,7 +24,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.3
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -39,11 +40,15 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
@@ -57,7 +62,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Build Windows
if: matrix.os == 'windows-2022'
@@ -74,7 +79,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Create artifact
uses: actions/upload-artifact@v4
@@ -90,7 +95,6 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.pacman
- name: Upload build
env:
@@ -119,6 +123,5 @@ jobs:
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.pacman
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

161
.github/workflows/update-aur.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Update AUR Package
on:
workflow_dispatch:
release:
types: [published]
jobs:
update-aur:
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm
pacman -S --noconfirm nodejs npm git base-devel openssh jq pacman-contrib
- name: Create builder user
run: |
# Create builder user with home directory
useradd -m -s /bin/bash builder
# Add builder to wheel group for sudo access
usermod -aG wheel builder
# Configure sudo for builder user (no password required)
echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
chmod 700 ~/.ssh
# Add AUR host key to known_hosts
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
# Configure SSH to use the key
cat > ~/.ssh/config << EOF
Host aur.archlinux.org
IdentityFile ~/.ssh/id_rsa
IdentitiesOnly yes
User aur
UserKnownHostsFile ~/.ssh/known_hosts
StrictHostKeyChecking no
EOF
# Start SSH agent and add key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
git clone ssh://aur@aur.archlinux.org/hydra-launcher-bin.git
# Give builder user ownership of the repository
chown -R builder:builder hydra-launcher-bin
- name: Get version to update
id: get-version
run: |
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "source=release" >> $GITHUB_OUTPUT
else
echo "Getting latest release version"
VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "source=latest" >> $GITHUB_OUTPUT
fi
echo "Version to update: $VERSION"
- name: Check if update is needed
id: check-update
run: |
CURRENT_VERSION=$(grep '^pkgver=' hydra-launcher-bin/PKGBUILD | cut -d'=' -f2)
NEW_VERSION="${{ steps.get-version.outputs.version }}"
echo "Current AUR version: $CURRENT_VERSION"
echo "New version: $NEW_VERSION"
if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo "update_needed=false" >> $GITHUB_OUTPUT
echo "No update needed - versions are the same"
else
echo "update_needed=true" >> $GITHUB_OUTPUT
echo "Update needed"
fi
- name: Update PKGBUILD and .SRCINFO
if: steps.check-update.outputs.update_needed == 'true'
run: |
# sleeps for 1 minute to be sure GH updated the release info
sleep 60
# Update pkgver in PKGBUILD
cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}"
NEW_VERSION="${NEW_VERSION#v}"
echo "Updating PKGBUILD pkgver to $NEW_VERSION"
# Read PKGBUILD and update pkgver line
sed -i "s/^pkgver=.*/pkgver=$NEW_VERSION/" ./PKGBUILD
# Reset pkgrel to 1 when version changes
sed -i "s/^pkgrel=.*/pkgrel=1/" ./PKGBUILD
echo "✅ Successfully updated pkgver to $NEW_VERSION in ./PKGBUILD"
# Update package checksums and generate .SRCINFO as builder user
sudo -u builder updpkgsums
sudo -u builder makepkg --printsrcinfo > .SRCINFO
- name: Commit and push changes
if: steps.check-update.outputs.update_needed == 'true'
run: |
cd hydra-launcher-bin
git config --global --add safe.directory .
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
echo "## Git Diff Preview"
echo "Changes that would be made:"
git diff PKGBUILD .SRCINFO || echo "No changes to show"
echo ""
echo "Staged changes:"
git add PKGBUILD .SRCINFO
git diff --staged || echo "No staged changes"
if git diff --staged --quiet; then
echo "No changes to commit"
else
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG"
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
git push origin master
echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}"
fi
- name: Create summary
if: always()
run: |
echo "## AUR Update Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ steps.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Source**: ${{ steps.get-version.outputs.source }}" >> $GITHUB_STEP_SUMMARY
echo "- **Update needed**: ${{ steps.check-update.outputs.update_needed }}" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-update.outputs.update_needed }}" = "true" ]; then
echo "- **Status**: ✅ AUR package updated successfully" >> $GITHUB_STEP_SUMMARY
else
echo "- **Status**: ⏭️ No update needed" >> $GITHUB_STEP_SUMMARY
fi

View File

@@ -56,7 +56,6 @@ linux:
- AppImage
- snap
- deb
- pacman
- rpm
maintainer: electronjs.org
category: Game

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.1",
"version": "3.7.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -90,8 +90,7 @@
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0",
"zod": "^3.24.1"
"yup": "^1.5.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",
@@ -116,9 +115,9 @@
"@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^33.4.11",
"electron": "^37.7.1",
"electron-builder": "^26.0.12",
"electron-vite": "^3.0.0",
"electron-vite": "^4.0.1",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4",
@@ -130,7 +129,7 @@
"sass-embedded": "^1.80.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "5.4.20",
"vite": "5.4.21",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -20,7 +20,7 @@ const s3 = new S3Client({
const dist = path.resolve(__dirname, "..", "dist");
const extensionsToUpload = [".deb", ".exe", ".pacman", ".AppImage"];
const extensionsToUpload = [".deb", ".exe", ".AppImage"];
fs.readdir(dist, async (err, files) => {
if (err) throw err;

View File

@@ -225,6 +225,7 @@
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"review_played_for": "Played for",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
@@ -363,7 +364,10 @@
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
"hide_original": "Hide original"
"hide_original": "Hide original",
"review_from_blocked_user": "Review from blocked user",
"show": "Show",
"hide": "Hide"
},
"activation": {
"title": "Activate Hydra",
@@ -430,6 +434,9 @@
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"adding": "Adding…",
"failed_add_download_source": "Failed to add download source. Please try again.",
"download_source_already_exists": "This download source URL already exists.",
"download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options",
@@ -437,9 +444,16 @@
"add_download_source_description": "Insert the URL of the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
"download_source_pending_matching": "Updating soon",
"download_source_matched": "Up to date",
"download_source_matching": "Updating",
"download_source_failed": "Error",
"download_source_no_information": "No information available",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"removed_download_sources": "Download sources removed",
"removed_all_download_sources": "All download sources removed",
"download_sources_synced_successfully": "All download sources are synced",
"cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Yes, delete everything",
"title_confirmation_delete_all_sources": "Delete all download sources",
@@ -470,6 +484,7 @@
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them",
"account": "Account",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "You have no blocked users",
"subscription_active_until": "Your Hydra Cloud is active until {{date}}",
"manage_subscription": "Manage subscription",
@@ -543,7 +558,9 @@
"hidden": "Hidden",
"test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game"
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
},
"notifications": {
"download_complete": "Download complete",

View File

@@ -70,6 +70,24 @@
"edit_game_modal_icon_resolution": "Resolución recomendada: 256x256px",
"edit_game_modal_logo_resolution": "Resolución recomendada: 640x360px",
"edit_game_modal_hero_resolution": "Resolución recomendada: 1920x620px",
"cancel": "Cancelar",
"confirm": "Confirmar",
"decky_plugin_installation_error": "Error instalando plugin Decky: {{error}}",
"decky_plugin_installation_failed": "Falló instalar plugin Decky: {{error}}",
"decky_plugin_installed": "Plugin Decky v{{version}} instalanda exitosamente",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"edit_game_modal_drop_hero_image_here": "Soltá la imagen hero acá",
"edit_game_modal_drop_icon_image_here": "Soltá la imagen de ícono hero acá",
"edit_game_modal_drop_logo_image_here": "Soltá la imagen de logo hero acá",
"edit_game_modal_drop_to_replace_hero": "Soltá para reemplazar hero",
"edit_game_modal_drop_to_replace_icon": "Soltá para reemplazar el ícono",
"edit_game_modal_drop_to_replace_logo": "Soltá para reemplazar el logo",
"install_decky_plugin": "Instalar plugin Decky",
"install_decky_plugin_message": "Esto va a descargar e instalar el plugin de Decky Loader para Hydra. Esto quizás requierea permisos elevados, ¿querés continuar?",
"install_decky_plugin_title": "Instarlar el plugin Decky Hydra",
"update_decky_plugin": "Actualizar plugin Decky",
"update_decky_plugin_message": "Una nueva versión del plugin Decky para Hydra está disponible. ¿Querés actualizarlo ahora?",
"update_decky_plugin_title": "Actualizar plugin Decky para Hydra",
"edit_game_modal_assets": "Recursos"
},
"header": {
@@ -285,9 +303,68 @@
"keyshop_price": "Precio de tiendas de terceros",
"historical_retail": "Precio de tiendas",
"historical_keyshop": "Precio de tiendas de terceros",
"add_to_favorites": "Añadir a favoritos",
"be_first_to_review": "¡Sé la primera persona en compartir lo que pensas de este juego!",
"create_shortcut_simple": "Crear atajo",
"delete_review": "Eliminar reseña",
"delete_review_modal_cancel_button": "Cancelar",
"delete_review_modal_delete_button": "Eliminar",
"delete_review_modal_description": "Esta acción no se puede deshacer.",
"delete_review_modal_title": "¿De verdad querés eliminar esta reseña?",
"failed_remove_files": "Error al eliminar los archivos",
"failed_remove_from_library": "Error al eliminar de la librería",
"failed_update_favorites": "Error al actualizar favoritos",
"files_removed_success": "Archivos eliminados correctamente",
"filter_by_source": "Filtrar por fuente",
"game_removed_from_library": "Juego eliminado de la librería",
"hide_original": "Ocultar original",
"leave_a_review": "Crear una reseña",
"load_more_reviews": "Cargar más reseñas",
"loading_more_reviews": "Cargando más reseñas...",
"loading_reviews": "Cargando reseñas...",
"maybe_later": "Tal vez después",
"no_repacks_found": "Sin fuentes encontradas para este juego",
"no_reviews_yet": "Sin reseñas aún",
"properties": "Propiedades",
"rating": "Calificación",
"rating_count": "Calificación",
"rating_negative": "Negativa",
"rating_neutral": "Neutral",
"rating_positive": "Positiva",
"rating_stats": "Calificación",
"rating_very_negative": "Muy Negativa",
"rating_very_positive": "Muy Positiva",
"remove_from_favorites": "Eliminar de favoritos",
"remove_review": "Eliminar reseña",
"review_cannot_be_empty": "El campo de la reseña no puede estar vacío.",
"review_deleted_successfully": "Reseña eliminada exitosamente.",
"review_deletion_failed": "Error al eliminar reseña. Por favor intentá de nuevo.",
"review_submission_failed": "Error al subir reseña. Por favor intentá de nuevo.",
"review_submitted_successfully": "¡Reseña eliminada exitosamente!",
"reviews": "Reseñas",
"show_less": "Ver menos",
"show_more": "Ver más",
"show_original": "Ver original",
"show_original_translated_from": "Ver original (traducido del {{language}})",
"show_translation": "Ver traducción",
"sort_highest_score": "Puntuación más alta",
"sort_lowest_score": "Puntuación más baja",
"sort_most_voted": "Más votads",
"sort_newest": "Más nuevos",
"sort_oldest": "Más viejos",
"submit_review": "Enviar",
"submitting": "Subiendo...",
"vote_failed": "Error al registrar tu voto. Por favor intentá de nuevo.",
"would_you_recommend_this_game": "¿Querés escribir una reseña para este juego?",
"write_review_placeholder": "Compartí tus pensamientos sobre este juego...",
"yes": "Si",
"you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"language": "Idioma",
"caption": "Subtítulo",
"audio": "Audio"
"audio": "Audio",
"review_from_blocked_user": "Reseña de usuario bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Activar Hydra",
@@ -345,7 +422,7 @@
"enable_real_debrid": "Habilitar Real-Debrid",
"real_debrid_description": "Real-Debrid es un descargador que te permite descargar archivos más rápidos, solo límitado por la velocidad de tu internet.",
"debrid_invalid_token": "Token API inválido",
"debrid_api_token_hint": "Podés obtener la el token de tu API <0>acá</0>",
"debrid_api_token_hint": "Podés obtener el token de tu API <0>acá</0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratis. Por favor suscribíte a Real-Debrid",
"debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"save_changes": "Guardar cambios",
@@ -357,7 +434,7 @@
"download_count_zero": "Sin opciones de descarga",
"download_count_one": "{{countFormatted}} opción de descarga",
"download_count_other": "{{countFormatted}} opciones de descarga",
"download_source_url": "Descargar fuente URL",
"download_source_url": "Añadir URL de una fuente",
"add_download_source_description": "Introducí la URL del archivo .json",
"download_source_up_to_date": "Actualizado",
"download_source_errored": "Error",
@@ -409,7 +486,7 @@
"subscription_renew_cancelled": "Renovación automática desactivada",
"subscription_renews_on": "Tu suscripción se renueva el {{date}}",
"bill_sent_until": "Tu próxima factura se enviará este día",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupés, presiona acá para hacer tu primera obra maestra.",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para hacer tu primera obra maestra.",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Guardar",
@@ -443,7 +520,7 @@
"enable_friend_request_notifications": "Cuando recibís una solicitud de amistad",
"enable_auto_install": "Descargar actualizaciones automáticamente",
"common_redist": "Common redistributables",
"common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
"common_redist_description": "Los common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
"install_common_redist": "Instalar",
"installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
@@ -465,7 +542,11 @@
"hidden": "Oculto",
"test_notification": "Probar notificación",
"notification_preview": "Probar notificación de logro",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
"debrid": "Debrid",
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -492,6 +573,7 @@
"game_card": {
"available_one": "Disponible",
"available_other": "Disponibles",
"calculating": "Calculando",
"no_downloads": "Sin descargas disponibles"
},
"binary_not_found_modal": {
@@ -593,6 +675,12 @@
"error_adding_friend": "No se pudo enviar la solicitud de amistad. Por favor revisá el código",
"friend_code_length_error": "El código de amistad debe tener mínimo 8 caracteres",
"game_removed_from_pinned": "Juego removido de fijados",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados"
},
"achievement": {

View File

@@ -8,7 +8,7 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 A hét felkapott játékai",
"weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
@@ -26,7 +26,7 @@
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"need_help": "Elakadtál?",
"favorites": "Kedvenc játékok",
"favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása",
@@ -224,7 +224,7 @@
"show_less": "Mutass kevesebbet",
"reviews": "Vélemények",
"leave_a_review": "Hagyd itt a véleményed",
"write_review_placeholder": "Oszd meg a gondolataid a játékról...",
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
"sort_newest": "Legújabb",
"no_reviews_yet": "Még nem lett vélemény megosztva",
"be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!",
@@ -252,7 +252,7 @@
"you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot",
"would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?",
"yes": "Igen",
"maybe_later": "Talán Később",
"maybe_later": "Talán később",
"cloud_save": "Mentés felhőben",
"cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön",
"backups": "Biztonsági másolatok",
@@ -356,13 +356,18 @@
"delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?",
"delete_review_modal_description": "Ez a lépés nem vonható vissza.",
"delete_review_modal_delete_button": "Törlés",
"delete_review_modal_cancel_button": "Mégse"
"delete_review_modal_cancel_button": "Mégse",
"vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.",
"show_original": "Eredeti megjelenítése",
"show_translation": "Fordítás megjelenítése",
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
"hide_original": "Eredeti elrejtése"
},
"activation": {
"title": "Hydra Aktiválása",
"installation_id": "Telepítési Azonosító:",
"enter_activation_code": "Írd be az aktiválási kódod",
"message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.",
"message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Töltés…"
},
@@ -386,7 +391,7 @@
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozási sorban",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
"checking_files": "Fájlok ellenőrzése…",
@@ -419,20 +424,30 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
"adding": "Hozzáadás…",
"failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.",
"download_source_already_exists": "Ez a letöltési forrás URL már létezik.",
"download_count_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{countFormatted}} letöltési opció",
"download_source_url": "URL forrás:",
"download_source_url": "URL Forrás:",
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt",
"download_source_pending_matching": "Frissítés hamarosan",
"download_source_matched": "Naprakész",
"download_source_matching": "Frissítés..",
"download_source_failed": "Hiba",
"download_source_no_information": "Nincs elérhető információ",
"sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források eltávolítva",
"removed_all_download_sources": "Összes letöltési forrás eltávolítva",
"download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva",
"cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
@@ -445,6 +460,7 @@
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás",
"importing": "Importálás...",
"public": "Publikus",
"private": "Privát",
"friends_only": "Csak barátok",
@@ -462,6 +478,7 @@
"seed_after_download_complete": "Letöltés utáni seedelés",
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése",
@@ -498,14 +515,14 @@
"cancel": "Mégsem",
"appearance": "Megjelenés",
"debrid": "Debrid",
"debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.",
"debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, és csak az internet sebességed szab határt.",
"enable_torbox": "TorBox bekapcsolása",
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
"torbox_account_linked": "TorBox fiók összekapcsolva",
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
"name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen",
"import_theme": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
@@ -535,7 +552,9 @@
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
"hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -563,10 +582,10 @@
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás"
"calculating": "Számítás alatt.."
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"title": "Hiányzó programok",
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
"instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson"
},
@@ -585,6 +604,7 @@
"activity": "Legutóbbi tevékenység",
"library": "Könyvtár",
"pinned": "Kitűzve",
"sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
@@ -654,7 +674,7 @@
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
"achievements": "achievementek",
"achievements": "achievement",
"games": "Játékok",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.",
@@ -669,7 +689,7 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekre kapott pontok alapján"
"karma_description": "Pozitív értékelésekkel szerzett pontok"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",
@@ -678,7 +698,7 @@
"unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement",
"achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}",
"hidden_achievement_tooltip": "Ez egy rejtett achievement",
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",

View File

@@ -28,6 +28,7 @@ import bg from "./bg/translation.json";
import uz from "./uz/translation.json";
import fi from "./fi/translation.json";
import sv from "./sv/translation.json";
import lv from "./lv/translation.json";
export default {
"pt-BR": ptBR,
@@ -60,4 +61,5 @@ export default {
et,
uz,
sv,
lv,
};

View File

@@ -0,0 +1,708 @@
{
"language_name": "Latviešu",
"app": {
"successfully_signed_in": "Veiksmīga pieteikšanās"
},
"home": {
"surprise_me": "Pārsteidz mani",
"no_results": "Nekas nav atrasts",
"start_typing": "Sākt rakstīt...",
"hot": "Šobrīd populārs",
"weekly": "📅 Nedēļas labākās spēles",
"achievements": "🏆 Spēles ar sasniegumiem"
},
"sidebar": {
"catalogue": "Katalogs",
"downloads": "Lejupielādes",
"settings": "Iestatījumi",
"my_library": "Bibliotēka",
"downloading_metadata": "{{title}} (Lejupielādē metadatus…)",
"paused": "{{title}} (Apturēts)",
"downloading": "{{title}} ({{percentage}} - Lejupielādē…)",
"filter": "Meklēt",
"home": "Sākums",
"queued": "{{title}} (Rindā)",
"game_has_no_executable": "Spēles palaišanas fails nav izvēlēts",
"sign_in": "Pieteikties",
"friends": "Draugi",
"need_help": "Nepieciešama palīdzība?",
"favorites": "Izlase",
"playable_button_title": "Rādīt tikai instalētās spēles.",
"add_custom_game_tooltip": "Pievienot pielāgotu spēli",
"show_playable_only_tooltip": "Rādīt tikai spēlēšanai pieejamās",
"custom_game_modal": "Pievienot pielāgotu spēli",
"custom_game_modal_description": "Pievienojiet pielāgotu spēli bibliotēkai, izvēloties izpildāmo failu",
"custom_game_modal_executable_path": "Ceļš uz izpildāmo failu",
"custom_game_modal_select_executable": "Izvēlieties izpildāmo failu",
"custom_game_modal_title": "Spēles nosaukums",
"custom_game_modal_enter_title": "Ievadiet spēles nosaukumu",
"custom_game_modal_browse": "Pārlūkot",
"custom_game_modal_cancel": "Atcelt",
"custom_game_modal_add": "Pievienot spēli",
"custom_game_modal_adding": "Pievieno spēli...",
"custom_game_modal_success": "Pielāgota spēle veiksmīgi pievienota",
"custom_game_modal_failed": "Neizdevās pievienot pielāgotu spēli",
"custom_game_modal_executable": "Izpildāmais fails",
"edit_game_modal": "Konfigurēt resursus",
"edit_game_modal_description": "Konfigurējiet spēles resursus un detaļas",
"edit_game_modal_title": "Nosaukums",
"edit_game_modal_enter_title": "Ievadiet nosaukumu",
"edit_game_modal_image": "Attēls",
"edit_game_modal_select_image": "Izvēlieties attēlu",
"edit_game_modal_browse": "Pārlūkot",
"edit_game_modal_image_preview": "Attēla priekšskatījums",
"edit_game_modal_icon": "Ikona",
"edit_game_modal_select_icon": "Izvēlieties ikonu",
"edit_game_modal_icon_preview": "Ikona priekšskatījums",
"edit_game_modal_logo": "Logotips",
"edit_game_modal_select_logo": "Izvēlieties logotipu",
"edit_game_modal_logo_preview": "Logotipa priekšskatījums",
"edit_game_modal_hero": "Vāka attēls",
"edit_game_modal_select_hero": "Izvēlieties spēles vāka attēlu",
"edit_game_modal_hero_preview": "Spēles vāka attēla priekšskatījums",
"edit_game_modal_cancel": "Atcelt",
"edit_game_modal_update": "Atjaunināt",
"edit_game_modal_updating": "Atjaunina...",
"edit_game_modal_fill_required": "Lūdzu, aizpildiet visus obligātos laukus",
"edit_game_modal_success": "Resursi veiksmīgi atjaunināti",
"edit_game_modal_failed": "Neizdevās atjaunināt resursus",
"edit_game_modal_image_filter": "Attēls",
"edit_game_modal_icon_resolution": "Ieteicamā izšķirtspēja: 256x256px",
"edit_game_modal_logo_resolution": "Ieteicamā izšķirtspēja: 640x360px",
"edit_game_modal_hero_resolution": "Ieteicamā izšķirtspēja: 1920x620px",
"edit_game_modal_assets": "Resursi",
"edit_game_modal_drop_icon_image_here": "Ievelciet ikonas attēlu šeit",
"edit_game_modal_drop_logo_image_here": "Ievelciet logotipa attēlu šeit",
"edit_game_modal_drop_hero_image_here": "Ievelciet vāka attēlu šeit",
"edit_game_modal_drop_to_replace_icon": "Ievelciet, lai aizstātu ikonu",
"edit_game_modal_drop_to_replace_logo": "Ievelciet, lai aizstātu logotipu",
"edit_game_modal_drop_to_replace_hero": "Ievelciet, lai aizstātu vāku",
"install_decky_plugin": "Instalēt Decky spraudni",
"update_decky_plugin": "Atjaunināt Decky spraudni",
"decky_plugin_installed_version": "Decky spraudnis (v{{version}})",
"install_decky_plugin_title": "Instalēt Hydra Decky spraudni",
"install_decky_plugin_message": "Tas lejupielādēs un instalēs Hydra spraudni Decky Loader. Var būt nepieciešamas paaugstinātas atļaujas. Turpināt?",
"update_decky_plugin_title": "Atjaunināt Hydra Decky spraudni",
"update_decky_plugin_message": "Ir pieejama jauna Hydra Decky spraudņa versija. Vai vēlaties to atjaunināt tagad?",
"decky_plugin_installed": "Decky spraudnis v{{version}} veiksmīgi instalēts",
"decky_plugin_installation_failed": "Neizdevās instalēt Decky spraudni: {{error}}",
"decky_plugin_installation_error": "Decky spraudņa instalēšanas kļūda: {{error}}",
"confirm": "Apstiprināt",
"cancel": "Atcelt"
},
"header": {
"search": "Meklēt",
"home": "Sākums",
"catalogue": "Katalogs",
"downloads": "Lejupielādes",
"search_results": "Meklēšanas rezultāti",
"settings": "Iestatījumi",
"version_available_install": "Pieejama versija {{version}}. Noklikšķiniet šeit, lai instalētu.",
"version_available_download": "Pieejama versija {{version}}. Noklikšķiniet šeit, lai lejupielādētu."
},
"bottom_panel": {
"no_downloads_in_progress": "Nav aktīvu lejupielāžu",
"downloading_metadata": "Lejupielādē metadatus {{title}}…",
"downloading": "Lejupielādē {{title}}… ({{percentage}} pabeigts) - Beigsies {{eta}} - {{speed}}",
"calculating_eta": "Lejupielādē {{title}}… ({{percentage}} pabeigts) - Aprēķina atlikušo laiku…",
"checking_files": "Pārbauda failus {{title}}… ({{percentage}} pabeigts)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Instalēšana pabeigta",
"installation_complete_message": "Bibliotēkas veiksmīgi instalētas"
},
"catalogue": {
"search": "Filtrs…",
"developers": "Izstrādātāji",
"genres": "Žanri",
"tags": "Atzīmes",
"publishers": "Izdevēji",
"download_sources": "Lejupielādes avoti",
"result_count": "{{resultCount}} rezultāti",
"filter_count": "{{filterCount}} pieejami",
"clear_filters": "Notīrīt {{filterCount}} atlasītos"
},
"game_details": {
"open_download_options": "Atvērt avotus",
"download_options_zero": "Nav avotu",
"download_options_one": "{{count}} avots",
"download_options_other": "{{count}} avoti",
"updated_at": "Atjaunināts {{updated_at}}",
"install": "Instalēt",
"resume": "Atsākt",
"pause": "Apturēt",
"cancel": "Atcelt",
"remove": "Dzēst",
"space_left_on_disk": "{{space}} brīvs diskā",
"eta": "Beigsies {{eta}}",
"calculating_eta": "Aprēķina atlikušo laiku…",
"downloading_metadata": "Lejupielādē metadatus…",
"filter": "Meklēt repakus",
"requirements": "Sistēmas prasības",
"minimum": "Minimālās",
"recommended": "Ieteicamās",
"paused": "Apturēts",
"release_date": "Izdots {{date}}",
"publisher": "Izdevējs {{publisher}}",
"hours": "stundas",
"minutes": "minūtes",
"amount_hours": "{{amount}} stundas",
"amount_minutes": "{{amount}} minūtes",
"accuracy": "precizitāte {{accuracy}}%",
"add_to_library": "Pievienot bibliotēkai",
"already_in_library": "Jau bibliotēkā",
"remove_from_library": "Dzēst no bibliotēkas",
"no_downloads": "Nav pieejamu avotu",
"play_time": "Spēlēts {{amount}}",
"last_time_played": "Pēdējo reizi spēlēts {{period}}",
"not_played_yet": "Jūs vēl neesat spēlējis {{title}}",
"next_suggestion": "Nākamais ieteikums",
"play": "Spēlēt",
"deleting": "Dzēš instalētāju…",
"close": "Aizvērt",
"playing_now": "Palaists",
"change": "Mainīt",
"repacks_modal_description": "Izvēlieties repaku lejupielādei",
"select_folder_hint": "Lai mainītu noklusējuma lejupielāžu mapi, atveriet <0>Iestatījumus</0>",
"download_now": "Lejupielādēt tagad",
"no_shop_details": "Neizdevās iegūt aprakstu",
"download_options": "Avoti",
"download_path": "Ceļš lejupielādēm",
"previous_screenshot": "Iepriekšējais ekrānuzņēmums",
"next_screenshot": "Nākamais ekrānuzņēmums",
"screenshot": "Ekrānuzņēmums {{number}}",
"open_screenshot": "Atvērt ekrānuzņēmumu {{number}}",
"download_settings": "Lejupielādes parametri",
"downloader": "Lejupielādētājs",
"select_executable": "Izvēlēties",
"no_executable_selected": "Fails nav izvēlēts",
"open_folder": "Atvērt mapi",
"open_download_location": "Pārlūkot lejupielādes mapi",
"create_shortcut": "Izveidot īsceļu uz darbvirsmas",
"create_shortcut_simple": "Izveidot īsceļu",
"clear": "Notīrīt",
"remove_files": "Dzēst failus",
"remove_from_library_title": "Vai esat pārliecināts?",
"remove_from_library_description": "{{game}} tiks dzēsta no jūsu bibliotēkas.",
"options": "Iestatījumi",
"properties": "Īpašības",
"executable_section_title": "Fails",
"executable_section_description": "Ceļš uz failu, kas tiks palaists, nospiežot \"Spēlēt\"",
"downloads_section_title": "Lejupielādes",
"downloads_section_description": "Pārbaudīt atjauninājumu vai citu spēles versiju pieejamību",
"danger_zone_section_title": "Bīstamā zona",
"danger_zone_section_description": "Jūs varat dzēst šo spēli no savas bibliotēkas vai failus, kas lejupielādēti no Hydra",
"download_in_progress": "Notiek lejupielāde",
"download_paused": "Lejupielāde apturēta",
"last_downloaded_option": "Pēdējais lejupielādes variants",
"create_steam_shortcut": "Izveidot Steam īsceļu",
"create_shortcut_success": "Īsceļš izveidots",
"you_might_need_to_restart_steam": "Iespējams, jums būs jāpārstartē Steam, lai redzētu izmaiņas",
"create_shortcut_error": "Neizdevās izveidot īsceļu",
"add_to_favorites": "Pievienot izlasei",
"remove_from_favorites": "Dzēst no izlases",
"failed_update_favorites": "Neizdevās atjaunināt izlasi",
"game_removed_from_library": "Spēle dzēsta no bibliotēkas",
"failed_remove_from_library": "Neizdevās dzēst no bibliotēkas",
"files_removed_success": "Faili veiksmīgi dzēsti",
"failed_remove_files": "Neizdevās dzēst failus",
"nsfw_content_title": "Šajā spēlē ir nepiemērots saturs",
"nsfw_content_description": "{{title}} satur saturu, kas var nebūt piemērots visiem vecumiem. \nVai esat pārliecināts, ka vēlaties turpināt?",
"allow_nsfw_content": "Turpināt",
"refuse_nsfw_content": "Atpakaļ",
"stats": "Statistika",
"download_count": "Lejupielādes",
"player_count": "Aktīvie spēlētāji",
"download_error": "Šis lejupielādes variants nav pieejams",
"download": "Lejupielādēt",
"executable_path_in_use": "Izpildāmais fails jau tiek izmantots \"{{game}}\"",
"warning": "Uzmanību:",
"hydra_needs_to_remain_open": "Lai veiktu šo lejupielādi, Hydra jāpaliek atvērtai līdz beigām. Ja Hydra aizvērsies pirms pabeigšanas, jūs zaudēsiet progresu.",
"achievements": "Sasniegumi",
"achievements_count": "Sasniegumi {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Rādīt vairāk",
"show_less": "Rādīt mazāk",
"reviews": "Atsauksmes",
"leave_a_review": "Atstāt atsauksmi",
"write_review_placeholder": "Dalieties savās domās par šo spēli...",
"sort_newest": "Vispirms jaunākās",
"no_reviews_yet": "Pagaidām nav atsauksmju",
"be_first_to_review": "Esiet pirmais, kurš dalīsies savās domās par šo spēli!",
"sort_oldest": "Vispirms vecākās",
"sort_highest_score": "Augstākais vērtējums",
"sort_lowest_score": "Zemākais vērtējums",
"sort_most_voted": "Vispopulārākās",
"rating": "Vērtējums",
"rating_stats": "Vērtējums",
"rating_very_negative": "Ļoti negatīvs",
"rating_negative": "Negatīvs",
"rating_neutral": "Neitrāls",
"rating_positive": "Pozitīvs",
"rating_very_positive": "Ļoti pozitīvs",
"submit_review": "Iesniegt",
"submitting": "Iesniegšana...",
"review_submitted_successfully": "Atsauksme veiksmīgi iesniegta!",
"review_submission_failed": "Neizdevās iesniegt atsauksmi. Lūdzu, mēģiniet vēlreiz.",
"review_cannot_be_empty": "Atsauksmes teksta lauks nevar būt tukšs.",
"review_deleted_successfully": "Atsauksme veiksmīgi dzēsta.",
"review_deletion_failed": "Neizdevās dzēst atsauksmi. Lūdzu, mēģiniet vēlreiz.",
"loading_reviews": "Ielādē atsauksmes...",
"loading_more_reviews": "Ielādē papildu atsauksmes...",
"load_more_reviews": "Ielādēt vairāk atsauksmju",
"you_seemed_to_enjoy_this_game": "Šķiet, jums patika šī spēle",
"would_you_recommend_this_game": "Vai vēlaties atstāt atsauksmi par šo spēli?",
"yes": "Jā",
"maybe_later": "Varbūt vēlāk",
"rating_count": "Vērtējums",
"delete_review": "Dzēst atsauksmi",
"remove_review": "Dzēst atsauksmi",
"delete_review_modal_title": "Vai esat pārliecināts, ka vēlaties dzēst savu atsauksmi?",
"delete_review_modal_description": "Šo darbību nevar atsaukt.",
"delete_review_modal_delete_button": "Dzēst",
"delete_review_modal_cancel_button": "Atcelt",
"show_original": "Rādīt oriģinālu",
"show_translation": "Rādīt tulkojumu",
"show_original_translated_from": "Rādīt oriģinālu (tulkot no {{language}})",
"hide_original": "Slēpt oriģinālu",
"cloud_save": "Mākoņglabāšana",
"cloud_save_description": "Glabājiet savu progresu mākonī un turpiniet spēlēt jebkurā ierīcē",
"backups": "Rezerves kopijas",
"install_backup": "Instalēt",
"delete_backup": "Dzēst",
"create_backup": "Izveidot jaunu rezerves kopiju",
"last_backup_date": "Pēdējā rezerves kopija no {{date}}",
"no_backup_preview": "Šim nosaukumam saglabājumi nav atrasti",
"restoring_backup": "Atjauno rezerves kopiju ({{progress}} pabeigts)…",
"uploading_backup": "Augšupielādē rezerves kopiju…",
"no_backups": "Jūs vēl neesat izveidojis rezerves kopijas šai spēlei",
"backup_uploaded": "Rezerves kopija augšupielādēta",
"backup_failed": "Rezerves kopēšanas kļūda",
"backup_deleted": "Rezerves kopija dzēsta",
"backup_restored": "Rezerves kopija atjaunota",
"see_all_achievements": "Skatīt visus sasniegumus",
"sign_in_to_see_achievements": "Piesakieties, lai redzētu sasniegumus",
"mapping_method_automatic": "Automātiska",
"mapping_method_manual": "Manuāla",
"mapping_method_label": "Kartēšanas metode",
"files_automatically_mapped": "Faili automātiski kartēti",
"no_backups_created": "Šai spēlei nav izveidotas rezerves kopijas",
"manage_files": "Failu pārvaldība",
"loading_save_preview": "Meklē saglabājumus…",
"wine_prefix": "Wine prefikss",
"wine_prefix_description": "Wine prefikss, ko izmanto šīs spēles palaišanai",
"launch_options": "Palaišanas parametri",
"launch_options_description": "Pieredzējuši lietotāji var veikt izmaiņas palaišanas parametros",
"launch_options_placeholder": "Parametrs nav norādīts",
"no_download_option_info": "Informācija nav pieejama",
"backup_deletion_failed": "Neizdevās dzēst rezerves kopiju",
"max_number_of_artifacts_reached": "Sasniegts maksimālais rezerves kopiju skaits šai spēlei",
"achievements_not_sync": "Jūsu sasniegumi nav sinhronizēti",
"manage_files_description": "Pārvaldiet failus, kas tiks saglabāti un atjaunoti",
"select_folder": "Izvēlēties mapi",
"backup_from": "Rezerves kopija no {{date}}",
"automatic_backup_from": "Automātiska rezerves kopija no {{date}}",
"enable_automatic_cloud_sync": "Iespējot automātisku sinhronizāciju mākonī",
"custom_backup_location_set": "Iestatīta pielāgota rezerves kopēšanas vieta",
"no_directory_selected": "Nav izvēlēts katalogs",
"no_write_permission": "Nevar augšupielādēt šajā direktorijā. Noklikšķiniet šeit, lai uzzinātu vairāk.",
"reset_achievements": "Atiestatīt sasniegumus",
"reset_achievements_description": "Tas atiestatīs visus sasniegumus {{game}} spēlei",
"reset_achievements_title": "Vai esat pārliecināts?",
"reset_achievements_success": "Sasniegumi veiksmīgi atiestatīti",
"reset_achievements_error": "Neizdevās atiestatīt sasniegumus",
"download_error_gofile_quota_exceeded": "Jūs pārsniedzāt Gofile mēneša kvotu. Lūdzu, uzgaidiet, kamēr kvota tiks atjaunota.",
"download_error_real_debrid_account_not_authorized": "Jūsu Real-Debrid konts nav autorizēts jaunām lejupielādēm. Lūdzu, pārbaudiet konta iestatījumus un mēģiniet vēlreiz.",
"download_error_not_cached_on_real_debrid": "Šī lejupielāde nav pieejama Real-Debrid, un Real-Debrid lejupielādes statusu pagaidām nav iespējams iegūt.",
"update_playtime_title": "Atjaunināt spēles laiku",
"update_playtime_description": "Manuāli atjauniniet spēles laiku {{game}} spēlei",
"update_playtime": "Atjaunināt spēles laiku",
"update_playtime_success": "Spēles laiks veiksmīgi atjaunināts",
"update_playtime_error": "Neizdevās atjaunināt spēles laiku",
"update_game_playtime": "Atjaunināt spēles laiku",
"manual_playtime_warning": "Jūsu stundas tiks atzīmētas kā manuāli atjauninātas. Šo darbību nevar atcelt.",
"manual_playtime_tooltip": "Šis spēles laiks tika atjaunināts manuāli",
"download_error_not_cached_on_torbox": "Šī lejupielāde nav pieejama TorBox, un TorBox lejupielādes statusu pagaidām nav iespējams iegūt.",
"download_error_not_cached_on_hydra": "Šī lejupielāde nav pieejama Nimbus.",
"game_removed_from_favorites": "Spēle dzēsta no izlases",
"game_added_to_favorites": "Spēle pievienota izlasei",
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
"automatically_extract_downloaded_files": "Automātiska lejupielādēto failu izpakošana",
"create_start_menu_shortcut": "Izveidot saīsni sākuma izvēlnē",
"invalid_wine_prefix_path": "Nederīgs Wine prefiksa ceļš",
"invalid_wine_prefix_path_description": "Wine prefiksa ceļš nav derīgs. Lūdzu, pārbaudiet ceļu un mēģiniet vēlreiz.",
"missing_wine_prefix": "Wine prefikss ir nepieciešams, lai izveidotu rezerves kopiju Linux vidē",
"artifact_renamed": "Rezerves kopija veiksmīgi pārsaukta",
"rename_artifact": "Pārsaukt rezerves kopiju",
"rename_artifact_description": "Pārsauciet rezerves kopiju, piešķirot tai aprakstošāku nosaukumu.",
"artifact_name_label": "Rezerves kopijas nosaukums",
"artifact_name_placeholder": "Ievadiet nosaukumu rezerves kopijai",
"save_changes": "Saglabāt izmaiņas",
"required_field": "Šis lauks ir obligāts",
"max_length_field": "Šim laukam jābūt mazāk par {{length}} simboliem",
"freeze_backup": "Piespraust, lai to nepārrakstītu automātiskās rezerves kopijas",
"unfreeze_backup": "Atspraust",
"backup_frozen": "Rezerves kopija piesprausta",
"backup_unfrozen": "Rezerves kopija atsprausta",
"backup_freeze_failed": "Neizdevās piespraust rezerves kopiju",
"backup_freeze_failed_description": "Jums jāatstāj vismaz viens brīvs slots automātiskajām rezerves kopijām",
"edit_game_modal_button": "Rediģēt spēles detaļas",
"game_details": "Spēles detaļas",
"currency_symbol": "₽",
"currency_country": "ru",
"prices": "Cenas",
"no_prices_found": "Cenas nav atrastas",
"view_all_prices": "Noklikšķiniet, lai skatītu visas cenas",
"retail_price": "Mazumtirdzniecības cena",
"keyshop_price": "Atslēgu veikala cena",
"historical_retail": "Vēsturiskās mazumtirdzniecības cenas",
"historical_keyshop": "Vēsturiskās atslēgu veikalu cenas",
"language": "Valoda",
"caption": "Subtitri",
"audio": "Audio",
"filter_by_source": "Filtrēt pēc avota",
"no_repacks_found": "Avoti šai spēlei nav atrasti"
},
"activation": {
"title": "Aktivizēt Hydra",
"installation_id": "Instalācijas ID:",
"enter_activation_code": "Ievadiet savu aktivizācijas kodu",
"message": "Ja nezināt, kur to pieprasīt, jums to nevajadzētu būt.",
"activate": "Aktivizēt",
"loading": "Ielādēšana…"
},
"downloads": {
"resume": "Atsākt",
"pause": "Apturēt",
"eta": "Beigsies {{eta}}",
"paused": "Apturēts",
"verifying": "Pārbauda…",
"completed": "Pabeigts",
"removed": "Nav lejupielādēts",
"cancel": "Atcelt",
"filter": "Meklēt lejupielādētās spēles",
"remove": "Dzēst",
"downloading_metadata": "Lejupielādē metadatus…",
"deleting": "Dzēš instalētāju…",
"delete": "Dzēst instalētāju",
"delete_modal_title": "Vai esat pārliecināts?",
"delete_modal_description": "Tas dzēsīs visus instalētājus no jūsu datora",
"install": "Instalēt",
"download_in_progress": "Procesā",
"queued_downloads": "Lejupielādes rindā",
"downloads_completed": "Pabeigts",
"queued": "Rindā",
"no_downloads_title": "Šeit ir tik tukšs...",
"no_downloads_description": "Jūs vēl neko neesat lejupielādējis, izmantojot Hydra, bet nekad nav par vēlu sākt.",
"checking_files": "Pārbauda failus…",
"seeding": "Sēdēšana",
"stop_seeding": "Apturēt sēdēšanu",
"resume_seeding": "Turpināt sēdēšanu",
"options": "Pārvaldīt",
"extract": "Izpakot failus",
"extracting": "Izpako failus…"
},
"settings": {
"downloads_path": "Lejupielāžu ceļš",
"change": "Mainīt",
"notifications": "Paziņojumi",
"enable_download_notifications": "Pēc lejupielādes pabeigšanas",
"enable_repack_list_notifications": "Pievienojot jaunu repaku",
"real_debrid_api_token_label": "Real-Debrid API-atslēga",
"quit_app_instead_hiding": "Aizvērt lietotni, nevis minimizēt uz paplātes",
"launch_with_system": "Palaist Hydra kopā ar sistēmu",
"general": "Vispārīgi",
"behavior": "Uzvedība",
"download_sources": "Lejupielādes avoti",
"language": "Valoda",
"api_token": "API atslēga",
"enable_real_debrid": "Iespējot Real-Debrid",
"real_debrid_description": "Real-Debrid ir neierobežots lejupielādētājs, kas ļauj ātri lejupielādēt failus, kas izvietoti internetā, vai uzreiz pārsūtīt tos uz atskaņotāju, izmantojot privātu tīklu, kas ļauj apiet jebkādus bloķējumus.",
"debrid_invalid_token": "Nederīga API atslēga",
"debrid_api_token_hint": "API atslēgu var iegūt <0>šeit</0>",
"real_debrid_free_account_error": "Kontam \"{{username}}\" nav abonementa. Lūdzu, iegādājieties Real-Debrid abonementu",
"debrid_linked_message": "Piesaistīts konts \"{{username}}\"",
"save_changes": "Saglabāt izmaiņas",
"changes_saved": "Izmaiņas veiksmīgi saglabātas",
"download_sources_description": "Hydra saņems lejupielādes saites no šiem avotiem. URL jāietver tieša saite uz .json failu ar lejupielādes saitēm.",
"validate_download_source": "Pārbaudīt",
"remove_download_source": "Dzēst",
"add_download_source": "Pievienot avotu",
"download_count_zero": "Sarakstā nav lejupielāžu",
"download_count_one": "{{countFormatted}} lejupielāde sarakstā",
"download_count_other": "{{countFormatted}} lejupielādes sarakstā",
"download_source_url": "Saite uz avotu",
"add_download_source_description": "Ievietojiet saiti uz .json failu",
"download_source_up_to_date": "Atjaunināts",
"download_source_errored": "Kļūda",
"sync_download_sources": "Atjaunināt avotus",
"removed_download_source": "Avots dzēsts",
"removed_download_sources": "Avoti dzēsti",
"cancel_button_confirmation_delete_all_sources": "Nē",
"confirm_button_confirmation_delete_all_sources": "Jā, dzēst visus",
"title_confirmation_delete_all_sources": "Dzēst visus avotus",
"description_confirmation_delete_all_sources": "Jūs dzēsīsiet visus avotus",
"button_delete_all_sources": "Dzēst visus avotus",
"added_download_source": "Avots pievienots",
"download_sources_synced": "Visi avoti atjaunināti",
"insert_valid_json_url": "Ievietojiet derīgu JSON faila URL",
"found_download_option_zero": "Nav atrasts lejupielādes variantu",
"found_download_option_one": "Atrasts {{countFormatted}} lejupielādes variants",
"found_download_option_other": "Atrasti {{countFormatted}} lejupielādes varianti",
"import": "Importēt",
"importing": "Importē...",
"public": "Publisks",
"private": "Privāts",
"friends_only": "Tikai draugiem",
"privacy": "Konfidencialitāte",
"profile_visibility": "Profila redzamība",
"profile_visibility_description": "Izvēlieties, kurš var redzēt jūsu profilu un bibliotēku",
"required_field": "Šis lauks ir obligāts",
"source_already_exists": "Šis avots jau ir pievienots",
"must_be_valid_url": "Avotam jābūt pareizam URL",
"blocked_users": "Bloķētie lietotāji",
"user_unblocked": "Lietotājs atbloķēts",
"enable_achievement_notifications": "Kad sasniegums ir atbloķēts",
"launch_minimized": "Palaist Hydra minimizētā veidā",
"disable_nsfw_alert": "Atspējot brīdinājumu par neķītru saturu",
"seed_after_download_complete": "Sēdēt pēc lejupielādes pabeigšanas",
"show_hidden_achievement_description": "Rādīt slēpto sasniegumu aprakstu pirms to iegūšanas",
"account": "Konts",
"no_users_blocked": "Jums nav bloķētu lietotāju",
"subscription_active_until": "Jūsu Hydra Cloud abonements ir aktīvs līdz {{date}}",
"manage_subscription": "Pārvaldīt abonementu",
"update_email": "Atjaunināt e-pastu",
"update_password": "Atjaunināt paroli",
"current_email": "Pašreizējais e-pasts:",
"no_email_account": "Jūs vēl neesat iestatījis e-pastu",
"account_data_updated_successfully": "Konta dati veiksmīgi atjaunināti",
"renew_subscription": "Atjaunot Hydra Cloud abonementu",
"subscription_expired_at": "Jūsu abonementa termiņš beidzās {{date}}",
"no_subscription": "Izbaudiet Hydra pilnībā",
"become_subscriber": "Kļūstiet par Hydra Cloud īpašnieku",
"subscription_renew_cancelled": "Automātiskā atjaunošana atspējota",
"subscription_renews_on": "Jūsu abonements tiek atjaunots {{date}}",
"bill_sent_until": "Jūsu nākamais rēķins tiks nosūtīts līdz šai dienai",
"no_themes": "Šķiet, ka jums vēl nav tēmu, bet neuztraucieties, noklikšķiniet šeit, lai izveidotu savu pirmo šedevru",
"editor_tab_code": "Kods",
"editor_tab_info": "Informācija",
"editor_tab_save": "Saglabāt",
"web_store": "Tīmekļa veikals",
"clear_themes": "Notīrīt",
"create_theme": "Izveidot",
"create_theme_modal_title": "Izveidot pielāgotu tēmu",
"create_theme_modal_description": "Izveidot jaunu tēmu, lai pielāgotu Hydra izskatu",
"theme_name": "Nosaukums",
"insert_theme_name": "Ievietot tēmas nosaukumu",
"set_theme": "Iestatīt tēmu",
"unset_theme": "Noņemt tēmu",
"delete_theme": "Dzēst tēmu",
"edit_theme": "Rediģēt tēmu",
"delete_all_themes": "Dzēst visas tēmas",
"delete_all_themes_description": "Tas dzēsīs visas jūsu pielāgotās tēmas",
"delete_theme_description": "Tas dzēsīs tēmu {{theme}}",
"cancel": "Atcelt",
"appearance": "Izskats",
"debrid": "Debrid",
"debrid_description": "Debrid servisi ir premium lejupielādētāji bez ierobežojumiem, kas ļauj ātri lejupielādēt failus no dažādiem failu apmaiņas servisiem, ierobežojoties tikai ar jūsu interneta ātrumu.",
"enable_torbox": "Iespējot TorBox",
"torbox_description": "TorBox ir jūsu premium serviss, kas konkurē pat ar labākajiem serveriem tirgū.",
"torbox_account_linked": "TorBox konts piesaistīts",
"create_real_debrid_account": "Noklikšķiniet šeit, ja jums vēl nav Real-Debrid konta",
"create_torbox_account": "Noklikšķiniet šeit, ja jums vēl nav TorBox konta",
"real_debrid_account_linked": "Real-Debrid konts piesaistīts",
"name_min_length": "Tēmas nosaukumam jābūt vismaz 3 simbolus garam",
"import_theme": "Importēt tēmu",
"import_theme_description": "Jūs importēsiet {{theme}} no tēmu veikala",
"error_importing_theme": "Kļūda importējot tēmu",
"theme_imported": "Tēma veiksmīgi importēta",
"enable_friend_request_notifications": "Saņemot draudzības pieprasījumu",
"enable_auto_install": "Automātiski lejupielādēt atjauninājumus",
"common_redist": "Bibliotēkas",
"common_redist_description": "Dažu spēļu palaišanai ir nepieciešamas bibliotēkas. Lai izvairītos no problēmām, ieteicams tās instalēt.",
"install_common_redist": "Instalēt",
"installing_common_redist": "Instalēšana…",
"show_download_speed_in_megabytes": "Rādīt lejupielādes ātrumu megabaitos sekundē",
"extract_files_by_default": "Izpakot failus pēc noklusējuma pēc lejupielādes",
"enable_steam_achievements": "Iespējot Steam sasniegumu meklēšanu",
"achievement_custom_notification_position": "Sasniegumu paziņojumu pozīcija",
"top-left": "Augšējais kreisais stūris",
"top-center": "Augšējais centrs",
"top-right": "Augšējais labais stūris",
"bottom-left": "Apakšējais kreisais stūris",
"bottom-center": "Apakšējais centrs",
"bottom-right": "Apakšējais labais stūris",
"enable_achievement_custom_notifications": "Iespējot sasniegumu paziņojumus",
"alignment": "Izlīdzināšana",
"variation": "Variācija",
"default": "Pēc noklusējuma",
"rare": "Retais",
"platinum": "Platīna",
"hidden": "Slēpts",
"test_notification": "Testa paziņojums",
"notification_preview": "Sasnieguma paziņojuma priekšskatījums",
"enable_friend_start_game_notifications": "Kad draugs sāk spēlēt spēli"
},
"notifications": {
"download_complete": "Lejupielāde pabeigta",
"game_ready_to_install": "{{title}} ir gatava instalēšanai",
"repack_list_updated": "Repaku saraksts atjaunināts",
"repack_count_one": "{{count}} repaks pievienots",
"repack_count_other": "{{count}} repaki pievienoti",
"new_update_available": "Pieejama jauna versija {{version}}",
"restart_to_install_update": "Pārstartējiet Hydra, lai instalētu atjauninājumu",
"notification_achievement_unlocked_title": "Sasniegums atbloķēts spēlei {{game}}",
"notification_achievement_unlocked_body": "tika atbloķēti {{achievement}} un citi {{count}}",
"new_friend_request_description": "{{displayName}} nosūtīja jums draudzības pieprasījumu",
"new_friend_request_title": "Jauns draudzības pieprasījums",
"extraction_complete": "Izpakošana pabeigta",
"game_extracted": "{{title}} veiksmīgi izpakots",
"friend_started_playing_game": "{{displayName}} sāka spēlēt spēli",
"test_achievement_notification_title": "Šis ir testa paziņojums",
"test_achievement_notification_description": "Diezgan forši, vai ne?"
},
"system_tray": {
"open": "Atvērt Hydra",
"quit": "Iziet"
},
"game_card": {
"available_one": "Pieejams",
"available_other": "Pieejams",
"no_downloads": "Nav pieejamu avotu",
"calculating": "Aprēķina"
},
"binary_not_found_modal": {
"title": "Programmas nav instalētas",
"description": "Wine vai Lutris nav atrasti",
"instructions": "Uzziniet pareizo veidu, kā instalēt kādu no tiem jūsu Linux distribūcijā, lai spēle varētu normāli darboties"
},
"modal": {
"close": "Aizvērt"
},
"forms": {
"toggle_password_visibility": "Rādīt paroli"
},
"user_profile": {
"amount_hours": "{{amount}} stundas",
"amount_minutes": "{{amount}} minūtes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Pēdējā spēle {{period}}",
"activity": "Nesenā aktivitāte",
"library": "Bibliotēka",
"pinned": "Piespraustās",
"achievements_earned": "Nopelnītie sasniegumi",
"played_recently": "Nesen spēlētās",
"playtime": "Spēles laiks",
"total_play_time": "Kopējais spēles laiks",
"manual_playtime_tooltip": "Spēles laiks tika atjaunināts manuāli",
"no_recent_activity_title": "Hmmmm... Šeit nav nekā",
"no_recent_activity_description": "Jūs sen neesat neko spēlējis. Ir laiks to mainīt!",
"display_name": "Parādāmais vārds",
"saving": "Saglabāšana",
"save": "Saglabāt",
"edit_profile": "Rediģēt profilu",
"saved_successfully": "Veiksmīgi saglabāts",
"try_again": "Lūdzu, mēģiniet vēlreiz",
"sign_out_modal_title": "Vai esat pārliecināts?",
"cancel": "Atcelt",
"successfully_signed_out": "Veiksmīga izrakstīšanās no konta",
"sign_out": "Iziet",
"playing_for": "Spēlēts {{amount}}",
"sign_out_modal_text": "Jūsu bibliotēka ir saistīta ar pašreizējo kontu. Izejot no sistēmas, jūsu bibliotēka kļūs nepieejama, un progress netiks saglabāts. Iziet?",
"add_friends": "Pievienot draugus",
"add": "Pievienot",
"friend_code": "Drauga kods",
"see_profile": "Skatīt profilu",
"sending": "Sūtīšana",
"friend_request_sent": "Draudzības pieprasījums nosūtīts",
"friends": "Draugi",
"friends_list": "Draugu saraksts",
"user_not_found": "Lietotājs nav atrasts",
"block_user": "Bloķēt lietotāju",
"add_friend": "Pievienot draugu",
"request_sent": "Pieprasījums nosūtīts",
"request_received": "Pieprasījums saņemts",
"accept_request": "Pieņemt pieprasījumu",
"ignore_request": "Ignorēt pieprasījumu",
"cancel_request": "Atcelt pieprasījumu",
"undo_friendship": "Dzēst draugu",
"request_accepted": "Pieprasījums pieņemts",
"user_blocked_successfully": "Lietotājs veiksmīgi bloķēts",
"user_block_modal_text": "{{displayName}} tiks bloķēts",
"blocked_users": "Bloķētie lietotāji",
"unblock": "Atbloķēt",
"no_friends_added": "Jūs vēl neesat pievienojis nevienu draugu",
"pending": "Gaida",
"no_pending_invites": "Jums nav pieprasījumu, kas gaida atbildi",
"no_blocked_users": "Jūs neesat bloķējis nevienu lietotāju",
"friend_code_copied": "Drauga kods kopēts",
"undo_friendship_modal_text": "Tas atcels jūsu draudzību ar {{displayName}}.",
"privacy_hint": "Lai norādītu, kurš to var redzēt, dodieties uz <0>Iestatījumiem</0>.",
"locked_profile": "Šis profils ir privāts",
"image_process_failure": "Attēlu apstrādes kļūme",
"required_field": "Šis lauks ir obligāts",
"displayname_min_length": "Parādāmam vārdam jābūt vismaz 3 simbolus garam.",
"displayname_max_length": "Parādāmam vārdam jābūt ne vairāk kā 50 simboliem.",
"report_profile": "Ziņot par šo profilu",
"report_reason": "Kāpēc jūs ziņojat par šo profilu?",
"report_description": "Papildu informācija",
"report_description_placeholder": "Papildu informācija",
"report": "Ziņot",
"report_reason_hate": "Naida runa",
"report_reason_sexual_content": "Seksuāls saturs",
"report_reason_violence": "Vardarbība",
"report_reason_spam": "Surogātpasts",
"report_reason_other": "Cits",
"profile_reported": "Ziņojums par profilu nosūtīts",
"your_friend_code": "Jūsu drauga kods:",
"upload_banner": "Augšupielādēt reklāmkarogu",
"uploading_banner": "Augšupielādē reklāmkarogu...",
"background_image_updated": "Fona attēls atjaunināts",
"stats": "Statistika",
"achievements": "Sasniegumi",
"games": "Spēles",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "Reitings tiek atjaunināts katru nedēļu",
"playing": "Spēlē {{game}}",
"achievements_unlocked": "Sasniegumi atbloķēti",
"earned_points": "Nopelnītie punkti:",
"show_achievements_on_profile": "Rādīt savus sasniegumus profilā",
"show_points_on_profile": "Rādīt nopelnītos punktus savā profilā",
"error_adding_friend": "Neizdevās nosūtīt draudzības pieprasījumu. Lūdzu, pārbaudiet drauga kodu",
"friend_code_length_error": "Drauga kodam jāsatur 8 simboli",
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
},
"achievement": {
"achievement_unlocked": "Sasniegums atbloķēts",
"user_achievements": "{{displayName}} sasniegumi",
"your_achievements": "Jūsu sasniegumi",
"unlocked_at": "Atbloķēts: {{date}}",
"subscription_needed": "Šī satura apskatīšanai nepieciešams Hydra Cloud abonements",
"new_achievements_unlocked": "Atbloķēti {{achievementCount}} jauni sasniegumi no {{gameCount}} spēlēm",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} sasniegumi",
"achievements_unlocked_for_game": "Atbloķēti {{achievementCount}} jauni sasniegumi spēlei {{gameTitle}}",
"hidden_achievement_tooltip": "Šis ir slēpts sasniegums",
"achievement_earn_points": "Nopelniet {{points}} punktus ar šo sasniegumu",
"earned_points": "Nopelnītie punkti:",
"available_points": "Pieejamie punkti:",
"how_to_earn_achievements_points": "Kā nopelnīt sasniegumu punktus?"
},
"hydra_cloud": {
"subscription_tour_title": "Hydra Cloud abonements",
"subscribe_now": "Abonējiet tūlīt",
"cloud_saving": "Saglabāšana mākonī",
"cloud_achievements": "Saglabājiet savus sasniegumus mākonī",
"animated_profile_picture": "Animētas profila bildes",
"premium_support": "Premium atbalsts",
"show_and_compare_achievements": "Rādiet un salīdziniet savus sasniegumus ar citu lietotāju sasniegumiem",
"animated_profile_banner": "Animēts profila reklāmkarogs",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Jūs tikko atklājāt Hydra Cloud funkciju!",
"learn_more": "Uzzināt vairāk",
"debrid_description": "Lejupielādējiet 4 reizes ātrāk ar Nimbus"
}
}

View File

@@ -349,7 +349,10 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"rating_count": "Avaliação"
"rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -416,6 +419,9 @@
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
"adding": "Adicionando…",
"failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.",
"download_source_already_exists": "Esta URL de fonte de download já existe.",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
@@ -423,7 +429,13 @@
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "Importando em breve",
"download_source_matched": "Sincronizada",
"download_source_matching": "Sincronizando",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
@@ -529,7 +541,9 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",

View File

@@ -180,7 +180,10 @@
"download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar"
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"review_from_blocked_user": "Avaliação de utilizador bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -252,7 +255,13 @@
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "A atualizar em breve",
"download_source_matched": "Atualizado",
"download_source_matching": "A atualizar",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, apague tudo",

View File

@@ -212,6 +212,7 @@
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
"rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -252,17 +253,6 @@
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Возможно позже",
"rating_count": "Оценка",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -360,7 +350,21 @@
"caption": "Субтитры",
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены"
"no_repacks_found": "Источники для этой игры не найдены",
"show": "Показать",
"hide": "Скрыть",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"review_from_blocked_user": "Отзыв от заблокированного пользователя"
},
"activation": {
"title": "Активировать Hydra",
@@ -427,6 +431,9 @@
"validate_download_source": "Проверить",
"remove_download_source": "Удалить",
"add_download_source": "Добавить источник",
"adding": "Добавление…",
"failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.",
"download_source_already_exists": "Этот URL источника уже существует.",
"download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке",
@@ -434,9 +441,16 @@
"add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка",
"download_source_pending_matching": "Скоро обновится",
"download_source_matched": "Обновлен",
"download_source_matching": "Обновление",
"download_source_failed": "Ошибка",
"download_source_no_information": "Информация отсутствует",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены",
"removed_all_download_sources": "Все источники удалены",
"download_sources_synced_successfully": "Все источники синхронизированы",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"title_confirmation_delete_all_sources": "Удалить все источники",
@@ -467,6 +481,7 @@
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "У вас нет заблокированных пользователей",
"subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}",
"manage_subscription": "Управлять подпиской",
@@ -540,7 +555,9 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -590,6 +607,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",

View File

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

View File

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

View File

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

View File

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

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 { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get("/profile/download-sources");
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

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 { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url?: string,
removeAll = false
removeAll = false,
downloadSourceId?: string
) => {
const params = new URLSearchParams({
all: removeAll.toString(),
});
if (url) params.set("url", url);
if (downloadSourceId) params.set("downloadSourceId", downloadSourceId);
return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
void HydraApi.delete(`/profile/download-sources?${params.toString()}`);
}
if (removeAll) {
await downloadSourcesSublevel.clear();
} else if (downloadSourceId) {
await downloadSourcesSublevel.del(downloadSourceId);
}
};
registerEvent("removeDownloadSource", removeDownloadSource);

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

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

@@ -64,14 +64,7 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/update-missing-fingerprints";
import "./download-sources/delete-download-source";
import "./download-sources/delete-all-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-list";
import "./download-sources/check-download-source-exists";
import "./repacks/get-all-repacks";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";

View File

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

View File

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

View File

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

View File

@@ -84,7 +84,7 @@ const removeGameFromLibrary = async (
await resetShopAssets(gameKey);
}
if (game?.remoteId) {
if (game.remoteId) {
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 { levelKeys } from "./keys";
export interface DownloadSource {
id: number;
name: string;
url: string;
status: number;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
import type { DownloadSource } from "@types";
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,

View File

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

View File

@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
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,12 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
ResourceCache,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
export const loadState = async () => {
await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -53,8 +50,12 @@ export const loadState = async () => {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
await HydraApi.setupApi().then(async () => {
uploadGamesBatch();
void migrateDownloadSources();
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// WSClient.connect();
});

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ export class HydraApi {
return this.userAuth.authToken !== "";
}
private static hasActiveSubscription() {
public static hasActiveSubscription() {
const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
}
@@ -106,9 +106,7 @@ export class HydraApi {
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
}
}

View File

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

View File

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

View File

@@ -79,6 +79,7 @@ export const mergeWithRemoteGames = async () => {
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
downloadSources: game.downloadSources,
});
}
})

View File

@@ -1,12 +1,16 @@
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async (
export const trackGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
return HydraApi.put(`/profile/games/${game.remoteId}`, {
if (game.shop === "custom") {
return;
}
return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});

View File

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

View File

@@ -106,7 +106,7 @@ export class PythonRPC {
"main.py"
);
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
stdio: ["inherit", "inherit"],
});

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

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

View File

@@ -7,7 +7,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_RENDERER_URL: string;
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}

View File

@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
updateMissingFingerprints: () =>
ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
deleteAllDownloadSources: () =>
ipcRenderer.invoke("deleteAllDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
checkDownloadSourceExists: (url: string) =>
ipcRenderer.invoke("checkDownloadSourceExists", url),
getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (

View File

@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
(async () => {
dispatch(setIsImportingSources(true));
try {
// Initial repacks load
await updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
// Update repacks AFTER all syncing and fingerprint updates are complete
await updateRepacks();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still update repacks even if sync fails
await updateRepacks();
} finally {
dispatch(setIsImportingSources(false));
}
})();
}, [updateRepacks, dispatch]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {

View File

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

View File

@@ -63,7 +63,7 @@ export function Header() {
};
const handleSearch = (value: string) => {
dispatch(setFilters({ title: value }));
dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");

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

View File

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

View File

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

View File

@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -211,20 +209,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
removeDownloadSource: (
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
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 "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search";

View File

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

View File

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

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

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

View File

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

View File

@@ -1,3 +1,5 @@
@use "../../scss/globals.scss";
.pagination {
display: flex;
gap: 4px;
@@ -18,4 +20,31 @@
font-size: 16px;
}
}
&__page-input {
box-sizing: border-box;
width: 40px;
min-width: 40px;
max-width: 40px;
min-height: 40px;
border-radius: 8px;
border: solid 1px globals.$border-color;
background-color: transparent;
color: globals.$muted-color;
text-align: center;
font-size: 12px;
padding: 0 6px;
outline: none;
}
&__double-chevron {
display: flex;
align-items: center;
justify-content: center;
font-size: 0; // remove whitespace node width between SVGs
}
&__double-chevron > svg + svg {
margin-left: -8px; // pull the second chevron closer
}
}

View File

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

View File

@@ -11,7 +11,6 @@
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: calc(globals.$spacing-unit * 1.5);
&__info {
display: flex;

View File

@@ -2,7 +2,7 @@
.gallery-slider {
&__container {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
padding: calc(globals.$spacing-unit * 1.5) 0;
width: 100%;
display: flex;
flex-direction: column;

View File

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

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
import "./hero.scss";
const processMediaElements = (document: Document) => {
const $images = Array.from(document.querySelectorAll("img"));
@@ -53,8 +54,6 @@ const getImageWithCustomPriority = (
};
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("game_details");
const {
@@ -152,18 +151,12 @@ export function GameDetailsContent() {
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<div className="game-details__hero">
<img
src={heroImage}
className="game-details__hero-image"
alt={game?.title}
/>
<div
className="game-details__hero-backdrop"
style={{
flex: 1,
}}
/>
<div
className="game-details__hero-logo-backdrop"
@@ -202,11 +195,13 @@ export function GameDetailsContent() {
)}
</div>
</div>
<div className="game-details__hero-panel">
<HeroPanel />
</div>
</div>
</div>
<HeroPanel />
<div className="game-details__description-container">
<div className="game-details__description-content">
<DescriptionHeader />
@@ -233,7 +228,7 @@ export function GameDetailsContent() {
</button>
)}
{game?.shop !== "custom" && shop && objectId && (
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
@@ -246,7 +241,7 @@ export function GameDetailsContent() {
)}
</div>
{game?.shop !== "custom" && <Sidebar />}
{shop !== "custom" && <Sidebar />}
</div>
</section>

View File

@@ -1,63 +1,170 @@
import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import "./game-details.scss";
import "react-loading-skeleton/dist/skeleton.css";
export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details");
return (
<div className="game-details__container">
<div className="game-details__hero">
<Skeleton className="game-details__hero-image-skeleton" />
</div>
<div className="game-details__hero-panel-skeleton">
<section className="description-header__info">
<Skeleton width={155} />
<Skeleton width={135} />
</section>
</div>
<div className="game-details__description-container">
<div className="game-details__description-content">
<div className="description-header">
<section className="description-header__info">
<Skeleton width={145} />
<Skeleton width={150} />
</section>
<div className="game-details__wrapper game-details__skeleton">
<section className="game-details__container">
<div className="game-details__hero">
<Skeleton
height={350}
style={{
borderRadius: "0px 0px 8px 8px",
position: "absolute",
width: "100%",
zIndex: 0,
}}
/>
<div className="game-details__hero-logo-backdrop">
<div className="game-details__hero-content">
<div className="game-details__game-logo" />
<div className="game-details__hero-buttons game-details__hero-buttons--right" />
</div>
<div className="game-details__hero-panel">
<div className="hero-panel__container">
<div className="hero-panel">
<div className="hero-panel__content">
<Skeleton height={16} width={150} />
<Skeleton height={16} width={120} />
</div>
<div className="hero-panel__actions" style={{ gap: "16px" }}>
<Skeleton
height={36}
width={36}
style={{ borderRadius: "6px" }}
/>
<Skeleton
height={36}
width={36}
style={{ borderRadius: "6px" }}
/>
<Skeleton
height={36}
width={100}
style={{ borderRadius: "6px" }}
/>
</div>
</div>
</div>
</div>
</div>
<div className="game-details__description-skeleton">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className="game-details__hero-image-skeleton" />
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className="game-details__hero-image-skeleton" />
<Skeleton />
</div>
<div className="game-details__description-container">
<div className="game-details__description-content">
<div className="description-header">
<section className="description-header__info">
<Skeleton height={16} width={200} />
<Skeleton height={16} width={150} />
</section>
</div>
<div style={{ marginBottom: "24px" }}>
<Skeleton
height={200}
width="100%"
style={{ borderRadius: "8px" }}
/>
</div>
<div className="game-details__description">
<Skeleton count={8} height={22} style={{ marginBottom: "8px" }} />
<Skeleton height={22} width="60%" />
</div>
<Skeleton
width={120}
height={36}
className="game-details__description-toggle"
width={100}
style={{
borderRadius: "4px",
marginTop: "24px",
alignSelf: "center",
}}
/>
<div style={{ marginTop: "48px" }} />
</div>
<aside className="content-sidebar">
<div className="sidebar-section">
<div
className="sidebar-section__button"
style={{ pointerEvents: "none" }}
>
<Skeleton height={16} width={16} />
<Skeleton height={16} width={60} />
</div>
<div className="sidebar-section__content">
<div className="stats__section">
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={80} />
</div>
<Skeleton height={14} width={40} />
</div>
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={70} />
</div>
<Skeleton height={14} width={35} />
</div>
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={60} />
</div>
<Skeleton height={14} width={30} />
</div>
</div>
</div>
</div>
<div className="sidebar-section">
<div
className="sidebar-section__button"
style={{ pointerEvents: "none" }}
>
<Skeleton height={16} width={16} />
<Skeleton height={16} width={120} />
</div>
<div className="sidebar-section__content">
<ul className="list">
{Array.from({ length: 4 }).map((_, index) => (
<li key={index}>
<div
className="list__item"
style={{ pointerEvents: "none" }}
>
<Skeleton
height={54}
width={54}
style={{ borderRadius: "4px" }}
/>
<div>
<Skeleton
height={14}
width={120}
style={{ marginBottom: "4px" }}
/>
<Skeleton height={12} width={80} />
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</aside>
</div>
<div className="content-sidebar">
<div className="requirement__button-container">
<Button className="requirement__button" theme="primary" disabled>
{t("minimum")}
</Button>
<Button className="requirement__button" theme="outline" disabled>
{t("recommended")}
</Button>
</div>
<div className="requirement__details-skeleton">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} />
))}
</div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -1,19 +1,5 @@
@use "../../scss/globals.scss";
$hero-height: 300px;
@keyframes slide-in {
0% {
transform: translateY(calc(40px + globals.$spacing-unit * 2));
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-details {
&__wrapper {
display: flex;
@@ -27,617 +13,6 @@ $hero-height: 300px;
}
}
&__review-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
&__review-form-controls {
display: flex;
gap: calc(globals.$spacing-unit * 2);
align-items: flex-end;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
&__review-message {
padding: calc(globals.$spacing-unit * 1);
border-radius: 4px;
font-size: globals.$small-font-size;
font-weight: 500;
margin-top: calc(globals.$spacing-unit * 1);
border: 1px solid;
&--success {
background: rgba(34, 197, 94, 0.1);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
&--error {
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3);
}
}
&__review-score-container {
display: flex;
align-items: center;
gap: 4px;
}
&__review-score-label {
font-size: 14px;
color: #ffffff;
font-weight: 500;
}
&__review-score-select {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
}
&--red {
border-color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
&--yellow {
border-color: #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
&--green {
border-color: #27ae60;
background-color: rgba(39, 174, 96, 0.1);
}
option {
background-color: #2a2a2a;
color: #ffffff;
}
}
&__star-rating {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
background: none;
border: none;
color: #666666;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
&--filled {
color: #ffffff;
&.game-details__review-score-select--red {
color: #e74c3c;
}
&.game-details__review-score-select--yellow {
color: #f39c12;
}
&.game-details__review-score-select--green {
color: #27ae60;
}
}
&--empty {
color: #666666;
&:hover {
color: #ffffff;
}
}
svg {
fill: currentColor;
}
}
&__reviews-sort {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.75);
min-width: 150px;
}
&__reviews-sort-label {
display: block;
font-size: globals.$body-font-size;
color: globals.$body-color;
}
&__reviews-sort-select {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
option {
background-color: globals.$dark-background-color;
color: globals.$body-color;
}
}
&__reviews-list {
margin-top: calc(globals.$spacing-unit * 3);
}
&__reviews-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__review-item {
overflow: hidden;
word-wrap: break-word;
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
display: inline-flex;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
display: inline-block;
min-width: 1ch;
overflow: hidden;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score-stars {
display: flex;
align-items: center;
gap: 2px;
}
&__review-star {
color: #666666;
transition: color 0.2s ease;
cursor: default;
&--filled {
color: #ffffff;
&.game-details__review-score--red {
color: #fca5a5;
}
&.game-details__review-score--yellow {
color: #fcd34d;
}
&.game-details__review-score--green {
color: #86efac;
}
}
&--empty {
color: #666666;
}
svg {
fill: currentColor;
}
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
@media (min-width: 1250px) {
height: 350px;
min-height: 350px;
}
}
&__hero-content {
padding: calc(globals.$spacing-unit * 1.5);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2);
}
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%);
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__hero-image {
width: 100%;
height: calc($hero-height + 72px);
min-height: calc($hero-height + 72px);
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
position: absolute;
z-index: 0;
@media (min-width: 1250px) {
object-position: center;
height: calc(350px + 72px);
min-height: calc(350px + 72px);
}
}
&__game-logo {
width: 200px;
align-self: flex-end;
@media (min-width: 768px) {
width: 250px;
}
@media (min-width: 1024px) {
width: 300px;
}
}
&__game-logo-text {
width: 200px;
align-self: flex-end;
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
text-align: left;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
@media (min-width: 768px) {
width: 250px;
font-size: 2.2rem;
}
@media (min-width: 1024px) {
width: 300px;
font-size: 2.5rem;
}
}
&__hero-image-skeleton {
height: 300px;
@media (min-width: 1250px) {
height: 350px;
}
}
&__container {
width: 100%;
height: 100%;
@@ -646,6 +21,7 @@ $hero-height: 300px;
z-index: 1;
}
// Description Section Styles
&__description-container {
display: flex;
width: 100%;
@@ -754,322 +130,51 @@ $hero-height: 300px;
}
}
&__description-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
// Skeleton-specific styles
&__skeleton {
.react-loading-skeleton {
background: linear-gradient(90deg, #1c1c1c 25%, #2a2a2a 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 80%;
// Ensure skeleton elements maintain proper spacing
.description-header {
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
@media (min-width: 1280px) {
width: 60%;
line-height: 22px;
.content-sidebar {
min-width: 300px;
max-width: 300px;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__randomizer-button {
animation: slide-in 0.2s;
position: fixed;
bottom: calc(globals.$spacing-unit * 3);
right: calc(9px + globals.$spacing-unit * 2);
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 10px 1px;
border: solid 2px globals.$border-color;
z-index: 1;
background-color: globals.$background-color;
&:hover {
background-color: globals.$background-color;
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 15px 5px;
opacity: 1;
}
&:active {
transform: scale(0.98);
}
&:disabled {
box-shadow: none;
transform: none;
opacity: 0.8;
background-color: globals.$background-color;
}
}
&__hero-panel-skeleton {
width: 100%;
padding: calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
height: 72px;
border-bottom: solid 1px globals.$border-color;
}
&__cloud-sync-button {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
font-size: globals.$small-font-size;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
&__stars-icon-container {
width: 16px;
height: 16px;
position: relative;
}
&__stars-icon {
width: 70px;
position: absolute;
top: -28px;
left: -27px;
}
&__cloud-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__cloud-icon {
width: 26px;
position: absolute;
top: -3px;
}
&__hero-backdrop {
flex: 1;
transition: opacity 0.2s ease;
}
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
// Hero panel skeleton spacing
.hero-panel__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__leave-review-cta {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
padding: calc(globals.$spacing-unit * 0.75)
calc(globals.$spacing-unit * 1.5);
background: linear-gradient(
135deg,
globals.$brand-teal,
globals.$brand-blue
);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: calc(globals.$spacing-unit);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
gap: calc(globals.$spacing-unit * 0.5);
}
&:active {
transform: translateY(0);
// Review items skeleton spacing
.review-item-skeleton {
border: 1px solid globals.$border-color;
border-radius: 8px;
padding: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1);
}
svg {
flex-shrink: 0;
}
}
&__review-input-container {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background-color: globals.$dark-background-color;
overflow: hidden;
}
&__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: globals.$background-color;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
}
&__editor-button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background-color: globals.$brand-blue;
border-color: globals.$brand-blue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__review-char-counter {
font-size: 12px;
color: #888888;
.over-limit {
color: #ff6b6b;
}
}
&__review-input {
min-height: 120px;
padding: 12px;
cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
min-height: 96px; // 120px - 24px padding
width: 100%;
cursor: text;
&:focus {
outline: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
// Sidebar section spacing
.sidebar-section-skeleton {
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
}
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -25,6 +25,7 @@ import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
import "./game-details.scss";
import "./hero.scss";
export default function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@@ -102,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,

View File

@@ -0,0 +1,116 @@
@use "../../scss/globals.scss";
.game-details {
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__reviews-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
}

View File

@@ -9,6 +9,7 @@ import { ReviewForm } from "./review-form";
import { ReviewItem } from "./review-item";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import "./game-reviews.scss";
import { useToast } from "@renderer/hooks";
type ReviewSortOption =
@@ -116,7 +117,7 @@ export function GameReviews({
});
const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return;
if (!objectId || !userDetailsId || shop === "custom") return;
try {
const response = await window.electron.hydraApi.get<{
@@ -144,11 +145,9 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
console.log("reviews", reviews);
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
if (!objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -164,7 +163,6 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
language: i18n.language,
});
const response = await window.electron.hydraApi.get(
@@ -440,8 +438,6 @@ export function GameReviews({
});
}, [reviews]);
console.log("reviews", reviews);
return (
<div className="game-details__reviews-section">
{showReviewPrompt &&
@@ -469,84 +465,82 @@ export function GameReviews({
</>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
{reviews.map((review) => (
<ReviewItem
key={review.id}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
previousVotesRef.current.get(review.id) || {
upvotes: 0,
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
/>
))}
</div>
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
<ReviewSortOptions
sortBy={reviewsSortBy}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
{reviews.map((review) => (
<ReviewItem
key={review.id}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
previousVotesRef.current.get(review.id) || {
upvotes: 0,
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
/>
))}
</div>
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,274 @@
@use "../../scss/globals.scss";
$hero-height: 350px;
@keyframes slide-in {
0% {
transform: translateY(calc(40px + globals.$spacing-unit * 2));
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-details {
&__hero-panel {
padding: globals.$spacing-unit;
}
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
@media (min-width: 1250px) {
height: 350px;
min-height: 350px;
}
}
&__hero-content {
padding: calc(globals.$spacing-unit * 1.5);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2);
}
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__hero-image-wrapper {
position: absolute;
width: 100%;
height: 384px;
max-height: 384px;
overflow: hidden;
border-radius: 0px 0px 8px 8px;
z-index: 0;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
z-index: 1;
pointer-events: none;
border-radius: inherit;
}
@media (min-width: 1250px) {
height: calc(350px + 82px);
min-height: calc(350px + 84px);
}
}
&__hero-image {
width: 100%;
height: $hero-height;
min-height: $hero-height;
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
position: absolute;
z-index: 0;
border-radius: 0px 0px 8px 8px;
@media (min-width: 1250px) {
object-position: center;
height: $hero-height;
min-height: $hero-height;
}
}
&__game-logo {
width: 200px;
align-self: flex-end;
object-fit: contain;
object-position: left bottom;
@media (min-width: 768px) {
width: 250px;
}
@media (min-width: 1024px) {
width: 300px;
max-height: 150px;
}
}
&__game-logo-text {
width: 200px;
align-self: flex-end;
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
text-align: left;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
@media (min-width: 768px) {
width: 250px;
font-size: 2.2rem;
}
@media (min-width: 1024px) {
width: 300px;
font-size: 2.5rem;
}
}
&__cloud-sync-button {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
font-size: globals.$small-font-size;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
&__cloud-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__cloud-icon {
width: 26px;
position: absolute;
top: -3px;
}
&__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__stars-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__stars-icon {
width: 26px;
position: absolute;
top: -3px;
}
}

View File

@@ -18,6 +18,7 @@
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 8px;
&--stuck {
background: rgba(0, 0, 0, 0.7);
@@ -29,7 +30,18 @@
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 0.5);
p {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: 400;
margin: 0;
&:first-child {
font-weight: 600;
}
}
}
&__actions {

View File

@@ -50,25 +50,29 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />
</div>
<div className="hero-panel__container">
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />
</div>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.progress : game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading
? lastPacket?.progress
: game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,232 @@
@use "../../scss/globals.scss";
.game-details {
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__review-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
&__review-score-container {
display: flex;
align-items: center;
gap: 4px;
}
&__review-score-select {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
}
&--red {
border-color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
&--yellow {
border-color: #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
&--green {
border-color: #27ae60;
background-color: rgba(39, 174, 96, 0.1);
}
option {
background-color: #2a2a2a;
color: #ffffff;
}
}
&__star-rating {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
background: none;
border: none;
color: #666666;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
&--filled {
color: #ffffff;
&.game-details__review-score-select--red {
color: #e74c3c;
}
&.game-details__review-score-select--yellow {
color: #f39c12;
}
&.game-details__review-score-select--green {
color: #27ae60;
}
}
&--empty {
color: #666666;
&:hover {
color: #ffffff;
}
}
svg {
fill: currentColor;
}
}
&__review-input-container {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background-color: globals.$dark-background-color;
overflow: hidden;
}
&__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: globals.$background-color;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
}
&__editor-button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background-color: globals.$brand-blue;
border-color: globals.$brand-blue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__review-char-counter {
font-size: 12px;
color: #888888;
.over-limit {
color: #ff6b6b;
}
}
&__review-input {
min-height: 120px;
padding: 12px;
cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
min-height: 96px; // 120px - 24px padding
width: 100%;
cursor: text;
&:focus {
outline: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
}
}
}

View File

@@ -2,6 +2,7 @@ import { Star } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EditorContent, Editor } from "@tiptap/react";
import { Button } from "@renderer/components";
import "./review-form.scss";
interface ReviewFormProps {
editor: Editor | null;

View File

@@ -1,6 +1,237 @@
@use "../../scss/globals.scss";
.game-details {
&__review-item {
overflow: hidden;
word-wrap: break-word;
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.45);
}
&__review-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
display: inline-flex;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
display: inline-block;
min-width: 1ch;
overflow: hidden;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score-stars {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
font-weight: 500;
}
&__review-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
&__review-star {
color: rgba(255, 255, 255, 0.7);
transition: color 0.2s ease;
cursor: default;
&--filled {
color: rgba(255, 255, 255, 0.7);
}
&--empty {
color: #666666;
}
svg {
fill: currentColor;
}
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-playtime {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0;
svg {
color: rgba(255, 255, 255, 0.6);
}
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
&__review-translation-toggle {
display: inline-flex;
align-items: center;

View File

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

View File

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

View File

@@ -38,4 +38,11 @@
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
&__actions {
display: flex;
justify-content: flex-end;
gap: globals.$spacing-unit;
margin-top: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -1,15 +1,13 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
import { useAppDispatch } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import type { DownloadSourceValidationResult } from "@types";
import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
@@ -28,7 +26,6 @@ export function AddDownloadSourceModal({
onClose,
onAddDownloadSource,
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
@@ -48,77 +45,43 @@ export function AddDownloadSourceModal({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const dispatch = useAppDispatch();
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
const onSubmit = useCallback(
async (values: FormValues) => {
const exists = await window.electron.checkDownloadSourceExists(
values.url
);
try {
await window.electron.addDownloadSource(values.url);
if (exists) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
onClose();
onAddDownloadSource();
} catch (error) {
logger.error("Failed to add download source:", error);
const errorMessage =
error instanceof Error && error.message.includes("already exists")
? t("download_source_already_exists")
: t("failed_add_download_source");
return;
}
const validationResult = await window.electron.validateDownloadSource(
values.url
);
setValidationResult(validationResult);
setUrl(values.url);
},
[setError, t]
);
setError("url", {
type: "server",
message: errorMessage,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setValue("url", "");
clearErrors();
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue("url", sourceUrl);
handleSubmit(onSubmit)();
}
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
if (validationResult) {
setIsLoading(true);
dispatch(setIsImportingSources(true));
try {
// Single call that handles: import → API sync → fingerprint
await window.electron.addDownloadSource(url);
// Close modal and update UI
onClose();
onAddDownloadSource();
} catch (error) {
console.error("Failed to add download source:", error);
setError("url", {
type: "server",
message: "Failed to import source. Please try again.",
});
} finally {
setIsLoading(false);
dispatch(setIsImportingSources(false));
}
}
};
}, [visible, clearErrors, setValue, sourceUrl]);
const handleClose = () => {
// Prevent closing while importing
if (isLoading) return;
onClose();
};
@@ -132,49 +95,32 @@ export function AddDownloadSourceModal({
clickOutsideToClose={!isLoading}
>
<div className="add-download-source-modal__container">
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
rightContent={
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
/>
<div className="add-download-source-modal__actions">
<Button
type="button"
theme="outline"
className="add-download-source-modal__validate-button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>
}
/>
{validationResult && (
<div className="add-download-source-modal__validation-result">
<div className="add-download-source-modal__validation-info">
<h4>{validationResult?.name}</h4>
<small>
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button
type="button"
onClick={handleAddDownloadSource}
onClick={handleClose}
disabled={isLoading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
{isLoading ? t("adding") : t("add_download_source")}
</Button>
</div>
)}
</form>
</div>
</Modal>
);

View File

@@ -201,7 +201,7 @@ export function SettingsAccount() {
</section>
<section className="settings-account__section">
<h3>Hydra Cloud</h3>
<h3>{t("hydra_cloud")}</h3>
<div className="settings-account__subscription-info">
{getHydraCloudSectionContent().description}
</div>

View File

@@ -27,6 +27,8 @@ export function SettingsBehavior() {
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
enableSteamAchievements: false,
autoplayGameTrailers: true,
hideToTrayOnGameStart: false,
});
const { t } = useTranslation("settings");
@@ -49,6 +51,8 @@ export function SettingsBehavior() {
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
});
}
}, [userPreferences]);
@@ -76,6 +80,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("hide_to_tray_on_game_start")}
checked={form.hideToTrayOnGameStart}
onChange={() =>
handleChange({
hideToTrayOnGameStart: !form.hideToTrayOnGameStart,
})
}
/>
{showRunAtStartup && (
<CheckboxField
label={t("launch_with_system")}
@@ -120,6 +134,14 @@ export function SettingsBehavior() {
/>
)}
<CheckboxField
label={t("autoplay_trailers_on_game_page")}
checked={form.autoplayGameTrailers}
onChange={() =>
handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers })
}
/>
<CheckboxField
label={t("disable_nsfw_alert")}
checked={form.disableNsfwAlert}

View File

@@ -1,5 +1,14 @@
@use "../../scss/globals.scss";
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.settings-download-sources {
&__list {
padding: 0;
@@ -22,6 +31,17 @@
&--syncing {
opacity: globals.$disabled-opacity;
}
&--pending {
opacity: 0.6;
}
}
&__spinner {
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
width: 12px;
height: 12px;
}
&__item-header {

View File

@@ -16,12 +16,13 @@ import {
TrashIcon,
} from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { useAppDispatch, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features";
import "./settings-download-sources.scss";
import { logger } from "@renderer/logger";
export function SettingsDownloadSources() {
const [
@@ -35,7 +36,6 @@ export function SettingsDownloadSources() {
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const [isFetchingSources, setIsFetchingSources] = useState(true);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
@@ -46,37 +46,53 @@ export function SettingsDownloadSources() {
const navigate = useNavigate();
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => {
await window.electron
.getDownloadSourcesList()
.then((sources) => {
setDownloadSources(sources);
})
.finally(() => {
setIsFetchingSources(false);
});
};
useEffect(() => {
getDownloadSources();
}, []);
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
useEffect(() => {
const hasPendingOrMatchingSource = downloadSources.some(
(source) =>
source.status === DownloadSourceStatus.PendingMatching ||
source.status === DownloadSourceStatus.Matching
);
if (!hasPendingOrMatchingSource || !downloadSources.length) {
return;
}
const intervalId = setInterval(async () => {
try {
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to fetch download sources:", error);
}
}, 5000);
return () => clearInterval(intervalId);
}, [downloadSources]);
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteDownloadSource(downloadSource.id);
await window.electron.removeDownloadSource(downloadSource.url);
await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
showSuccessToast(t("removed_download_source"));
await getDownloadSources();
updateRepacks();
} catch (error) {
logger.error("Failed to remove download source:", error);
} finally {
setIsRemovingDownloadSource(false);
}
@@ -86,53 +102,47 @@ export function SettingsDownloadSources() {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteAllDownloadSources();
await window.electron.removeDownloadSource("", true);
showSuccessToast(t("removed_download_sources"));
await getDownloadSources();
setShowConfirmationDeleteAllSourcesModal(false);
updateRepacks();
await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
showSuccessToast(t("removed_all_download_sources"));
} catch (error) {
logger.error("Failed to remove all download sources:", error);
} finally {
setIsRemovingDownloadSource(false);
setShowConfirmationDeleteAllSourcesModal(false);
}
};
const handleAddDownloadSource = async () => {
// Refresh sources list and repacks after import completes
await getDownloadSources();
// Force repacks update to ensure UI reflects new data
await updateRepacks();
showSuccessToast(t("added_download_source"));
try {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to refresh download sources:", error);
}
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
try {
// Sync local sources (check for updates)
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
// Refresh sources and repacks AFTER sync completes
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced"));
} catch (error) {
console.error("Error syncing download sources:", error);
// Still refresh the UI even if sync fails
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced_successfully"));
} finally {
setIsSyncingDownloadSources(false);
}
};
const statusTitle = {
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
[DownloadSourceStatus.Errored]: t("download_source_errored"),
[DownloadSourceStatus.PendingMatching]: t(
"download_source_pending_matching"
),
[DownloadSourceStatus.Matched]: t("download_source_matched"),
[DownloadSourceStatus.Matching]: t("download_source_matching"),
[DownloadSourceStatus.Failed]: t("download_source_failed"),
};
const handleModalClose = () => {
@@ -142,7 +152,7 @@ export function SettingsDownloadSources() {
const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
console.error("Cannot navigate: fingerprint is undefined");
logger.error("Cannot navigate: fingerprint is undefined");
return;
}
@@ -180,8 +190,7 @@ export function SettingsDownloadSources() {
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource ||
isFetchingSources
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
@@ -197,8 +206,7 @@ export function SettingsDownloadSources() {
disabled={
isRemovingDownloadSource ||
isSyncingDownloadSources ||
!downloadSources.length ||
isFetchingSources
!downloadSources.length
}
>
<TrashIcon />
@@ -209,11 +217,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={
isSyncingDownloadSources ||
isFetchingSources ||
isRemovingDownloadSource
}
disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
<PlusCircleIcon />
{t("add_download_source")}
@@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
<ul className="settings-download-sources__list">
{downloadSources.map((downloadSource) => {
const isPendingOrMatching =
downloadSource.status === DownloadSourceStatus.PendingMatching ||
downloadSource.status === DownloadSourceStatus.Matching;
return (
<li
key={downloadSource.id}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""} ${isPendingOrMatching ? "settings-download-sources__item--pending" : ""}`}
>
<div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
<div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge>
<Badge>
{isPendingOrMatching && (
<SyncIcon className="settings-download-sources__spinner" />
)}
{statusTitle[downloadSource.status]}
</Badge>
</div>
<button
@@ -244,11 +257,13 @@ export function SettingsDownloadSources() {
}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
{isPendingOrMatching
? t("download_source_no_information")
: t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div>

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