mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-03 00:33:49 +03:00
Merge branch 'feature/cloud-sync' into feature/game-achievements
# Conflicts: # src/locales/en/translation.json # src/locales/pt-BR/translation.json # src/main/events/library/add-game-to-library.ts # src/renderer/src/pages/game-details/sidebar/sidebar.css.ts # src/renderer/src/pages/game-details/sidebar/sidebar.tsx
This commit is contained in:
commit
e93088e8b9
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@ -40,8 +40,8 @@ jobs:
|
|||||||
sudo apt-get install -y libarchive-tools
|
sudo apt-get install -y libarchive-tools
|
||||||
yarn build:linux
|
yarn build:linux
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -50,8 +50,8 @@ jobs:
|
|||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
run: yarn build:win
|
run: yarn build:win
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@ -58,6 +58,22 @@ jobs:
|
|||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Build-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
dist/win-unpacked/**
|
||||||
|
dist/*-portable.exe
|
||||||
|
dist/*.zip
|
||||||
|
dist/*.dmg
|
||||||
|
dist/*.deb
|
||||||
|
dist/*.rpm
|
||||||
|
dist/*.tar.gz
|
||||||
|
dist/*.yml
|
||||||
|
dist/*.blockmap
|
||||||
|
dist/*.pacman
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
.vscode
|
.vscode/
|
||||||
node_modules
|
node_modules/
|
||||||
hydra-download-manager/
|
hydra-download-manager/
|
||||||
fastlist.exe
|
fastlist.exe
|
||||||
__pycache__
|
__pycache__
|
||||||
@ -10,3 +10,4 @@ out
|
|||||||
.env
|
.env
|
||||||
.vite
|
.vite
|
||||||
sentry.properties
|
sentry.properties
|
||||||
|
ludusavi/
|
26
README.md
26
README.md
@ -13,20 +13,20 @@
|
|||||||
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
|
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
|
||||||
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
|
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
|
||||||
|
|
||||||
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
|
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](./README.pt-BR.md)
|
||||||
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
|
[![en](https://img.shields.io/badge/lang-en-red.svg)](./README.md)
|
||||||
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
|
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](./README.ru.md)
|
||||||
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
|
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](./README.uk-UA.md)
|
||||||
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
|
[![be](https://img.shields.io/badge/lang-be-orange)](./README.be.md)
|
||||||
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
|
[![es](https://img.shields.io/badge/lang-es-red)](./README.es.md)
|
||||||
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
|
[![fr](https://img.shields.io/badge/lang-fr-blue)](./README.fr.md)
|
||||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
[![de](https://img.shields.io/badge/lang-de-black)](./README.de.md)
|
||||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
[![ita](https://img.shields.io/badge/lang-it-red)](./README.it.md)
|
||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](./README.cs.md)
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](./README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](./README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Katalog](./docs/screenshot.png)
|
![Hydra Katalog](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,7 +25,7 @@
|
|||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Katalog](./docs/screenshot.png)
|
![Hydra Katalog](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Catalogue Hydra](./docs/screenshot.png)
|
![Catalogue Hydra](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,7 +25,7 @@
|
|||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,7 +26,7 @@
|
|||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3,6 +3,7 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- ludusavi
|
||||||
- hydra-download-manager
|
- hydra-download-manager
|
||||||
- seeds
|
- seeds
|
||||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||||
|
Binary file not shown.
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "2.1.7",
|
"version": "2.1.7-preview",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
@ -23,7 +23,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "electron-vite build && electron-builder --win",
|
"build:win": "electron-vite build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
@ -72,6 +72,7 @@
|
|||||||
"react-redux": "^9.1.1",
|
"react-redux": "^9.1.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"sudo-prompt": "^9.2.1",
|
"sudo-prompt": "^9.2.1",
|
||||||
|
"tar": "^7.4.3",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"user-agents": "^1.1.193",
|
"user-agents": "^1.1.193",
|
||||||
"yaml": "^2.4.1",
|
"yaml": "^2.4.1",
|
||||||
@ -88,6 +89,7 @@
|
|||||||
"@swc/core": "^1.4.16",
|
"@swc/core": "^1.4.16",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/color": "^3.0.6",
|
"@types/color": "^3.0.6",
|
||||||
|
"@types/folder-hash": "^4.0.4",
|
||||||
"@types/jsdom": "^21.1.6",
|
"@types/jsdom": "^21.1.6",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
49
postinstall.cjs
Normal file
49
postinstall.cjs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const { default: axios } = require("axios");
|
||||||
|
const util = require("node:util");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const exec = util.promisify(require("node:child_process").exec);
|
||||||
|
|
||||||
|
const fileName = {
|
||||||
|
win32: "ludusavi-v0.25.0-win64.zip",
|
||||||
|
linux: "ludusavi-v0.25.0-linux.zip",
|
||||||
|
darwin: "ludusavi-v0.25.0-mac.zip",
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadLudusavi = async () => {
|
||||||
|
if (fs.existsSync("ludusavi")) {
|
||||||
|
console.log("Ludusavi already exists, skipping download...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fileName[process.platform];
|
||||||
|
const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v0.25.0/${file}`;
|
||||||
|
|
||||||
|
console.log(`Downloading ${file}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||||
|
|
||||||
|
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||||
|
|
||||||
|
stream.on("finish", async () => {
|
||||||
|
console.log(`Downloaded ${file}, extracting...`);
|
||||||
|
|
||||||
|
const pwd = process.cwd();
|
||||||
|
|
||||||
|
const targetPath = path.join(pwd, "ludusavi");
|
||||||
|
|
||||||
|
await exec(`npx extract-zip ${file} ${targetPath}`);
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Extracted. Renaming folder...");
|
||||||
|
|
||||||
|
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||||
|
fs.rmSync(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
downloadLudusavi();
|
@ -1,7 +1,7 @@
|
|||||||
libtorrent
|
libtorrent
|
||||||
cx_Freeze
|
cx_Freeze
|
||||||
cx_Logging; sys_platform == 'win32'
|
cx_Logging; sys_platform == 'win32'
|
||||||
lief; sys_platform == 'win32'
|
|
||||||
pywin32; sys_platform == 'win32'
|
pywin32; sys_platform == 'win32'
|
||||||
psutil
|
psutil
|
||||||
Pillow
|
Pillow
|
||||||
|
requests
|
||||||
|
@ -131,7 +131,21 @@
|
|||||||
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
||||||
"warning": "Warning:",
|
"warning": "Warning:",
|
||||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
|
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
|
||||||
"achievements": "Achievements"
|
"achievements": "Achievements {{unlockedCount}}/{{achievementsCount}}",
|
||||||
|
"cloud_save": "Cloud save",
|
||||||
|
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
|
||||||
|
"backups": "Backups",
|
||||||
|
"install_backup": "Install",
|
||||||
|
"delete_backup": "Delete",
|
||||||
|
"create_backup": "New backup",
|
||||||
|
"last_backup_date": "Last backup on {{date}}",
|
||||||
|
"no_backup_preview": "No save games were found for this title",
|
||||||
|
"restoring_backup": "Restoring backup ({{progress}} complete)…",
|
||||||
|
"uploading_backup": "Uploading backup…",
|
||||||
|
"no_backups": "You haven't created any backups for this game yet",
|
||||||
|
"backup_uploaded": "Backup uploaded",
|
||||||
|
"backup_deleted": "Backup deleted",
|
||||||
|
"backup_restored": "Backup restored"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activate Hydra",
|
"title": "Activate Hydra",
|
||||||
|
@ -127,7 +127,21 @@
|
|||||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||||
"warning": "Aviso:",
|
"warning": "Aviso:",
|
||||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||||
"achievements": "Conquistas"
|
"achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}",
|
||||||
|
"cloud_save": "Salvamento em nuvem",
|
||||||
|
"cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
|
||||||
|
"backups": "Backups",
|
||||||
|
"install_backup": "Restaurar",
|
||||||
|
"delete_backup": "Apagar",
|
||||||
|
"create_backup": "Novo backup",
|
||||||
|
"last_backup_date": "Último backup em {{date}}",
|
||||||
|
"no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo",
|
||||||
|
"restoring_backup": "Restaurando backup ({{progress}} concluído)…",
|
||||||
|
"uploading_backup": "Criando backup…",
|
||||||
|
"no_backups": "Você ainda não fez nenhum backup deste jogo",
|
||||||
|
"backup_uploaded": "Backup criado",
|
||||||
|
"backup_deleted": "Backup apagado",
|
||||||
|
"backup_restored": "Backup restaurado"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
@ -169,7 +183,7 @@
|
|||||||
"enable_download_notifications": "Quando um download for concluído",
|
"enable_download_notifications": "Quando um download for concluído",
|
||||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
"behavior": "Comportamento",
|
"behavior": "Comportamento",
|
||||||
|
@ -116,7 +116,7 @@
|
|||||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||||
"warning": "Aviso:",
|
"warning": "Aviso:",
|
||||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||||
"achievements": "Conquistas"
|
"achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
@ -158,7 +158,7 @@
|
|||||||
"enable_download_notifications": "Quando uma transferência for concluída",
|
"enable_download_notifications": "Quando uma transferência for concluída",
|
||||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||||
"launch_with_system": "Iniciar o Hydra com o sistema",
|
"launch_with_system": "Iniciar o Hydra com o sistema",
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
"behavior": "Comportamento",
|
"behavior": "Comportamento",
|
||||||
|
@ -17,4 +17,6 @@ export const seedsPath = app.isPackaged
|
|||||||
? path.join(process.resourcesPath, "seeds")
|
? path.join(process.resourcesPath, "seeds")
|
||||||
: path.join(__dirname, "..", "..", "seeds");
|
: path.join(__dirname, "..", "..", "seeds");
|
||||||
|
|
||||||
|
export const backupsPath = path.join(app.getPath("userData"), "Backups");
|
||||||
|
|
||||||
export const appVersion = app.getVersion();
|
export const appVersion = app.getVersion();
|
||||||
|
@ -18,6 +18,9 @@ export class GameShopCache {
|
|||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
serializedData: string;
|
serializedData: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use IndexedDB's `howLongToBeatEntries` instead
|
||||||
|
*/
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
howLongToBeatSerializedData: string;
|
howLongToBeatSerializedData: string;
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ const getCatalogue = async (
|
|||||||
title: steamGame.name,
|
title: steamGame.name,
|
||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
cover: steamUrlBuilder.library(game.objectId),
|
cover: steamUrlBuilder.library(game.objectId),
|
||||||
objectID: game.objectId,
|
objectId: game.objectId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
|
|||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
|
||||||
const getLocalizedSteamAppDetails = async (
|
const getLocalizedSteamAppDetails = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
language: string
|
language: string
|
||||||
): Promise<ShopDetails | null> => {
|
): Promise<ShopDetails | null> => {
|
||||||
if (language === "english") {
|
if (language === "english") {
|
||||||
return getSteamAppDetails(objectID, language);
|
return getSteamAppDetails(objectId, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSteamAppDetails(objectID, language).then(
|
return getSteamAppDetails(objectId, language).then(
|
||||||
async (localizedAppDetails) => {
|
async (localizedAppDetails) => {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,21 +34,21 @@ const getLocalizedSteamAppDetails = async (
|
|||||||
|
|
||||||
const getGameShopDetails = async (
|
const getGameShopDetails = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
objectId: string,
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
language: string
|
language: string
|
||||||
): Promise<ShopDetails | null> => {
|
): Promise<ShopDetails | null> => {
|
||||||
if (shop === "steam") {
|
if (shop === "steam") {
|
||||||
const cachedData = await gameShopCacheRepository.findOne({
|
const cachedData = await gameShopCacheRepository.findOne({
|
||||||
where: { objectID, language },
|
where: { objectID: objectId, language },
|
||||||
});
|
});
|
||||||
|
|
||||||
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
|
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
gameShopCacheRepository.upsert(
|
gameShopCacheRepository.upsert(
|
||||||
{
|
{
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop: "steam",
|
shop: "steam",
|
||||||
language,
|
language,
|
||||||
serializedData: JSON.stringify(result),
|
serializedData: JSON.stringify(result),
|
||||||
@ -68,7 +68,7 @@ const getGameShopDetails = async (
|
|||||||
if (cachedGame) {
|
if (cachedGame) {
|
||||||
return {
|
return {
|
||||||
...cachedGame,
|
...cachedGame,
|
||||||
objectID,
|
objectId,
|
||||||
} as ShopDetails;
|
} as ShopDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,28 +1,29 @@
|
|||||||
import type { CatalogueEntry } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { HydraApi } from "@main/services";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
const getGames = async (
|
const getGames = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
take = 12,
|
take = 12,
|
||||||
cursor = 0
|
skip = 0
|
||||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
): Promise<CatalogueEntry[]> => {
|
||||||
const steamGames = await steamGamesWorker.run(
|
const searchParams = new URLSearchParams({
|
||||||
{ limit: take, offset: cursor },
|
take: take.toString(),
|
||||||
{ name: "list" }
|
skip: skip.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = await HydraApi.get<CatalogueEntry[]>(
|
||||||
|
`/games/catalogue?${searchParams.toString()}`,
|
||||||
|
undefined,
|
||||||
|
{ needsAuth: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return games.map((game) => ({
|
||||||
results: steamGames.map((steamGame) => ({
|
...game,
|
||||||
title: steamGame.name,
|
cover: steamUrlBuilder.library(game.objectId),
|
||||||
shop: "steam",
|
}));
|
||||||
cover: steamUrlBuilder.library(steamGame.id),
|
|
||||||
objectID: steamGame.id,
|
|
||||||
})),
|
|
||||||
cursor: cursor + steamGames.length,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getGames", getGames);
|
registerEvent("getGames", getGames);
|
||||||
|
@ -1,45 +1,23 @@
|
|||||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
import type { HowLongToBeatCategory } from "@types";
|
||||||
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameShopCacheRepository } from "@main/repository";
|
import { formatName } from "@shared";
|
||||||
|
|
||||||
const getHowLongToBeat = async (
|
const getHowLongToBeat = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
|
||||||
shop: GameShop,
|
|
||||||
title: string
|
title: string
|
||||||
): Promise<HowLongToBeatCategory[] | null> => {
|
): Promise<HowLongToBeatCategory[] | null> => {
|
||||||
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
|
const response = await searchHowLongToBeat(title);
|
||||||
|
|
||||||
const gameShopCache = await gameShopCacheRepository.findOne({
|
const game = response.data.find((game) => {
|
||||||
where: { objectID, shop },
|
return formatName(game.game_name) === formatName(title);
|
||||||
});
|
});
|
||||||
|
|
||||||
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
|
if (!game) return null;
|
||||||
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
|
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
||||||
: null;
|
|
||||||
if (howLongToBeatCachedData) return howLongToBeatCachedData;
|
|
||||||
|
|
||||||
return searchHowLongToBeatPromise.then(async (response) => {
|
return howLongToBeat;
|
||||||
const game = response.data.find(
|
|
||||||
(game) => game.profile_steam === Number(objectID)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!game) return null;
|
|
||||||
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
|
||||||
|
|
||||||
gameShopCacheRepository.upsert(
|
|
||||||
{
|
|
||||||
objectID,
|
|
||||||
shop,
|
|
||||||
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
|
|
||||||
},
|
|
||||||
["objectID"]
|
|
||||||
);
|
|
||||||
|
|
||||||
return howLongToBeat;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getHowLongToBeat", getHowLongToBeat);
|
registerEvent("getHowLongToBeat", getHowLongToBeat);
|
||||||
|
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { GameShop } from "@types";
|
||||||
|
import { Ludusavi } from "@main/services";
|
||||||
|
|
||||||
|
const checkGameCloudSyncSupport = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => {
|
||||||
|
const games = await Ludusavi.findGames(shop, objectId);
|
||||||
|
return games.length === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);
|
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const deleteGameArtifact = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
gameArtifactId: string
|
||||||
|
) =>
|
||||||
|
HydraApi.delete<{ ok: boolean }>(
|
||||||
|
`/profile/games/artifacts/${gameArtifactId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
registerEvent("deleteGameArtifact", deleteGameArtifact);
|
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as tar from "tar";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import axios from "axios";
|
||||||
|
import { app } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
import { backupsPath } from "@main/constants";
|
||||||
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
export interface LudusaviBackup {
|
||||||
|
files: {
|
||||||
|
[key: string]: {
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceLudusaviBackupWithCurrentUser = (
|
||||||
|
gameBackupPath: string,
|
||||||
|
backupHomeDir: string
|
||||||
|
) => {
|
||||||
|
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
|
||||||
|
|
||||||
|
const data = fs.readFileSync(mappingYamlPath, "utf8");
|
||||||
|
const manifest = YAML.parse(data) as {
|
||||||
|
backups: LudusaviBackup[];
|
||||||
|
drives: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentHomeDir = app.getPath("home");
|
||||||
|
|
||||||
|
// TODO: Only works on Windows
|
||||||
|
const usersDirPath = path.join(gameBackupPath, "drive-C", "Users");
|
||||||
|
|
||||||
|
const oldPath = path.join(usersDirPath, path.basename(backupHomeDir));
|
||||||
|
const newPath = path.join(usersDirPath, path.basename(currentHomeDir));
|
||||||
|
|
||||||
|
// Directories are different, rename
|
||||||
|
if (backupHomeDir !== currentHomeDir) {
|
||||||
|
if (fs.existsSync(newPath)) {
|
||||||
|
fs.rmSync(newPath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(oldPath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = manifest.backups.map((backup: LudusaviBackup) => {
|
||||||
|
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[key.replace(backupHomeDir, currentHomeDir)]: value,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...backup,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadGameArtifact = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
gameArtifactId: string
|
||||||
|
) => {
|
||||||
|
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
|
||||||
|
downloadUrl: string;
|
||||||
|
objectKey: string;
|
||||||
|
homeDir: string;
|
||||||
|
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
|
||||||
|
|
||||||
|
const zipLocation = path.join(app.getPath("userData"), objectKey);
|
||||||
|
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.rmSync(backupPath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(downloadUrl, {
|
||||||
|
responseType: "stream",
|
||||||
|
onDownloadProgress: (progressEvent) => {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-backup-download-progress-${objectId}-${shop}`,
|
||||||
|
progressEvent
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const writer = fs.createWriteStream(zipLocation);
|
||||||
|
|
||||||
|
response.data.pipe(writer);
|
||||||
|
|
||||||
|
writer.on("error", (err) => {
|
||||||
|
logger.error("Failed to write zip", err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(backupPath, { recursive: true });
|
||||||
|
|
||||||
|
writer.on("close", () => {
|
||||||
|
tar
|
||||||
|
.x({
|
||||||
|
file: zipLocation,
|
||||||
|
cwd: backupPath,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
const [game] = await Ludusavi.findGames(shop, objectId);
|
||||||
|
if (!game) throw new Error("Game not found in Ludusavi manifest");
|
||||||
|
|
||||||
|
replaceLudusaviBackupWithCurrentUser(
|
||||||
|
path.join(backupPath, game),
|
||||||
|
path.normalize(homeDir).replace(/\\/g, "/")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ludusavi.restoreBackup(backupPath).then(() => {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("downloadGameArtifact", downloadGameArtifact);
|
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import type { GameArtifact, GameShop } from "@types";
|
||||||
|
|
||||||
|
const getGameArtifacts = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
});
|
||||||
|
|
||||||
|
return HydraApi.get<GameArtifact[]>(
|
||||||
|
`/profile/games/artifacts?${params.toString()}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getGameArtifacts", getGameArtifacts);
|
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { GameShop } from "@types";
|
||||||
|
import { Ludusavi } from "@main/services";
|
||||||
|
import path from "node:path";
|
||||||
|
import { backupsPath } from "@main/constants";
|
||||||
|
|
||||||
|
const getGameBackupPreview = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => {
|
||||||
|
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||||
|
|
||||||
|
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getGameBackupPreview", getGameBackupPreview);
|
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as tar from "tar";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { GameShop } from "@types";
|
||||||
|
import axios from "axios";
|
||||||
|
import os from "node:os";
|
||||||
|
import { backupsPath } from "@main/constants";
|
||||||
|
import { app } from "electron";
|
||||||
|
|
||||||
|
const bundleBackup = async (shop: GameShop, objectId: string) => {
|
||||||
|
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||||
|
|
||||||
|
await Ludusavi.backupGame(shop, objectId, backupPath);
|
||||||
|
|
||||||
|
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
|
||||||
|
|
||||||
|
await tar.create(
|
||||||
|
{
|
||||||
|
gzip: false,
|
||||||
|
file: tarLocation,
|
||||||
|
cwd: backupPath,
|
||||||
|
},
|
||||||
|
["."]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tarLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSaveGame = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => {
|
||||||
|
const bundleLocation = await bundleBackup(shop, objectId);
|
||||||
|
|
||||||
|
fs.stat(bundleLocation, async (err, stat) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error("Failed to get zip file stats", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl } = await HydraApi.post<{
|
||||||
|
id: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
}>("/profile/games/artifacts", {
|
||||||
|
artifactLengthInBytes: stat.size,
|
||||||
|
shop,
|
||||||
|
objectId,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
homeDir: path.normalize(app.getPath("home")).replace(/\\/g, "/"),
|
||||||
|
platform: os.platform(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.readFile(bundleLocation, async (err, fileBuffer) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error("Failed to read zip file", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.put(uploadUrl, fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/tar",
|
||||||
|
},
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
console.log(progressEvent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-upload-complete-${objectId}-${shop}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.rm(bundleLocation, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error("Failed to remove tar file", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("uploadSaveGame", uploadSaveGame);
|
@ -12,7 +12,7 @@ export interface SearchGamesArgs {
|
|||||||
export const convertSteamGameToCatalogueEntry = (
|
export const convertSteamGameToCatalogueEntry = (
|
||||||
game: SteamGame
|
game: SteamGame
|
||||||
): CatalogueEntry => ({
|
): CatalogueEntry => ({
|
||||||
objectID: String(game.id),
|
objectId: String(game.id),
|
||||||
title: game.name,
|
title: game.name,
|
||||||
shop: "steam" as GameShop,
|
shop: "steam" as GameShop,
|
||||||
cover: steamUrlBuilder.library(String(game.id)),
|
cover: steamUrlBuilder.library(String(game.id)),
|
||||||
|
@ -57,6 +57,12 @@ import "./profile/update-profile";
|
|||||||
import "./profile/process-profile-image";
|
import "./profile/process-profile-image";
|
||||||
import "./profile/send-friend-request";
|
import "./profile/send-friend-request";
|
||||||
import "./profile/sync-friend-requests";
|
import "./profile/sync-friend-requests";
|
||||||
|
import "./cloud-save/download-game-artifact";
|
||||||
|
import "./cloud-save/get-game-artifacts";
|
||||||
|
import "./cloud-save/get-game-backup-preview";
|
||||||
|
import "./cloud-save/upload-save-game";
|
||||||
|
import "./cloud-save/check-game-cloud-sync-support";
|
||||||
|
import "./cloud-save/delete-game-artifact";
|
||||||
import "./notifications/publish-new-repacks-notification";
|
import "./notifications/publish-new-repacks-notification";
|
||||||
import { isPortableVersion } from "@main/helpers";
|
import { isPortableVersion } from "@main/helpers";
|
||||||
|
|
||||||
|
@ -11,14 +11,14 @@ import { updateLocalUnlockedAchivements } from "@main/services/achievements/upda
|
|||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
objectId: string,
|
||||||
title: string,
|
title: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => {
|
) => {
|
||||||
return gameRepository
|
return gameRepository
|
||||||
.update(
|
.update(
|
||||||
{
|
{
|
||||||
objectID,
|
objectID: objectId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shop,
|
shop,
|
||||||
@ -28,23 +28,25 @@ const addGameToLibrary = async (
|
|||||||
)
|
)
|
||||||
.then(async ({ affected }) => {
|
.then(async ({ affected }) => {
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository.insert({
|
await gameRepository.insert({
|
||||||
title,
|
title,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop,
|
shop,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const game = await gameRepository.findOne({ where: { objectID } });
|
const game = await gameRepository.findOne({
|
||||||
|
where: { objectID: objectId },
|
||||||
|
});
|
||||||
|
|
||||||
updateLocalUnlockedAchivements(game!);
|
updateLocalUnlockedAchivements(game!);
|
||||||
|
|
||||||
|
@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
|
|||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
const getGameByObjectID = async (
|
const getGameByObjectId = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string
|
objectId: string
|
||||||
) =>
|
) =>
|
||||||
gameRepository.findOne({
|
gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
registerEvent("getGameByObjectID", getGameByObjectID);
|
registerEvent("getGameByObjectId", getGameByObjectId);
|
||||||
|
@ -14,7 +14,7 @@ const startGameDownload = async (
|
|||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
payload: StartGameDownloadPayload
|
payload: StartGameDownloadPayload
|
||||||
) => {
|
) => {
|
||||||
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
|
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
|
||||||
|
|
||||||
return dataSource.transaction(async (transactionalEntityManager) => {
|
return dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
const gameRepository = transactionalEntityManager.getRepository(Game);
|
const gameRepository = transactionalEntityManager.getRepository(Game);
|
||||||
@ -23,7 +23,7 @@ const startGameDownload = async (
|
|||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop,
|
shop,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -51,18 +51,18 @@ const startGameDownload = async (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository.insert({
|
await gameRepository.insert({
|
||||||
title,
|
title,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
objectID,
|
objectID: objectId,
|
||||||
downloader,
|
downloader,
|
||||||
shop,
|
shop,
|
||||||
status: "active",
|
status: "active",
|
||||||
@ -73,7 +73,7 @@ const startGameDownload = async (
|
|||||||
|
|
||||||
const updatedGame = await gameRepository.findOne({
|
const updatedGame = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, net, protocol, session } from "electron";
|
import { app, BrowserWindow, net, protocol } from "electron";
|
||||||
import { init } from "@sentry/electron/main";
|
import { init } from "@sentry/electron/main";
|
||||||
import updater from "electron-updater";
|
import updater from "electron-updater";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@ -104,46 +104,6 @@ app.whenReady().then(async () => {
|
|||||||
WindowManager.createMainWindow();
|
WindowManager.createMainWindow();
|
||||||
WindowManager.createNotificationWindow();
|
WindowManager.createNotificationWindow();
|
||||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||||
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
|
||||||
callback({
|
|
||||||
requestHeaders: {
|
|
||||||
...details.requestHeaders,
|
|
||||||
"user-agent":
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
|
||||||
const headers = {
|
|
||||||
"access-control-allow-origin": ["*"],
|
|
||||||
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
|
||||||
"access-control-expose-headers": ["ETag"],
|
|
||||||
"access-control-allow-headers": [
|
|
||||||
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
|
||||||
],
|
|
||||||
"access-control-allow-credentials": ["true"],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (details.method === "OPTIONS") {
|
|
||||||
callback({
|
|
||||||
cancel: false,
|
|
||||||
responseHeaders: {
|
|
||||||
...details.responseHeaders,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
statusLine: "HTTP/1.1 200 OK",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
callback({
|
|
||||||
responseHeaders: {
|
|
||||||
...details.responseHeaders,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
|
@ -1,32 +1,65 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { requestWebPage } from "@main/helpers";
|
import { requestWebPage } from "@main/helpers";
|
||||||
import { HowLongToBeatCategory } from "@types";
|
import type {
|
||||||
|
HowLongToBeatCategory,
|
||||||
|
HowLongToBeatSearchResponse,
|
||||||
|
} from "@types";
|
||||||
import { formatName } from "@shared";
|
import { formatName } from "@shared";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import UserAgent from "user-agents";
|
||||||
|
|
||||||
export interface HowLongToBeatResult {
|
const state = {
|
||||||
game_id: number;
|
apiKey: null as string | null,
|
||||||
profile_steam: number;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export interface HowLongToBeatSearchResponse {
|
const getHowLongToBeatSearchApiKey = async () => {
|
||||||
data: HowLongToBeatResult[];
|
const userAgent = new UserAgent();
|
||||||
}
|
|
||||||
|
const document = await requestWebPage("https://howlongtobeat.com/");
|
||||||
|
const scripts = Array.from(document.querySelectorAll("script"));
|
||||||
|
|
||||||
|
const appScript = scripts.find((script) =>
|
||||||
|
script.src.startsWith("/_next/static/chunks/pages/_app")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!appScript) return null;
|
||||||
|
|
||||||
|
const response = await axios.get(
|
||||||
|
`https://howlongtobeat.com${appScript.src}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"User-Agent": userAgent.toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = /fetch\("\/api\/search\/"\.concat\("(.*?)"\)/gm.exec(
|
||||||
|
response.data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!results) return null;
|
||||||
|
|
||||||
|
return results[1];
|
||||||
|
};
|
||||||
|
|
||||||
export const searchHowLongToBeat = async (gameName: string) => {
|
export const searchHowLongToBeat = async (gameName: string) => {
|
||||||
|
state.apiKey = state.apiKey ?? (await getHowLongToBeatSearchApiKey());
|
||||||
|
if (!state.apiKey) return { data: [] };
|
||||||
|
|
||||||
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
const response = await axios
|
const response = await axios
|
||||||
.post(
|
.post(
|
||||||
"https://howlongtobeat.com/api/search",
|
"https://howlongtobeat.com/api/search/8fbd64723a8204dd",
|
||||||
{
|
{
|
||||||
searchType: "games",
|
searchType: "games",
|
||||||
searchTerms: formatName(gameName).split(" "),
|
searchTerms: formatName(gameName).split(" "),
|
||||||
searchPage: 1,
|
searchPage: 1,
|
||||||
size: 100,
|
size: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent": userAgent.toString(),
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
|
||||||
Referer: "https://howlongtobeat.com/",
|
Referer: "https://howlongtobeat.com/",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -8,3 +8,4 @@ export * from "./how-long-to-beat";
|
|||||||
export * from "./process-watcher";
|
export * from "./process-watcher";
|
||||||
export * from "./main-loop";
|
export * from "./main-loop";
|
||||||
export * from "./hydra-api";
|
export * from "./hydra-api";
|
||||||
|
export * from "./ludusavi";
|
||||||
|
63
src/main/services/ludusavi.ts
Normal file
63
src/main/services/ludusavi.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { GameShop, LudusaviBackup } from "@types";
|
||||||
|
import Piscina from "piscina";
|
||||||
|
|
||||||
|
import { app } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
|
||||||
|
|
||||||
|
const binaryPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
|
||||||
|
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
|
||||||
|
|
||||||
|
export class Ludusavi {
|
||||||
|
private static worker = new Piscina({
|
||||||
|
filename: ludusaviWorkerPath,
|
||||||
|
workerData: {
|
||||||
|
binaryPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
|
||||||
|
const games = await this.worker.run(
|
||||||
|
{ objectId, shop },
|
||||||
|
{ name: "findGames" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async backupGame(
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
backupPath: string
|
||||||
|
): Promise<LudusaviBackup> {
|
||||||
|
const games = await this.findGames(shop, objectId);
|
||||||
|
if (!games.length) throw new Error("Game not found");
|
||||||
|
|
||||||
|
return this.worker.run(
|
||||||
|
{ title: games[0], backupPath },
|
||||||
|
{ name: "backupGame" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getBackupPreview(
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
backupPath: string
|
||||||
|
): Promise<LudusaviBackup | null> {
|
||||||
|
const games = await this.findGames(shop, objectId);
|
||||||
|
if (!games.length) return null;
|
||||||
|
|
||||||
|
const backupData = await this.worker.run(
|
||||||
|
{ title: games[0], backupPath, preview: true },
|
||||||
|
{ name: "backupGame" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return backupData;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async restoreBackup(backupPath: string) {
|
||||||
|
return this.worker.run(backupPath, { name: "restoreBackup" });
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
|
|||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||||
import { GameRunning } from "@types";
|
import type { GameRunning } from "@types";
|
||||||
import { PythonInstance } from "./download";
|
import { PythonInstance } from "./download";
|
||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: $title.textContent,
|
title: $title.textContent,
|
||||||
objectID: steamGameUrl.split("/").pop(),
|
objectId: steamGameUrl.split("/").pop(),
|
||||||
} as Steam250Game;
|
} as Steam250Game;
|
||||||
})
|
})
|
||||||
.filter((game) => game != null);
|
.filter((game) => game != null);
|
||||||
@ -38,7 +38,7 @@ export const getSteam250List = async () => {
|
|||||||
).flat();
|
).flat();
|
||||||
|
|
||||||
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
|
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
|
||||||
if (item) map.set(item.objectID, item);
|
if (item) map.set(item.objectId, item);
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, new Map());
|
}, new Map());
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { GameShop } from "@types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface SteamGridResponse {
|
export interface SteamGridResponse {
|
||||||
@ -20,9 +21,9 @@ export interface SteamGridGameResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getSteamGridData = async (
|
export const getSteamGridData = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
path: string,
|
path: string,
|
||||||
shop: string,
|
shop: GameShop,
|
||||||
params: Record<string, string> = {}
|
params: Record<string, string> = {}
|
||||||
): Promise<SteamGridResponse> => {
|
): Promise<SteamGridResponse> => {
|
||||||
const searchParams = new URLSearchParams(params);
|
const searchParams = new URLSearchParams(params);
|
||||||
@ -32,7 +33,7 @@ export const getSteamGridData = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
|
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
||||||
@ -58,10 +59,10 @@ export const getSteamGridGameById = async (
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSteamGameClientIcon = async (objectID: string) => {
|
export const getSteamGameClientIcon = async (objectId: string) => {
|
||||||
const {
|
const {
|
||||||
data: { id: steamGridGameId },
|
data: { id: steamGridGameId },
|
||||||
} = await getSteamGridData(objectID, "games", "steam");
|
} = await getSteamGridData(objectId, "games", "steam");
|
||||||
|
|
||||||
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
||||||
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
||||||
|
@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getSteamAppDetails = async (
|
export const getSteamAppDetails = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
language: string
|
language: string
|
||||||
) => {
|
) => {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
appids: objectID,
|
appids: objectId,
|
||||||
l: language,
|
l: language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
|
|||||||
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data[objectID].success) return response.data[objectID].data;
|
if (response.data[objectId].success) return response.data[objectId].data;
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -16,6 +16,7 @@ import trayIcon from "@resources/tray-icon.png?asset";
|
|||||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||||
import { IsNull, Not } from "typeorm";
|
import { IsNull, Not } from "typeorm";
|
||||||
import { HydraApi } from "./hydra-api";
|
import { HydraApi } from "./hydra-api";
|
||||||
|
import UserAgent from "user-agents";
|
||||||
|
|
||||||
export class WindowManager {
|
export class WindowManager {
|
||||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
public static mainWindow: Electron.BrowserWindow | null = null;
|
||||||
@ -77,6 +78,54 @@ export class WindowManager {
|
|||||||
show: false,
|
show: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||||
|
(details, callback) => {
|
||||||
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
|
callback({
|
||||||
|
requestHeaders: {
|
||||||
|
...details.requestHeaders,
|
||||||
|
"user-agent": userAgent.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||||
|
(details, callback) => {
|
||||||
|
if (details.webContentsId !== this.mainWindow?.webContents.id) {
|
||||||
|
return callback(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"access-control-allow-origin": ["*"],
|
||||||
|
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
||||||
|
"access-control-expose-headers": ["ETag"],
|
||||||
|
"access-control-allow-headers": [
|
||||||
|
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (details.method === "OPTIONS") {
|
||||||
|
return callback({
|
||||||
|
cancel: false,
|
||||||
|
responseHeaders: {
|
||||||
|
...details.responseHeaders,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
statusLine: "HTTP/1.1 200 OK",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback({
|
||||||
|
responseHeaders: {
|
||||||
|
...details.responseHeaders,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.loadMainWindowURL();
|
this.loadMainWindowURL();
|
||||||
this.mainWindow.removeMenu();
|
this.mainWindow.removeMenu();
|
||||||
|
|
||||||
@ -116,11 +165,10 @@ export class WindowManager {
|
|||||||
sandbox: false,
|
sandbox: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.notificationWindow.setIgnoreMouseEvents(true);
|
this.notificationWindow.setIgnoreMouseEvents(true);
|
||||||
this.notificationWindow.setVisibleOnAllWorkspaces(true, {
|
// this.notificationWindow.setVisibleOnAllWorkspaces(true, {
|
||||||
visibleOnFullScreen: true,
|
// visibleOnFullScreen: true,
|
||||||
});
|
// });
|
||||||
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
|
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
|
||||||
this.loadNotificationWindowURL();
|
this.loadNotificationWindowURL();
|
||||||
}
|
}
|
||||||
@ -145,6 +193,8 @@ export class WindowManager {
|
|||||||
|
|
||||||
authWindow.removeMenu();
|
authWindow.removeMenu();
|
||||||
|
|
||||||
|
if (!app.isPackaged) authWindow.webContents.openDevTools();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
lng: i18next.language,
|
lng: i18next.language,
|
||||||
});
|
});
|
||||||
@ -176,7 +226,7 @@ export class WindowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static createSystemTray(language: string) {
|
public static createSystemTray(language: string) {
|
||||||
let tray;
|
let tray: Tray;
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const macIcon = nativeImage
|
const macIcon = nativeImage
|
||||||
|
61
src/main/workers/ludusavi.worker.ts
Normal file
61
src/main/workers/ludusavi.worker.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types";
|
||||||
|
import cp from "node:child_process";
|
||||||
|
|
||||||
|
import { workerData } from "node:worker_threads";
|
||||||
|
|
||||||
|
const { binaryPath } = workerData;
|
||||||
|
|
||||||
|
export const findGames = ({
|
||||||
|
shop,
|
||||||
|
objectId,
|
||||||
|
}: {
|
||||||
|
shop: GameShop;
|
||||||
|
objectId: string;
|
||||||
|
}) => {
|
||||||
|
const args = ["find", "--api"];
|
||||||
|
|
||||||
|
if (shop === "steam") {
|
||||||
|
args.push("--steam-id", objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = cp.execFileSync(binaryPath, args);
|
||||||
|
|
||||||
|
const games = JSON.parse(result.toString("utf-8")) as LudusaviFindResult;
|
||||||
|
return Object.keys(games.games);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const backupGame = ({
|
||||||
|
title,
|
||||||
|
backupPath,
|
||||||
|
preview = false,
|
||||||
|
winePrefix,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
backupPath: string;
|
||||||
|
preview?: boolean;
|
||||||
|
winePrefix?: string;
|
||||||
|
}) => {
|
||||||
|
const args = ["backup", title, "--api", "--force"];
|
||||||
|
|
||||||
|
if (preview) args.push("--preview");
|
||||||
|
if (backupPath) args.push("--path", backupPath);
|
||||||
|
if (winePrefix) args.push("--wine-prefix", winePrefix);
|
||||||
|
|
||||||
|
const result = cp.execFileSync(binaryPath, args);
|
||||||
|
|
||||||
|
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restoreBackup = (backupPath: string) => {
|
||||||
|
const result = cp.execFileSync(binaryPath, [
|
||||||
|
"restore",
|
||||||
|
"--path",
|
||||||
|
backupPath,
|
||||||
|
"--api",
|
||||||
|
"--force",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --wine-prefix
|
@ -13,6 +13,7 @@ import type {
|
|||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { CatalogueCategory } from "@shared";
|
import type { CatalogueCategory } from "@shared";
|
||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electron", {
|
contextBridge.exposeInMainWorld("electron", {
|
||||||
/* Torrenting */
|
/* Torrenting */
|
||||||
@ -37,13 +38,13 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||||
getCatalogue: (category: CatalogueCategory) =>
|
getCatalogue: (category: CatalogueCategory) =>
|
||||||
ipcRenderer.invoke("getCatalogue", category),
|
ipcRenderer.invoke("getCatalogue", category),
|
||||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
|
||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
|
getHowLongToBeat: (title: string) =>
|
||||||
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
|
ipcRenderer.invoke("getHowLongToBeat", title),
|
||||||
getGames: (take?: number, prevCursor?: number) =>
|
getGames: (take?: number, skip?: number) =>
|
||||||
ipcRenderer.invoke("getGames", take, prevCursor),
|
ipcRenderer.invoke("getGames", take, skip),
|
||||||
searchGameRepacks: (query: string) =>
|
searchGameRepacks: (query: string) =>
|
||||||
ipcRenderer.invoke("searchGameRepacks", query),
|
ipcRenderer.invoke("searchGameRepacks", query),
|
||||||
getGameStats: (objectId: string, shop: GameShop) =>
|
getGameStats: (objectId: string, shop: GameShop) =>
|
||||||
@ -83,8 +84,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
ipcRenderer.invoke("deleteDownloadSource", id),
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
|
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
|
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
|
||||||
createGameShortcut: (id: number) =>
|
createGameShortcut: (id: number) =>
|
||||||
ipcRenderer.invoke("createGameShortcut", id),
|
ipcRenderer.invoke("createGameShortcut", id),
|
||||||
updateExecutablePath: (id: number, executablePath: string) =>
|
updateExecutablePath: (id: number, executablePath: string) =>
|
||||||
@ -106,8 +107,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
||||||
deleteGameFolder: (gameId: number) =>
|
deleteGameFolder: (gameId: number) =>
|
||||||
ipcRenderer.invoke("deleteGameFolder", gameId),
|
ipcRenderer.invoke("deleteGameFolder", gameId),
|
||||||
getGameByObjectID: (objectID: string) =>
|
getGameByObjectId: (objectId: string) =>
|
||||||
ipcRenderer.invoke("getGameByObjectID", objectID),
|
ipcRenderer.invoke("getGameByObjectId", objectId),
|
||||||
onGamesRunning: (
|
onGamesRunning: (
|
||||||
cb: (
|
cb: (
|
||||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||||
@ -129,6 +130,62 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
getDiskFreeSpace: (path: string) =>
|
getDiskFreeSpace: (path: string) =>
|
||||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||||
|
|
||||||
|
/* Cloud save */
|
||||||
|
uploadSaveGame: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("uploadSaveGame", objectId, shop),
|
||||||
|
downloadGameArtifact: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
gameArtifactId: string
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
|
||||||
|
getGameArtifacts: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
|
||||||
|
getGameBackupPreview: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
|
||||||
|
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
|
||||||
|
deleteGameArtifact: (gameArtifactId: string) =>
|
||||||
|
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
|
||||||
|
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
`on-upload-complete-${objectId}-${shop}`,
|
||||||
|
listener
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onBackupDownloadProgress: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: (progress: AxiosProgressEvent) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
progress: AxiosProgressEvent
|
||||||
|
) => cb(progress);
|
||||||
|
ipcRenderer.on(`on-backup-download-progress-${objectId}-${shop}`, listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
|
listener
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onBackupDownloadComplete: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: () => void
|
||||||
|
) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on(`on-backup-download-complete-${objectId}-${shop}`, listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
|
listener
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
ping: () => ipcRenderer.invoke("ping"),
|
ping: () => ipcRenderer.invoke("ping"),
|
||||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||||
|
@ -26,6 +26,10 @@ globalStyle("::-webkit-scrollbar-thumb", {
|
|||||||
borderRadius: "24px",
|
borderRadius: "24px",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
globalStyle("::-webkit-scrollbar-thumb:hover", {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.16)",
|
||||||
|
});
|
||||||
|
|
||||||
globalStyle("html, body, #root, main", {
|
globalStyle("html, body, #root, main", {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
});
|
});
|
||||||
|
@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||||
import { downloadSourcesWorker } from "./workers";
|
import { downloadSourcesWorker } from "./workers";
|
||||||
import { repacksContext } from "./context";
|
import { repacksContext } from "./context";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -231,6 +232,8 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const downloadSource of downloadSources) {
|
for (const downloadSource of downloadSources) {
|
||||||
|
logger.info("Migrating download source", downloadSource.url);
|
||||||
|
|
||||||
const channel = new BroadcastChannel(
|
const channel = new BroadcastChannel(
|
||||||
`download_sources:import:${downloadSource.url}`
|
`download_sources:import:${downloadSource.url}`
|
||||||
);
|
);
|
||||||
@ -243,6 +246,10 @@ export function App() {
|
|||||||
channel.onmessage = () => {
|
channel.onmessage = () => {
|
||||||
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
|
logger.info(
|
||||||
|
"Deleted download source from SQLite",
|
||||||
|
downloadSource.url
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
indexRepacks();
|
indexRepacks();
|
||||||
|
725
src/renderer/src/assets/lottie/cloud.json
Normal file
725
src/renderer/src/assets/lottie/cloud.json
Normal file
@ -0,0 +1,725 @@
|
|||||||
|
{
|
||||||
|
"v": "5.12.1",
|
||||||
|
"fr": 30,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 60,
|
||||||
|
"w": 400,
|
||||||
|
"h": 400,
|
||||||
|
"nm": "Cloud",
|
||||||
|
"ddd": 0,
|
||||||
|
"assets": [],
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 2,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 6",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [322.789, 202.565, 0],
|
||||||
|
"to": [-1.5, -0.167, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [313.789, 201.565, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [-1.5, -0.167, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [322.789, 202.565, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0],
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0],
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[69.827, 0],
|
||||||
|
[0, 69.827],
|
||||||
|
[-69.827, 0],
|
||||||
|
[0, -69.827]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 3,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 5",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [243.704, 202.565, 0],
|
||||||
|
"to": [-1.667, 0, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [233.704, 202.565, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [-1.667, 0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [243.704, 202.565, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0],
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0],
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[69.827, 0],
|
||||||
|
[0, 69.827],
|
||||||
|
[-69.827, 0],
|
||||||
|
[0, -69.827]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 4,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 4",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [260.681, 151.053, 0],
|
||||||
|
"to": [1.333, -1.333, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [268.681, 143.053, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [1.333, -1.333, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [260.681, 151.053, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0],
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 38.564],
|
||||||
|
[-38.564, 0],
|
||||||
|
[0, -38.564],
|
||||||
|
[38.564, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[69.827, 0],
|
||||||
|
[0, 69.827],
|
||||||
|
[-69.827, 0],
|
||||||
|
[0, -69.827]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 5,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 3",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [162.135, 206.563, 0],
|
||||||
|
"to": [-0.833, -0.167, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [157.135, 205.563, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [-0.833, -0.167, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [162.135, 206.563, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -36.66],
|
||||||
|
[36.66, 0],
|
||||||
|
[0, 36.66],
|
||||||
|
[-36.66, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 36.66],
|
||||||
|
[-36.66, 0],
|
||||||
|
[0, -36.66],
|
||||||
|
[36.66, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[66.378, 0],
|
||||||
|
[0, 66.378],
|
||||||
|
[-66.378, 0],
|
||||||
|
[0, -66.378]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 6,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 2",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [180.178, 132.225, 0],
|
||||||
|
"to": [-0.5, -2.333, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [177.178, 118.225, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [-0.5, -2.333, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [180.178, 132.225, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -50.068],
|
||||||
|
[50.068, 0],
|
||||||
|
[0, 50.068],
|
||||||
|
[-50.068, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 50.068],
|
||||||
|
[-50.068, 0],
|
||||||
|
[0, -50.068],
|
||||||
|
[50.068, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[90.655, 0],
|
||||||
|
[0, 90.655],
|
||||||
|
[-90.655, 0],
|
||||||
|
[0, -90.655]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 7,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "Layer 1",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 0,
|
||||||
|
"s": [95.756, 208.288, 0],
|
||||||
|
"to": [-1.167, 0, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": { "x": 0.667, "y": 1 },
|
||||||
|
"o": { "x": 0.333, "y": 0 },
|
||||||
|
"t": 30,
|
||||||
|
"s": [88.756, 208.288, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [-1.167, 0, 0]
|
||||||
|
},
|
||||||
|
{ "t": 60, "s": [95.756, 208.288, 0] }
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, -35.403],
|
||||||
|
[35.403, 0],
|
||||||
|
[0, 35.403],
|
||||||
|
[-35.403, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 35.403],
|
||||||
|
[-35.403, 0],
|
||||||
|
[0, -35.403],
|
||||||
|
[35.403, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[64.103, 0],
|
||||||
|
[0, 64.103],
|
||||||
|
[-64.103, 0],
|
||||||
|
[0, -64.103]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "Path 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "fl",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||||
|
"r": 1,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "Fill 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Fill",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||||
|
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||||
|
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||||
|
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||||
|
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||||
|
"nm": "Transform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "Group 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 8,
|
||||||
|
"ty": 3,
|
||||||
|
"nm": "Null 1",
|
||||||
|
"parent": 6,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": { "a": 0, "k": 0, "ix": 11 },
|
||||||
|
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||||
|
"p": { "a": 0, "k": [19.822, 67.775, 0], "ix": 2, "l": 2 },
|
||||||
|
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||||
|
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 270,
|
||||||
|
"st": 0,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [],
|
||||||
|
"props": {}
|
||||||
|
}
|
@ -44,7 +44,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||||||
|
|
||||||
const handleHover = useCallback(() => {
|
const handleHover = useCallback(() => {
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
|
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
|
||||||
setStats(stats);
|
setStats(stats);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -140,7 +140,10 @@ export function Sidebar() {
|
|||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
game: LibraryGame
|
game: LibraryGame
|
||||||
) => {
|
) => {
|
||||||
const path = buildGameDetailsPath(game);
|
const path = buildGameDetailsPath({
|
||||||
|
...game,
|
||||||
|
objectId: game.objectID,
|
||||||
|
});
|
||||||
if (path !== location.pathname) {
|
if (path !== location.pathname) {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
}
|
}
|
||||||
|
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { gameBackupsTable } from "@renderer/dexie";
|
||||||
|
import { useToast } from "@renderer/hooks";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
import type { LudusaviBackup, GameArtifact, GameShop } from "@types";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export enum CloudSyncState {
|
||||||
|
New,
|
||||||
|
Different,
|
||||||
|
Same,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudSyncContext {
|
||||||
|
backupPreview: LudusaviBackup | null;
|
||||||
|
artifacts: GameArtifact[];
|
||||||
|
showCloudSyncModal: boolean;
|
||||||
|
showCloudSyncFilesModal: boolean;
|
||||||
|
supportsCloudSync: boolean | null;
|
||||||
|
backupState: CloudSyncState;
|
||||||
|
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||||
|
uploadSaveGame: () => Promise<void>;
|
||||||
|
deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||||
|
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
restoringBackup: boolean;
|
||||||
|
uploadingBackup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudSyncContext = createContext<CloudSyncContext>({
|
||||||
|
backupPreview: null,
|
||||||
|
showCloudSyncModal: false,
|
||||||
|
supportsCloudSync: null,
|
||||||
|
backupState: CloudSyncState.Unknown,
|
||||||
|
setShowCloudSyncModal: () => {},
|
||||||
|
downloadGameArtifact: async () => {},
|
||||||
|
uploadSaveGame: async () => {},
|
||||||
|
artifacts: [],
|
||||||
|
deleteGameArtifact: async () => {},
|
||||||
|
showCloudSyncFilesModal: false,
|
||||||
|
setShowCloudSyncFilesModal: () => {},
|
||||||
|
restoringBackup: false,
|
||||||
|
uploadingBackup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Provider } = cloudSyncContext;
|
||||||
|
export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext;
|
||||||
|
|
||||||
|
export interface CloudSyncContextProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
objectId: string;
|
||||||
|
shop: GameShop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudSyncContextProvider({
|
||||||
|
children,
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
}: CloudSyncContextProviderProps) {
|
||||||
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
|
||||||
|
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
|
||||||
|
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [restoringBackup, setRestoringBackup] = useState(false);
|
||||||
|
const [uploadingBackup, setUploadingBackup] = useState(false);
|
||||||
|
const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false);
|
||||||
|
|
||||||
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
|
const downloadGameArtifact = useCallback(
|
||||||
|
async (gameArtifactId: string) => {
|
||||||
|
setRestoringBackup(true);
|
||||||
|
window.electron.downloadGameArtifact(objectId, shop, gameArtifactId);
|
||||||
|
},
|
||||||
|
[objectId, shop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getGameBackupPreview = useCallback(async () => {
|
||||||
|
window.electron.getGameArtifacts(objectId, shop).then((results) => {
|
||||||
|
setArtifacts(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron
|
||||||
|
.getGameBackupPreview(objectId, shop)
|
||||||
|
.then((preview) => {
|
||||||
|
logger.info("Game backup preview", objectId, shop, preview);
|
||||||
|
if (preview && Object.keys(preview.games).length) {
|
||||||
|
setBackupPreview(preview);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error("Failed to get game backup preview", objectId, shop, err);
|
||||||
|
});
|
||||||
|
}, [objectId, shop]);
|
||||||
|
|
||||||
|
const uploadSaveGame = useCallback(async () => {
|
||||||
|
setUploadingBackup(true);
|
||||||
|
window.electron.uploadSaveGame(objectId, shop);
|
||||||
|
}, [objectId, shop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeUploadCompleteListener = window.electron.onUploadComplete(
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
() => {
|
||||||
|
showSuccessToast(t("backup_uploaded"));
|
||||||
|
|
||||||
|
setUploadingBackup(false);
|
||||||
|
gameBackupsTable.add({
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
getGameBackupPreview();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeDownloadCompleteListener =
|
||||||
|
window.electron.onBackupDownloadComplete(objectId, shop, () => {
|
||||||
|
showSuccessToast(t("backup_restored"));
|
||||||
|
|
||||||
|
setRestoringBackup(false);
|
||||||
|
getGameBackupPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeUploadCompleteListener();
|
||||||
|
removeDownloadCompleteListener();
|
||||||
|
};
|
||||||
|
}, [objectId, shop, showSuccessToast, t, getGameBackupPreview]);
|
||||||
|
|
||||||
|
const deleteGameArtifact = useCallback(
|
||||||
|
async (gameArtifactId: string) => {
|
||||||
|
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
|
||||||
|
getGameBackupPreview();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getGameBackupPreview]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron
|
||||||
|
.checkGameCloudSyncSupport(objectId, shop)
|
||||||
|
.then((result) => {
|
||||||
|
logger.info("Cloud sync support", objectId, shop, result);
|
||||||
|
setSupportsCloudSync(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error("Failed to check cloud sync support", err);
|
||||||
|
});
|
||||||
|
}, [objectId, shop, getGameBackupPreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBackupPreview(null);
|
||||||
|
setArtifacts([]);
|
||||||
|
setSupportsCloudSync(null);
|
||||||
|
setShowCloudSyncModal(false);
|
||||||
|
setRestoringBackup(false);
|
||||||
|
setUploadingBackup(false);
|
||||||
|
}, [objectId, shop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCloudSyncModal) {
|
||||||
|
getGameBackupPreview();
|
||||||
|
}
|
||||||
|
}, [getGameBackupPreview, showCloudSyncModal]);
|
||||||
|
|
||||||
|
const backupState = useMemo(() => {
|
||||||
|
if (!backupPreview) return CloudSyncState.Unknown;
|
||||||
|
if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
|
||||||
|
if (backupPreview.overall.changedGames.different)
|
||||||
|
return CloudSyncState.Different;
|
||||||
|
if (backupPreview.overall.changedGames.same) return CloudSyncState.Same;
|
||||||
|
|
||||||
|
return CloudSyncState.Unknown;
|
||||||
|
}, [backupPreview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider
|
||||||
|
value={{
|
||||||
|
supportsCloudSync,
|
||||||
|
backupPreview,
|
||||||
|
showCloudSyncModal,
|
||||||
|
artifacts,
|
||||||
|
backupState,
|
||||||
|
restoringBackup,
|
||||||
|
uploadingBackup,
|
||||||
|
showCloudSyncFilesModal,
|
||||||
|
setShowCloudSyncModal,
|
||||||
|
uploadSaveGame,
|
||||||
|
downloadGameArtifact,
|
||||||
|
deleteGameArtifact,
|
||||||
|
setShowCloudSyncFilesModal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -5,7 +5,6 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { getSteamLanguage } from "@renderer/helpers";
|
import { getSteamLanguage } from "@renderer/helpers";
|
||||||
@ -33,7 +32,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||||||
gameTitle: "",
|
gameTitle: "",
|
||||||
isGameRunning: false,
|
isGameRunning: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
objectID: undefined,
|
objectId: undefined,
|
||||||
gameColor: "",
|
gameColor: "",
|
||||||
showRepacksModal: false,
|
showRepacksModal: false,
|
||||||
showGameOptionsModal: false,
|
showGameOptionsModal: false,
|
||||||
@ -53,13 +52,17 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
|||||||
|
|
||||||
export interface GameDetailsContextProps {
|
export interface GameDetailsContextProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
objectId: string;
|
||||||
|
gameTitle: string;
|
||||||
|
shop: GameShop;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameDetailsContextProvider({
|
export function GameDetailsContextProvider({
|
||||||
children,
|
children,
|
||||||
|
objectId,
|
||||||
|
gameTitle,
|
||||||
|
shop,
|
||||||
}: GameDetailsContextProps) {
|
}: GameDetailsContextProps) {
|
||||||
const { objectID, shop } = useParams();
|
|
||||||
|
|
||||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
const [game, setGame] = useState<Game | null>(null);
|
||||||
@ -75,10 +78,6 @@ export function GameDetailsContextProvider({
|
|||||||
|
|
||||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const gameTitle = searchParams.get("title")!;
|
|
||||||
|
|
||||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -101,9 +100,9 @@ export function GameDetailsContextProvider({
|
|||||||
|
|
||||||
const updateGame = useCallback(async () => {
|
const updateGame = useCallback(async () => {
|
||||||
return window.electron
|
return window.electron
|
||||||
.getGameByObjectID(objectID!)
|
.getGameByObjectId(objectId!)
|
||||||
.then((result) => setGame(result));
|
.then((result) => setGame(result));
|
||||||
}, [setGame, objectID]);
|
}, [setGame, objectId]);
|
||||||
|
|
||||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||||
|
|
||||||
@ -114,7 +113,7 @@ export function GameDetailsContextProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electron
|
window.electron
|
||||||
.getGameShopDetails(
|
.getGameShopDetails(
|
||||||
objectID!,
|
objectId!,
|
||||||
shop as GameShop,
|
shop as GameShop,
|
||||||
getSteamLanguage(i18n.language)
|
getSteamLanguage(i18n.language)
|
||||||
)
|
)
|
||||||
@ -133,15 +132,12 @@ export function GameDetailsContextProvider({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.electron
|
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
|
||||||
.getGameStats(objectID!, shop as GameShop)
|
setStats(result);
|
||||||
.then((result) => {
|
});
|
||||||
setStats(result);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
window.electron
|
window.electron
|
||||||
.getGameAchievements(objectID!, shop as GameShop)
|
.getGameAchievements(objectId!, shop as GameShop)
|
||||||
.then((achievements) => {
|
.then((achievements) => {
|
||||||
setAchievements(achievements);
|
setAchievements(achievements);
|
||||||
})
|
})
|
||||||
@ -150,7 +146,7 @@ export function GameDetailsContextProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateGame();
|
updateGame();
|
||||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShopDetails(null);
|
setShopDetails(null);
|
||||||
@ -159,7 +155,7 @@ export function GameDetailsContextProvider({
|
|||||||
setisGameRunning(false);
|
setisGameRunning(false);
|
||||||
setAchievements([]);
|
setAchievements([]);
|
||||||
dispatch(setHeaderTitle(gameTitle));
|
dispatch(setHeaderTitle(gameTitle));
|
||||||
}, [objectID, gameTitle, dispatch]);
|
}, [objectId, gameTitle, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||||
@ -181,10 +177,10 @@ export function GameDetailsContextProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||||
(objectId, shop) => {
|
(objectId, shop) => {
|
||||||
if (objectID !== objectId || shop !== shop) return;
|
if (objectId !== objectId || shop !== shop) return;
|
||||||
|
|
||||||
window.electron
|
window.electron
|
||||||
.getGameAchievements(objectID!, shop as GameShop)
|
.getGameAchievements(objectId!, shop as GameShop)
|
||||||
.then(setAchievements)
|
.then(setAchievements)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@ -193,7 +189,7 @@ export function GameDetailsContextProvider({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [objectID, shop]);
|
}, [objectId, shop]);
|
||||||
|
|
||||||
const getDownloadsPath = async () => {
|
const getDownloadsPath = async () => {
|
||||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||||
@ -233,7 +229,7 @@ export function GameDetailsContextProvider({
|
|||||||
gameTitle,
|
gameTitle,
|
||||||
isGameRunning,
|
isGameRunning,
|
||||||
isLoading,
|
isLoading,
|
||||||
objectID,
|
objectId,
|
||||||
gameColor,
|
gameColor,
|
||||||
showGameOptionsModal,
|
showGameOptionsModal,
|
||||||
showRepacksModal,
|
showRepacksModal,
|
||||||
|
@ -15,7 +15,7 @@ export interface GameDetailsContext {
|
|||||||
gameTitle: string;
|
gameTitle: string;
|
||||||
isGameRunning: boolean;
|
isGameRunning: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
objectID: string | undefined;
|
objectId: string | undefined;
|
||||||
gameColor: string;
|
gameColor: string;
|
||||||
showRepacksModal: boolean;
|
showRepacksModal: boolean;
|
||||||
showGameOptionsModal: boolean;
|
showGameOptionsModal: boolean;
|
||||||
|
@ -2,3 +2,4 @@ export * from "./game-details/game-details.context";
|
|||||||
export * from "./settings/settings.context";
|
export * from "./settings/settings.context";
|
||||||
export * from "./user-profile/user-profile.context";
|
export * from "./user-profile/user-profile.context";
|
||||||
export * from "./repacks/repacks.context";
|
export * from "./repacks/repacks.context";
|
||||||
|
export * from "./cloud-sync/cloud-sync.context";
|
||||||
|
@ -41,15 +41,18 @@ export function RepacksContextProvider({ children }: RepacksContextProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const indexRepacks = useCallback(() => {
|
const indexRepacks = useCallback(() => {
|
||||||
|
console.log("INDEXING");
|
||||||
setIsIndexingRepacks(true);
|
setIsIndexingRepacks(true);
|
||||||
repacksWorker.postMessage("INDEX_REPACKS");
|
repacksWorker.postMessage("INDEX_REPACKS");
|
||||||
|
|
||||||
repacksWorker.onmessage = () => {
|
repacksWorker.onmessage = () => {
|
||||||
|
console.log("INDEXING COMPLETE");
|
||||||
setIsIndexingRepacks(false);
|
setIsIndexingRepacks(false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("CALLED");
|
||||||
indexRepacks();
|
indexRepacks();
|
||||||
}, [indexRepacks]);
|
}, [indexRepacks]);
|
||||||
|
|
||||||
|
52
src/renderer/src/declaration.d.ts
vendored
52
src/renderer/src/declaration.d.ts
vendored
@ -26,7 +26,10 @@ import type {
|
|||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
GameAchievement,
|
GameAchievement,
|
||||||
|
GameArtifact,
|
||||||
|
LudusaviBackup,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -49,20 +52,15 @@ declare global {
|
|||||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||||
getGameShopDetails: (
|
getGameShopDetails: (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
language: string
|
language: string
|
||||||
) => Promise<ShopDetails | null>;
|
) => Promise<ShopDetails | null>;
|
||||||
getRandomGame: () => Promise<Steam250Game>;
|
getRandomGame: () => Promise<Steam250Game>;
|
||||||
getHowLongToBeat: (
|
getHowLongToBeat: (
|
||||||
objectID: string,
|
|
||||||
shop: GameShop,
|
|
||||||
title: string
|
title: string
|
||||||
) => Promise<HowLongToBeatCategory[] | null>;
|
) => Promise<HowLongToBeatCategory[] | null>;
|
||||||
getGames: (
|
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
||||||
take?: number,
|
|
||||||
prevCursor?: number
|
|
||||||
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
|
|
||||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||||
@ -81,7 +79,7 @@ declare global {
|
|||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
addGameToLibrary: (
|
addGameToLibrary: (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
title: string,
|
title: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
@ -97,7 +95,7 @@ declare global {
|
|||||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||||
removeGame: (gameId: number) => Promise<void>;
|
removeGame: (gameId: number) => Promise<void>;
|
||||||
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
||||||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
getGameByObjectId: (objectId: string) => Promise<Game | null>;
|
||||||
onGamesRunning: (
|
onGamesRunning: (
|
||||||
cb: (
|
cb: (
|
||||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||||
@ -120,6 +118,42 @@ declare global {
|
|||||||
/* Hardware */
|
/* Hardware */
|
||||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||||
|
|
||||||
|
/* Cloud save */
|
||||||
|
uploadSaveGame: (objectId: string, shop: GameShop) => Promise<void>;
|
||||||
|
downloadGameArtifact: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
gameArtifactId: string
|
||||||
|
) => Promise<void>;
|
||||||
|
getGameArtifacts: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => Promise<GameArtifact[]>;
|
||||||
|
getGameBackupPreview: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => Promise<LudusaviBackup | null>;
|
||||||
|
checkGameCloudSyncSupport: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
) => Promise<boolean>;
|
||||||
|
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
|
||||||
|
onBackupDownloadComplete: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: () => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
onUploadComplete: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: () => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
onBackupDownloadProgress: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: (progress: AxiosProgressEvent) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
openExternal: (src: string) => Promise<void>;
|
openExternal: (src: string) => Promise<void>;
|
||||||
getVersion: () => Promise<string>;
|
getVersion: () => Promise<string>;
|
||||||
|
@ -1,13 +1,36 @@
|
|||||||
|
import type { GameShop, HowLongToBeatCategory } from "@types";
|
||||||
import { Dexie } from "dexie";
|
import { Dexie } from "dexie";
|
||||||
|
|
||||||
|
export interface GameBackup {
|
||||||
|
id?: number;
|
||||||
|
shop: GameShop;
|
||||||
|
objectId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HowLongToBeatEntry {
|
||||||
|
id?: number;
|
||||||
|
objectId: string;
|
||||||
|
categories: HowLongToBeatCategory[];
|
||||||
|
shop: GameShop;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export const db = new Dexie("Hydra");
|
export const db = new Dexie("Hydra");
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(4).stores({
|
||||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||||
|
gameBackups: `++id, [shop+objectId], createdAt`,
|
||||||
|
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadSourcesTable = db.table("downloadSources");
|
export const downloadSourcesTable = db.table("downloadSources");
|
||||||
export const repacksTable = db.table("repacks");
|
export const repacksTable = db.table("repacks");
|
||||||
|
export const gameBackupsTable = db.table<GameBackup>("gameBackups");
|
||||||
|
export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
||||||
|
"howLongToBeatEntries"
|
||||||
|
);
|
||||||
|
|
||||||
db.open();
|
db.open();
|
||||||
|
@ -27,11 +27,11 @@ export const getSteamLanguage = (language: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const buildGameDetailsPath = (
|
export const buildGameDetailsPath = (
|
||||||
game: { shop: GameShop; objectID: string; title: string },
|
game: { shop: GameShop; objectId: string; title: string },
|
||||||
params: Record<string, string> = {}
|
params: Record<string, string> = {}
|
||||||
) => {
|
) => {
|
||||||
const searchParams = new URLSearchParams({ title: game.title, ...params });
|
const searchParams = new URLSearchParams({ title: game.title, ...params });
|
||||||
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
|
return `/game/${game.shop}/${game.objectId}?${searchParams.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
||||||
|
@ -14,6 +14,7 @@ import type {
|
|||||||
UserDetails,
|
UserDetails,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||||
|
import { gameBackupsTable } from "@renderer/dexie";
|
||||||
|
|
||||||
export function useUserDetails() {
|
export function useUserDetails() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -32,6 +33,7 @@ export function useUserDetails() {
|
|||||||
dispatch(setUserDetails(null));
|
dispatch(setUserDetails(null));
|
||||||
dispatch(setProfileBackground(null));
|
dispatch(setProfileBackground(null));
|
||||||
|
|
||||||
|
await gameBackupsTable.clear();
|
||||||
window.localStorage.removeItem("userDetails");
|
window.localStorage.removeItem("userDetails");
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
@ -44,32 +46,9 @@ export function useUserDetails() {
|
|||||||
const updateUserDetails = useCallback(
|
const updateUserDetails = useCallback(
|
||||||
async (userDetails: UserDetails) => {
|
async (userDetails: UserDetails) => {
|
||||||
dispatch(setUserDetails(userDetails));
|
dispatch(setUserDetails(userDetails));
|
||||||
|
window.localStorage.setItem("userDetails", JSON.stringify(userDetails));
|
||||||
if (userDetails.profileImageUrl) {
|
|
||||||
// TODO: Decide if we want to use this
|
|
||||||
// const profileBackground = await profileBackgroundFromProfileImage(
|
|
||||||
// userDetails.profileImageUrl
|
|
||||||
// ).catch((err) => {
|
|
||||||
// logger.error("profileBackgroundFromProfileImage", err);
|
|
||||||
// return `#151515B3`;
|
|
||||||
// });
|
|
||||||
// dispatch(setProfileBackground(profileBackground));
|
|
||||||
|
|
||||||
window.localStorage.setItem(
|
|
||||||
"userDetails",
|
|
||||||
JSON.stringify({ ...userDetails, profileBackground })
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const profileBackground = `#151515B3`;
|
|
||||||
dispatch(setProfileBackground(profileBackground));
|
|
||||||
|
|
||||||
window.localStorage.setItem(
|
|
||||||
"userDetails",
|
|
||||||
JSON.stringify({ ...userDetails, profileBackground })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[dispatch, profileBackground]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchUserDetails = useCallback(async () => {
|
const fetchUserDetails = useCallback(async () => {
|
||||||
|
3
src/renderer/src/logger.ts
Normal file
3
src/renderer/src/logger.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import log from "electron-log/renderer";
|
||||||
|
|
||||||
|
export const logger = log.scope("renderer");
|
@ -66,7 +66,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/" Component={Home} />
|
<Route path="/" Component={Home} />
|
||||||
<Route path="/catalogue" Component={Catalogue} />
|
<Route path="/catalogue" Component={Catalogue} />
|
||||||
<Route path="/downloads" Component={Downloads} />
|
<Route path="/downloads" Component={Downloads} />
|
||||||
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
<Route path="/game/:shop/:objectId" Component={GameDetails} />
|
||||||
<Route path="/search" Component={SearchResults} />
|
<Route path="/search" Component={SearchResults} />
|
||||||
<Route path="/settings" Component={Settings} />
|
<Route path="/settings" Component={Settings} />
|
||||||
<Route path="/profile/:userId" Component={Profile} />
|
<Route path="/profile/:userId" Component={Profile} />
|
||||||
|
@ -24,12 +24,10 @@ export function Catalogue() {
|
|||||||
|
|
||||||
const contentRef = useRef<HTMLElement>(null);
|
const contentRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
const cursorRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const cursor = Number(searchParams.get("cursor") ?? 0);
|
const skip = Number(searchParams.get("skip") ?? 0);
|
||||||
|
|
||||||
const handleGameClick = (game: CatalogueEntry) => {
|
const handleGameClick = (game: CatalogueEntry) => {
|
||||||
dispatch(clearSearch());
|
dispatch(clearSearch());
|
||||||
@ -42,11 +40,10 @@ export function Catalogue() {
|
|||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
|
||||||
window.electron
|
window.electron
|
||||||
.getGames(24, cursor)
|
.getGames(24, skip)
|
||||||
.then(({ results, cursor }) => {
|
.then((results) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
cursorRef.current = cursor;
|
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}, 500);
|
}, 500);
|
||||||
@ -55,11 +52,11 @@ export function Catalogue() {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [dispatch, cursor, searchParams]);
|
}, [dispatch, skip, searchParams]);
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const handleNextPage = () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
cursor: cursorRef.current.toString(),
|
skip: String(skip + 24),
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate(`/catalogue?${params.toString()}`);
|
navigate(`/catalogue?${params.toString()}`);
|
||||||
@ -80,7 +77,7 @@ export function Catalogue() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
disabled={cursor === 0 || isLoading}
|
disabled={skip === 0 || isLoading}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
{t("previous_page")}
|
{t("previous_page")}
|
||||||
@ -103,7 +100,7 @@ export function Catalogue() {
|
|||||||
<>
|
<>
|
||||||
{searchResults.map((game) => (
|
{searchResults.map((game) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={game.objectID}
|
key={game.objectId}
|
||||||
game={game}
|
game={game}
|
||||||
onClick={() => handleGameClick(game)}
|
onClick={() => handleGameClick(game)}
|
||||||
/>
|
/>
|
||||||
|
@ -93,6 +93,7 @@ export const downloadRightContent = style({
|
|||||||
padding: `${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
flex: "1",
|
flex: "1",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadActions = style({
|
export const downloadActions = style({
|
||||||
|
@ -227,7 +227,14 @@ export function DownloadGroup({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.downloadTitle}
|
className={styles.downloadTitle}
|
||||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
buildGameDetailsPath({
|
||||||
|
...game,
|
||||||
|
objectId: game.objectID,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{game.title}
|
{game.title}
|
||||||
</button>
|
</button>
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
|
||||||
|
export const artifacts = style({
|
||||||
|
display: "flex",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
flexDirection: "column",
|
||||||
|
listStyle: "none",
|
||||||
|
margin: "0",
|
||||||
|
padding: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const artifactButton = style({
|
||||||
|
display: "flex",
|
||||||
|
textAlign: "left",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
color: vars.color.body,
|
||||||
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
|
backgroundColor: vars.color.darkBackground,
|
||||||
|
border: `1px solid ${vars.color.border}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
});
|
@ -0,0 +1,76 @@
|
|||||||
|
import { Modal, ModalProps } from "@renderer/components";
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
import { cloudSyncContext } from "@renderer/context";
|
||||||
|
|
||||||
|
export interface CloudSyncFilesModalProps
|
||||||
|
extends Omit<ModalProps, "children" | "title"> {}
|
||||||
|
|
||||||
|
export function CloudSyncFilesModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}: CloudSyncFilesModalProps) {
|
||||||
|
const { backupPreview } = useContext(cloudSyncContext);
|
||||||
|
|
||||||
|
console.log(backupPreview);
|
||||||
|
|
||||||
|
const files = useMemo(() => {
|
||||||
|
if (!backupPreview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [game] = Object.values(backupPreview.games);
|
||||||
|
const entries = Object.entries(game.files);
|
||||||
|
|
||||||
|
return entries.map(([key, value]) => {
|
||||||
|
return { path: key, ...value };
|
||||||
|
});
|
||||||
|
}, [backupPreview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="Gerenciar arquivos"
|
||||||
|
description="Escolha quais diretórios serão sincronizados"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{/* <div className={styles.downloaders}>
|
||||||
|
{["AUTOMATIC", "CUSTOM"].map((downloader) => (
|
||||||
|
<Button
|
||||||
|
key={downloader}
|
||||||
|
className={styles.downloaderOption}
|
||||||
|
theme={selectedDownloader === downloader ? "primary" : "outline"}
|
||||||
|
disabled={
|
||||||
|
downloader === Downloader.RealDebrid &&
|
||||||
|
!userPreferences?.realDebridApiToken
|
||||||
|
}
|
||||||
|
onClick={() => setSelectedDownloader(downloader)}
|
||||||
|
>
|
||||||
|
{selectedDownloader === downloader && (
|
||||||
|
<CheckCircleFillIcon className={styles.downloaderIcon} />
|
||||||
|
)}
|
||||||
|
{DOWNLOADER_NAME[downloader]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: "left" }}>Arquivo</th>
|
||||||
|
<th style={{ textAlign: "left" }}>Hash</th>
|
||||||
|
<th style={{ textAlign: "left" }}>Tamanho</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{files.map((file) => (
|
||||||
|
<tr key={file.path}>
|
||||||
|
<td style={{ textAlign: "left" }}>{file.path}</td>
|
||||||
|
<td style={{ textAlign: "left" }}>{file.change}</td>
|
||||||
|
<td style={{ textAlign: "left" }}>{file.path}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { keyframes, style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
|
||||||
|
export const rotate = keyframes({
|
||||||
|
"0%": { transform: "rotate(0deg)" },
|
||||||
|
"100%": {
|
||||||
|
transform: "rotate(360deg)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const artifacts = style({
|
||||||
|
display: "flex",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
flexDirection: "column",
|
||||||
|
listStyle: "none",
|
||||||
|
margin: "0",
|
||||||
|
padding: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const artifactButton = style({
|
||||||
|
display: "flex",
|
||||||
|
textAlign: "left",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
color: vars.color.body,
|
||||||
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
|
backgroundColor: vars.color.darkBackground,
|
||||||
|
border: `1px solid ${vars.color.border}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const syncIcon = style({
|
||||||
|
animationName: rotate,
|
||||||
|
animationDuration: "1s",
|
||||||
|
animationIterationCount: "infinite",
|
||||||
|
animationTimingFunction: "linear",
|
||||||
|
});
|
@ -0,0 +1,251 @@
|
|||||||
|
import { Button, Modal, ModalProps } from "@renderer/components";
|
||||||
|
import { useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||||
|
|
||||||
|
import * as styles from "./cloud-sync-modal.css";
|
||||||
|
import { formatBytes } from "@shared";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
CheckCircleFillIcon,
|
||||||
|
ClockIcon,
|
||||||
|
DeviceDesktopIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
SyncIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UploadIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { useToast } from "@renderer/hooks";
|
||||||
|
import { GameBackup, gameBackupsTable } from "@renderer/dexie";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AxiosProgressEvent } from "axios";
|
||||||
|
import { formatDownloadProgress } from "@renderer/helpers";
|
||||||
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
|
||||||
|
export interface CloudSyncModalProps
|
||||||
|
extends Omit<ModalProps, "children" | "title"> {}
|
||||||
|
|
||||||
|
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||||
|
const [deletingArtifact, setDeletingArtifact] = useState(false);
|
||||||
|
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
|
||||||
|
const [backupDownloadProgress, setBackupDownloadProgress] =
|
||||||
|
useState<AxiosProgressEvent | null>(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const {
|
||||||
|
artifacts,
|
||||||
|
backupPreview,
|
||||||
|
uploadingBackup,
|
||||||
|
restoringBackup,
|
||||||
|
uploadSaveGame,
|
||||||
|
downloadGameArtifact,
|
||||||
|
deleteGameArtifact,
|
||||||
|
setShowCloudSyncFilesModal,
|
||||||
|
} = useContext(cloudSyncContext);
|
||||||
|
|
||||||
|
const { objectId, shop, gameTitle } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
|
const { showSuccessToast, showErrorToast } = useToast();
|
||||||
|
|
||||||
|
const handleDeleteArtifactClick = async (gameArtifactId: string) => {
|
||||||
|
setDeletingArtifact(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGameArtifact(gameArtifactId);
|
||||||
|
|
||||||
|
showSuccessToast(t("backup_deleted"));
|
||||||
|
} catch (err) {
|
||||||
|
showErrorToast("backup_deletion_failed");
|
||||||
|
} finally {
|
||||||
|
setDeletingArtifact(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gameBackupsTable
|
||||||
|
.where({ shop: shop, objectId })
|
||||||
|
.last()
|
||||||
|
.then((lastBackup) => setLastBackup(lastBackup || null));
|
||||||
|
|
||||||
|
const removeBackupDownloadProgressListener =
|
||||||
|
window.electron.onBackupDownloadProgress(
|
||||||
|
objectId!,
|
||||||
|
shop,
|
||||||
|
(progressEvent) => {
|
||||||
|
setBackupDownloadProgress(progressEvent);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeBackupDownloadProgressListener();
|
||||||
|
};
|
||||||
|
}, [backupPreview, objectId, shop]);
|
||||||
|
|
||||||
|
const handleBackupInstallClick = async (artifactId: string) => {
|
||||||
|
setBackupDownloadProgress(null);
|
||||||
|
downloadGameArtifact(artifactId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupStateLabel = useMemo(() => {
|
||||||
|
if (uploadingBackup) {
|
||||||
|
return (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<SyncIcon className={styles.syncIcon} />
|
||||||
|
{t("uploading_backup")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoringBackup) {
|
||||||
|
return (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<SyncIcon className={styles.syncIcon} />
|
||||||
|
{t("restoring_backup", {
|
||||||
|
progress: formatDownloadProgress(
|
||||||
|
backupDownloadProgress?.progress ?? 0
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastBackup) {
|
||||||
|
return (
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<i style={{ color: vars.color.success }}>
|
||||||
|
<CheckCircleFillIcon />
|
||||||
|
</i>
|
||||||
|
|
||||||
|
{t("last_backup_date", {
|
||||||
|
date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backupPreview) {
|
||||||
|
return t("no_backup_preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("no_backups");
|
||||||
|
}, [
|
||||||
|
uploadingBackup,
|
||||||
|
backupDownloadProgress?.progress,
|
||||||
|
lastBackup,
|
||||||
|
backupPreview,
|
||||||
|
restoringBackup,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title={t("cloud_save")}
|
||||||
|
description={t("cloud_save_description")}
|
||||||
|
onClose={onClose}
|
||||||
|
large
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 24,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
|
||||||
|
<h2>{gameTitle}</h2>
|
||||||
|
<p>{backupStateLabel}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
color: vars.color.body,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowCloudSyncFilesModal(true)}
|
||||||
|
>
|
||||||
|
Gerenciar arquivos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={uploadSaveGame}
|
||||||
|
disabled={disableActions || !backupPreview}
|
||||||
|
>
|
||||||
|
<UploadIcon />
|
||||||
|
{t("create_backup")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: SPACING_UNIT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>{t("backups")}</h2>
|
||||||
|
<small>{artifacts.length} / 2</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={styles.artifacts}>
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<li key={artifact.id} className={styles.artifactButton}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
|
||||||
|
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<DeviceDesktopIcon size={14} />
|
||||||
|
{artifact.hostname}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<ClockIcon size={14} />
|
||||||
|
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBackupInstallClick(artifact.id)}
|
||||||
|
disabled={disableActions}
|
||||||
|
>
|
||||||
|
<HistoryIcon />
|
||||||
|
{t("install_backup")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteArtifactClick(artifact.id)}
|
||||||
|
theme="danger"
|
||||||
|
disabled={disableActions}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
{t("delete_backup")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
@ -9,8 +9,12 @@ import { Sidebar } from "./sidebar/sidebar";
|
|||||||
|
|
||||||
import * as styles from "./game-details.css";
|
import * as styles from "./game-details.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
import Lottie from "lottie-react";
|
||||||
|
|
||||||
|
import downloadingAnimation from "@renderer/assets/lottie/cloud.json";
|
||||||
|
import { useUserDetails } from "@renderer/hooks";
|
||||||
|
|
||||||
const HERO_ANIMATION_THRESHOLD = 25;
|
const HERO_ANIMATION_THRESHOLD = 25;
|
||||||
|
|
||||||
@ -22,7 +26,7 @@ export function GameDetailsContent() {
|
|||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
objectID,
|
objectId,
|
||||||
shopDetails,
|
shopDetails,
|
||||||
game,
|
game,
|
||||||
gameColor,
|
gameColor,
|
||||||
@ -30,10 +34,15 @@ export function GameDetailsContent() {
|
|||||||
hasNSFWContentBlocked,
|
hasNSFWContentBlocked,
|
||||||
} = useContext(gameDetailsContext);
|
} = useContext(gameDetailsContext);
|
||||||
|
|
||||||
|
const { userDetails } = useUserDetails();
|
||||||
|
|
||||||
|
const { supportsCloudSync, setShowCloudSyncModal } =
|
||||||
|
useContext(cloudSyncContext);
|
||||||
|
|
||||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||||
|
|
||||||
const handleHeroLoad = async () => {
|
const handleHeroLoad = async () => {
|
||||||
const output = await average(steamUrlBuilder.libraryHero(objectID!), {
|
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
|
||||||
amount: 1,
|
amount: 1,
|
||||||
format: "hex",
|
format: "hex",
|
||||||
});
|
});
|
||||||
@ -47,7 +56,7 @@ export function GameDetailsContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBackdropOpacity(1);
|
setBackdropOpacity(1);
|
||||||
}, [objectID]);
|
}, [objectId]);
|
||||||
|
|
||||||
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
|
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
|
||||||
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
|
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
|
||||||
@ -69,10 +78,19 @@ export function GameDetailsContent() {
|
|||||||
setBackdropOpacity(opacity);
|
setBackdropOpacity(opacity);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloudSaveButtonClick = () => {
|
||||||
|
if (!userDetails) {
|
||||||
|
window.electron.openAuthWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCloudSyncModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
|
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.libraryHero(objectID!)}
|
src={steamUrlBuilder.libraryHero(objectId!)}
|
||||||
className={styles.heroImage}
|
className={styles.heroImage}
|
||||||
alt={game?.title}
|
alt={game?.title}
|
||||||
onLoad={handleHeroLoad}
|
onLoad={handleHeroLoad}
|
||||||
@ -98,10 +116,37 @@ export function GameDetailsContent() {
|
|||||||
>
|
>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.logo(objectID!)}
|
src={steamUrlBuilder.logo(objectId!)}
|
||||||
className={styles.gameLogo}
|
className={styles.gameLogo}
|
||||||
alt={game?.title}
|
alt={game?.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{supportsCloudSync && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cloudSyncButton}
|
||||||
|
onClick={handleCloudSaveButtonClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 16 + 4,
|
||||||
|
height: 16,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lottie
|
||||||
|
animationData={downloadingAnimation}
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
style={{ width: 26, position: "absolute", top: -3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{t("cloud_save")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,23 +43,6 @@ export function GameDetailsSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={sidebarStyles.contentSidebar}>
|
<div className={sidebarStyles.contentSidebar}>
|
||||||
{/* <div className={sidebarStyles.contentSidebarTitle}>
|
|
||||||
<h3>HowLongToBeat</h3>
|
|
||||||
</div>
|
|
||||||
<ul className={sidebarStyles.howLongToBeatCategoriesList}>
|
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<Skeleton
|
|
||||||
key={index}
|
|
||||||
className={sidebarStyles.howLongToBeatCategorySkeleton}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul> */}
|
|
||||||
<div
|
|
||||||
className={sidebarStyles.contentSidebarTitle}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
<h3>{t("requirements")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className={sidebarStyles.requirementButtonContainer}>
|
<div className={sidebarStyles.requirementButtonContainer}>
|
||||||
<Button
|
<Button
|
||||||
className={sidebarStyles.requirementButton}
|
className={sidebarStyles.requirementButton}
|
||||||
|
@ -6,8 +6,8 @@ import { recipe } from "@vanilla-extract/recipes";
|
|||||||
export const HERO_HEIGHT = 300;
|
export const HERO_HEIGHT = 300;
|
||||||
|
|
||||||
export const slideIn = keyframes({
|
export const slideIn = keyframes({
|
||||||
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` },
|
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)`, opacity: "0px" },
|
||||||
"100%": { transform: "translateY(0)" },
|
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const wrapper = recipe({
|
export const wrapper = recipe({
|
||||||
@ -49,6 +49,8 @@ export const heroContent = style({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-end",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const heroLogoBackdrop = style({
|
export const heroLogoBackdrop = style({
|
||||||
@ -200,3 +202,33 @@ globalStyle(`${description} img`, {
|
|||||||
globalStyle(`${description} a`, {
|
globalStyle(`${description} a`, {
|
||||||
color: vars.color.body,
|
color: vars.color.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const cloudSyncButton = style({
|
||||||
|
padding: `${SPACING_UNIT * 1.5}px ${SPACING_UNIT * 2}px`,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
minHeight: "40px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
color: vars.color.muted,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `solid 1px ${vars.color.border}`,
|
||||||
|
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)",
|
||||||
|
animation: `${slideIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
|
||||||
|
animationDuration: "0.3s",
|
||||||
|
":active": {
|
||||||
|
opacity: "0.9",
|
||||||
|
},
|
||||||
|
":disabled": {
|
||||||
|
opacity: vars.opacity.disabled,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -18,21 +18,26 @@ import { vars } from "@renderer/theme.css";
|
|||||||
|
|
||||||
import { GameDetailsContent } from "./game-details-content";
|
import { GameDetailsContent } from "./game-details-content";
|
||||||
import {
|
import {
|
||||||
|
CloudSyncContextConsumer,
|
||||||
|
CloudSyncContextProvider,
|
||||||
GameDetailsContextConsumer,
|
GameDetailsContextConsumer,
|
||||||
GameDetailsContextProvider,
|
GameDetailsContextProvider,
|
||||||
} from "@renderer/context";
|
} from "@renderer/context";
|
||||||
import { useDownload } from "@renderer/hooks";
|
import { useDownload } from "@renderer/hooks";
|
||||||
import { GameOptionsModal, RepacksModal } from "./modals";
|
import { GameOptionsModal, RepacksModal } from "./modals";
|
||||||
import { Downloader, getDownloadersForUri } from "@shared";
|
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";
|
||||||
|
|
||||||
export function GameDetails() {
|
export function GameDetails() {
|
||||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||||
const [randomizerLocked, setRandomizerLocked] = useState(false);
|
const [randomizerLocked, setRandomizerLocked] = useState(false);
|
||||||
|
|
||||||
const { objectID } = useParams();
|
const { objectId, shop } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const fromRandomizer = searchParams.get("fromRandomizer");
|
const fromRandomizer = searchParams.get("fromRandomizer");
|
||||||
|
const gameTitle = searchParams.get("title");
|
||||||
|
|
||||||
const { startDownload } = useDownload();
|
const { startDownload } = useDownload();
|
||||||
|
|
||||||
@ -45,7 +50,7 @@ export function GameDetails() {
|
|||||||
window.electron.getRandomGame().then((randomGame) => {
|
window.electron.getRandomGame().then((randomGame) => {
|
||||||
setRandomGame(randomGame);
|
setRandomGame(randomGame);
|
||||||
});
|
});
|
||||||
}, [objectID]);
|
}, [objectId]);
|
||||||
|
|
||||||
const handleRandomizerClick = () => {
|
const handleRandomizerClick = () => {
|
||||||
if (randomGame) {
|
if (randomGame) {
|
||||||
@ -74,7 +79,11 @@ export function GameDetails() {
|
|||||||
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
|
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameDetailsContextProvider>
|
<GameDetailsContextProvider
|
||||||
|
gameTitle={gameTitle!}
|
||||||
|
shop={shop! as GameShop}
|
||||||
|
objectId={objectId!}
|
||||||
|
>
|
||||||
<GameDetailsContextConsumer>
|
<GameDetailsContextConsumer>
|
||||||
{({
|
{({
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -96,7 +105,7 @@ export function GameDetails() {
|
|||||||
) => {
|
) => {
|
||||||
await startDownload({
|
await startDownload({
|
||||||
repackId: repack.id,
|
repackId: repack.id,
|
||||||
objectID: objectID!,
|
objectId: objectId!,
|
||||||
title: gameTitle,
|
title: gameTitle,
|
||||||
downloader,
|
downloader,
|
||||||
shop: shop as GameShop,
|
shop: shop as GameShop,
|
||||||
@ -115,64 +124,92 @@ export function GameDetails() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme
|
<CloudSyncContextProvider
|
||||||
baseColor={vars.color.background}
|
objectId={objectId!}
|
||||||
highlightColor="#444"
|
shop={shop! as GameShop}
|
||||||
>
|
>
|
||||||
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
|
<CloudSyncContextConsumer>
|
||||||
|
{({
|
||||||
<RepacksModal
|
showCloudSyncModal,
|
||||||
visible={showRepacksModal}
|
setShowCloudSyncModal,
|
||||||
startDownload={handleStartDownload}
|
showCloudSyncFilesModal,
|
||||||
onClose={() => setShowRepacksModal(false)}
|
setShowCloudSyncFilesModal,
|
||||||
/>
|
}) => (
|
||||||
|
<>
|
||||||
<ConfirmationModal
|
<CloudSyncModal
|
||||||
visible={hasNSFWContentBlocked}
|
onClose={() => setShowCloudSyncModal(false)}
|
||||||
onClose={handleNSFWContentRefuse}
|
visible={showCloudSyncModal}
|
||||||
title={t("nsfw_content_title")}
|
|
||||||
descriptionText={t("nsfw_content_description", {
|
|
||||||
title: gameTitle,
|
|
||||||
})}
|
|
||||||
confirmButtonLabel={t("allow_nsfw_content")}
|
|
||||||
cancelButtonLabel={t("refuse_nsfw_content")}
|
|
||||||
onConfirm={() => setHasNSFWContentBlocked(false)}
|
|
||||||
clickOutsideToClose={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{game && (
|
|
||||||
<GameOptionsModal
|
|
||||||
visible={showGameOptionsModal}
|
|
||||||
game={game}
|
|
||||||
onClose={() => {
|
|
||||||
setShowGameOptionsModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fromRandomizer && (
|
|
||||||
<Button
|
|
||||||
className={styles.randomizerButton}
|
|
||||||
onClick={handleRandomizerClick}
|
|
||||||
theme="outline"
|
|
||||||
disabled={!randomGame || randomizerLocked}
|
|
||||||
>
|
|
||||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
|
||||||
<Lottie
|
|
||||||
animationData={starsAnimation}
|
|
||||||
style={{
|
|
||||||
width: 70,
|
|
||||||
position: "absolute",
|
|
||||||
top: -28,
|
|
||||||
left: -27,
|
|
||||||
}}
|
|
||||||
loop
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{t("next_suggestion")}
|
<CloudSyncFilesModal
|
||||||
</Button>
|
onClose={() => setShowCloudSyncFilesModal(false)}
|
||||||
)}
|
visible={showCloudSyncFilesModal}
|
||||||
</SkeletonTheme>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CloudSyncContextConsumer>
|
||||||
|
|
||||||
|
<SkeletonTheme
|
||||||
|
baseColor={vars.color.background}
|
||||||
|
highlightColor="#444"
|
||||||
|
>
|
||||||
|
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
|
||||||
|
|
||||||
|
<RepacksModal
|
||||||
|
visible={showRepacksModal}
|
||||||
|
startDownload={handleStartDownload}
|
||||||
|
onClose={() => setShowRepacksModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={hasNSFWContentBlocked}
|
||||||
|
onClose={handleNSFWContentRefuse}
|
||||||
|
title={t("nsfw_content_title")}
|
||||||
|
descriptionText={t("nsfw_content_description", {
|
||||||
|
title: gameTitle,
|
||||||
|
})}
|
||||||
|
confirmButtonLabel={t("allow_nsfw_content")}
|
||||||
|
cancelButtonLabel={t("refuse_nsfw_content")}
|
||||||
|
onConfirm={() => setHasNSFWContentBlocked(false)}
|
||||||
|
clickOutsideToClose={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{game && (
|
||||||
|
<GameOptionsModal
|
||||||
|
visible={showGameOptionsModal}
|
||||||
|
game={game}
|
||||||
|
onClose={() => {
|
||||||
|
setShowGameOptionsModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fromRandomizer && (
|
||||||
|
<Button
|
||||||
|
className={styles.randomizerButton}
|
||||||
|
onClick={handleRandomizerClick}
|
||||||
|
theme="outline"
|
||||||
|
disabled={!randomGame || randomizerLocked}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: 16, height: 16, position: "relative" }}
|
||||||
|
>
|
||||||
|
<Lottie
|
||||||
|
animationData={starsAnimation}
|
||||||
|
style={{
|
||||||
|
width: 70,
|
||||||
|
position: "absolute",
|
||||||
|
top: -28,
|
||||||
|
left: -27,
|
||||||
|
}}
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{t("next_suggestion")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SkeletonTheme>
|
||||||
|
</CloudSyncContextProvider>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</GameDetailsContextConsumer>
|
</GameDetailsContextConsumer>
|
||||||
|
@ -18,7 +18,7 @@ export function HeroPanelActions() {
|
|||||||
game,
|
game,
|
||||||
repacks,
|
repacks,
|
||||||
isGameRunning,
|
isGameRunning,
|
||||||
objectID,
|
objectId,
|
||||||
gameTitle,
|
gameTitle,
|
||||||
setShowGameOptionsModal,
|
setShowGameOptionsModal,
|
||||||
setShowRepacksModal,
|
setShowRepacksModal,
|
||||||
@ -39,7 +39,7 @@ export function HeroPanelActions() {
|
|||||||
setToggleLibraryGameDisabled(true);
|
setToggleLibraryGameDisabled(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.electron.addGameToLibrary(objectID!, gameTitle, "steam");
|
await window.electron.addGameToLibrary(objectId!, gameTitle, "steam");
|
||||||
|
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
updateGame();
|
updateGame();
|
||||||
|
@ -29,6 +29,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
|
|||||||
const [latestRepack] = repacks;
|
const [latestRepack] = repacks;
|
||||||
|
|
||||||
if (latestRepack) {
|
if (latestRepack) {
|
||||||
|
console.log(latestRepack);
|
||||||
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
|
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
|
||||||
const repacksCount = repacks.length;
|
const repacksCount = repacks.length;
|
||||||
|
|
||||||
|
@ -65,7 +65,8 @@ export function RepacksModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkIfLastDownloadedOption = (repack: GameRepack) => {
|
const checkIfLastDownloadedOption = (repack: GameRepack) => {
|
||||||
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
|
if (!game) return false;
|
||||||
|
return repack.uris.some((uri) => uri.includes(game.uri!));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
export const sidebarSectionButton = style({
|
||||||
|
height: "72px",
|
||||||
|
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
color: vars.color.muted,
|
||||||
|
width: "100%",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
},
|
||||||
|
":active": {
|
||||||
|
opacity: vars.opacity.active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chevron = recipe({
|
||||||
|
base: {
|
||||||
|
transition: "transform ease 0.2s",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
open: {
|
||||||
|
true: {
|
||||||
|
transform: "rotate(180deg)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,38 @@
|
|||||||
|
import { ChevronDownIcon } from "@primer/octicons-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import * as styles from "./sidebar-section.css";
|
||||||
|
|
||||||
|
export interface SidebarSectionProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({ title, children }: SidebarSectionProps) {
|
||||||
|
const content = useRef<HTMLDivElement>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={styles.sidebarSectionButton}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className={styles.chevron({ open: isOpen })} />
|
||||||
|
<span>{title}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={content}
|
||||||
|
style={{
|
||||||
|
maxHeight: isOpen ? `${content.current?.scrollHeight}px` : "0",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "max-height 0.4s cubic-bezier(0, 1, 0, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -4,6 +4,7 @@ import type { HowLongToBeatCategory } from "@types";
|
|||||||
import { vars } from "@renderer/theme.css";
|
import { vars } from "@renderer/theme.css";
|
||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
|
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||||
|
|
||||||
const durationTranslation: Record<string, string> = {
|
const durationTranslation: Record<string, string> = {
|
||||||
Hours: "hours",
|
Hours: "hours",
|
||||||
@ -30,41 +31,42 @@ export function HowLongToBeatSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||||
<div className={styles.contentSidebarTitle}>
|
<SidebarSection title="HowLongToBeat">
|
||||||
<h3>HowLongToBeat</h3>
|
<ul className={styles.howLongToBeatCategoriesList}>
|
||||||
</div>
|
{howLongToBeatData
|
||||||
|
? howLongToBeatData.map((category) => (
|
||||||
<ul className={styles.howLongToBeatCategoriesList}>
|
<li
|
||||||
{howLongToBeatData
|
key={category.title}
|
||||||
? howLongToBeatData.map((category) => (
|
className={styles.howLongToBeatCategory}
|
||||||
<li key={category.title} className={styles.howLongToBeatCategory}>
|
|
||||||
<p
|
|
||||||
className={styles.howLongToBeatCategoryLabel}
|
|
||||||
style={{
|
|
||||||
fontWeight: "bold",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{category.title}
|
<p
|
||||||
</p>
|
className={styles.howLongToBeatCategoryLabel}
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
<p className={styles.howLongToBeatCategoryLabel}>
|
<p className={styles.howLongToBeatCategoryLabel}>
|
||||||
{getDuration(category.duration)}
|
{getDuration(category.duration)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{category.accuracy !== "00" && (
|
{category.accuracy !== "00" && (
|
||||||
<small>
|
<small>
|
||||||
{t("accuracy", { accuracy: category.accuracy })}
|
{t("accuracy", { accuracy: category.accuracy })}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
: Array.from({ length: 4 }).map((_, index) => (
|
: Array.from({ length: 4 }).map((_, index) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={index}
|
key={index}
|
||||||
className={styles.howLongToBeatCategorySkeleton}
|
className={styles.howLongToBeatCategorySkeleton}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</SidebarSection>
|
||||||
</SkeletonTheme>
|
</SkeletonTheme>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,8 @@ import { globalStyle, style } from "@vanilla-extract/css";
|
|||||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
|
||||||
export const contentSidebar = style({
|
export const contentSidebar = style({
|
||||||
borderLeft: `solid 1px ${vars.color.border};`,
|
borderLeft: `solid 1px ${vars.color.border}`,
|
||||||
|
backgroundColor: vars.color.darkBackground,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
"@media": {
|
"@media": {
|
||||||
@ -18,15 +19,6 @@ export const contentSidebar = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const contentSidebarTitle = style({
|
|
||||||
height: "72px",
|
|
||||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: vars.color.background,
|
|
||||||
justifyContent: "space-between",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const requirementButtonContainer = style({
|
export const requirementButtonContainer = style({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -56,7 +48,7 @@ export const requirementsDetailsSkeleton = style({
|
|||||||
|
|
||||||
export const howLongToBeatCategoriesList = style({
|
export const howLongToBeatCategoriesList = style({
|
||||||
margin: "0",
|
margin: "0",
|
||||||
padding: "16px",
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
@ -66,7 +58,8 @@ export const howLongToBeatCategory = style({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "4px",
|
gap: "4px",
|
||||||
backgroundColor: vars.color.background,
|
background:
|
||||||
|
"linear-gradient(90deg, transparent 20%, rgb(255 255 255 / 2%) 100%)",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
padding: `8px 16px`,
|
padding: `8px 16px`,
|
||||||
border: `solid 1px ${vars.color.border}`,
|
border: `solid 1px ${vars.color.border}`,
|
||||||
@ -87,6 +80,8 @@ export const statsSection = style({
|
|||||||
gap: `${SPACING_UNIT * 2}px`,
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
padding: `${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
transition: "max-height ease 0.5s",
|
||||||
|
overflow: "hidden",
|
||||||
"@media": {
|
"@media": {
|
||||||
"(min-width: 1024px)": {
|
"(min-width: 1024px)": {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button, Link } from "@renderer/components";
|
import { Button, Link } from "@renderer/components";
|
||||||
@ -8,9 +8,12 @@ import { gameDetailsContext } from "@renderer/context";
|
|||||||
import { useDate, useFormat } from "@renderer/hooks";
|
import { useDate, useFormat } from "@renderer/hooks";
|
||||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||||
|
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||||
|
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||||
|
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [_howLongToBeat, _setHowLongToBeat] = useState<{
|
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
data: HowLongToBeatCategory[] | null;
|
data: HowLongToBeatCategory[] | null;
|
||||||
}>({ isLoading: true, data: null });
|
}>({ isLoading: true, data: null });
|
||||||
@ -18,7 +21,7 @@ export function Sidebar() {
|
|||||||
const [activeRequirement, setActiveRequirement] =
|
const [activeRequirement, setActiveRequirement] =
|
||||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||||
|
|
||||||
const { gameTitle, shopDetails, stats, achievements, shop, objectID } =
|
const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
|
||||||
useContext(gameDetailsContext);
|
useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
@ -27,50 +30,60 @@ export function Sidebar() {
|
|||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
const buildGameAchievementPath = () => {
|
const buildGameAchievementPath = () => {
|
||||||
const urlParams = new URLSearchParams({ objectId: objectID!, shop });
|
const urlParams = new URLSearchParams({ objectId: objectId!, shop });
|
||||||
return `/achievements?${urlParams.toString()}`;
|
return `/achievements?${urlParams.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (objectID) {
|
if (objectId) {
|
||||||
// setHowLongToBeat({ isLoading: true, data: null });
|
setHowLongToBeat({ isLoading: true, data: null });
|
||||||
|
|
||||||
// window.electron
|
howLongToBeatEntriesTable
|
||||||
// .getHowLongToBeat(objectID, "steam", gameTitle)
|
.where({ shop, objectId })
|
||||||
// .then((howLongToBeat) => {
|
.first()
|
||||||
// setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
.then(async (cachedHowLongToBeat) => {
|
||||||
// })
|
if (cachedHowLongToBeat) {
|
||||||
// .catch(() => {
|
setHowLongToBeat({
|
||||||
// setHowLongToBeat({ isLoading: false, data: null });
|
isLoading: false,
|
||||||
// });
|
data: cachedHowLongToBeat.categories,
|
||||||
// }
|
});
|
||||||
// }, [objectID, gameTitle]);
|
} else {
|
||||||
|
try {
|
||||||
|
const howLongToBeat =
|
||||||
|
await window.electron.getHowLongToBeat(gameTitle);
|
||||||
|
|
||||||
|
if (howLongToBeat) {
|
||||||
|
howLongToBeatEntriesTable.add({
|
||||||
|
objectId,
|
||||||
|
shop: "steam",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
categories: howLongToBeat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
||||||
|
} catch (err) {
|
||||||
|
setHowLongToBeat({ isLoading: false, data: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [objectId, shop, gameTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.contentSidebar}>
|
<aside className={styles.contentSidebar}>
|
||||||
{/* <HowLongToBeatSection
|
|
||||||
howLongToBeatData={howLongToBeat.data}
|
|
||||||
isLoading={howLongToBeat.isLoading}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{achievements.length > 0 && (
|
{achievements.length > 0 && (
|
||||||
<>
|
<SidebarSection
|
||||||
<div
|
title={t("achievements", {
|
||||||
className={styles.contentSidebarTitle}
|
unlockedCount: achievements.filter((a) => a.unlocked).length,
|
||||||
style={{ border: "none" }}
|
achievementsCount: achievements.length,
|
||||||
>
|
})}
|
||||||
<h3>
|
>
|
||||||
{t("achievements")}{" "}
|
<span>
|
||||||
<span style={{ fontSize: "12px" }}>
|
<Link to={buildGameAchievementPath()}>Ver todas</Link>
|
||||||
({achievements.filter((a) => a.unlocked).length}/
|
<a></a>
|
||||||
{achievements.length})
|
</span>
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<span>
|
|
||||||
<Link to={buildGameAchievementPath()}>Ver todas</Link>
|
|
||||||
<a></a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -111,18 +124,11 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<>
|
<SidebarSection title={t("stats")}>
|
||||||
<div
|
|
||||||
className={styles.contentSidebarTitle}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
<h3>{t("stats")}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.statsSection}>
|
<div className={styles.statsSection}>
|
||||||
<div className={styles.statsCategory}>
|
<div className={styles.statsCategory}>
|
||||||
<p className={styles.statsCategoryTitle}>
|
<p className={styles.statsCategoryTitle}>
|
||||||
@ -140,40 +146,44 @@ export function Sidebar() {
|
|||||||
<p>{numberFormatter.format(stats?.playerCount)}</p>
|
<p>{numberFormatter.format(stats?.playerCount)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
|
<HowLongToBeatSection
|
||||||
<h3>{t("requirements")}</h3>
|
howLongToBeatData={howLongToBeat.data}
|
||||||
</div>
|
isLoading={howLongToBeat.isLoading}
|
||||||
<div className={styles.requirementButtonContainer}>
|
|
||||||
<Button
|
|
||||||
className={styles.requirementButton}
|
|
||||||
onClick={() => setActiveRequirement("minimum")}
|
|
||||||
theme={activeRequirement === "minimum" ? "primary" : "outline"}
|
|
||||||
>
|
|
||||||
{t("minimum")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className={styles.requirementButton}
|
|
||||||
onClick={() => setActiveRequirement("recommended")}
|
|
||||||
theme={activeRequirement === "recommended" ? "primary" : "outline"}
|
|
||||||
>
|
|
||||||
{t("recommended")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={styles.requirementsDetails}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html:
|
|
||||||
shopDetails?.pc_requirements?.[activeRequirement] ??
|
|
||||||
t(`no_${activeRequirement}_requirements`, {
|
|
||||||
gameTitle,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SidebarSection title={t("requirements")}>
|
||||||
|
<div className={styles.requirementButtonContainer}>
|
||||||
|
<Button
|
||||||
|
className={styles.requirementButton}
|
||||||
|
onClick={() => setActiveRequirement("minimum")}
|
||||||
|
theme={activeRequirement === "minimum" ? "primary" : "outline"}
|
||||||
|
>
|
||||||
|
{t("minimum")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={styles.requirementButton}
|
||||||
|
onClick={() => setActiveRequirement("recommended")}
|
||||||
|
theme={activeRequirement === "recommended" ? "primary" : "outline"}
|
||||||
|
>
|
||||||
|
{t("recommended")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.requirementsDetails}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
shopDetails?.pc_requirements?.[activeRequirement] ??
|
||||||
|
t(`no_${activeRequirement}_requirements`, {
|
||||||
|
gameTitle,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SidebarSection>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -186,7 +186,7 @@ export function Home() {
|
|||||||
))
|
))
|
||||||
: catalogue[currentCatalogueCategory].map((result) => (
|
: catalogue[currentCatalogueCategory].map((result) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={result.objectID}
|
key={result.objectId}
|
||||||
game={result}
|
game={result}
|
||||||
onClick={() => navigate(buildGameDetailsPath(result))}
|
onClick={() => navigate(buildGameDetailsPath(result))}
|
||||||
/>
|
/>
|
||||||
|
@ -115,7 +115,7 @@ export function SearchResults() {
|
|||||||
<>
|
<>
|
||||||
{searchResults.map((game) => (
|
{searchResults.map((game) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={game.objectID}
|
key={game.objectId}
|
||||||
game={game}
|
game={game}
|
||||||
onClick={() => handleGameClick(game)}
|
onClick={() => handleGameClick(game)}
|
||||||
/>
|
/>
|
||||||
|
@ -64,6 +64,8 @@ export function EditProfileModal(
|
|||||||
const { showSuccessToast, showErrorToast } = useToast();
|
const { showSuccessToast, showErrorToast } = useToast();
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
console.log(values);
|
||||||
|
|
||||||
return patchUser(values)
|
return patchUser(values)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await Promise.allSettled([fetchUserDetails(), getUserProfile()]);
|
await Promise.allSettled([fetchUserDetails(), getUserProfile()]);
|
||||||
@ -118,6 +120,8 @@ export function EditProfileModal(
|
|||||||
return { imagePath: null };
|
return { imagePath: null };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(imagePath);
|
||||||
|
|
||||||
onChange(imagePath);
|
onChange(imagePath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@ export const gameCover = style({
|
|||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
|
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
":before": {
|
":before": {
|
||||||
content: "",
|
content: "",
|
||||||
top: "0",
|
top: "0",
|
||||||
@ -14,7 +15,7 @@ export const gameCover = style({
|
|||||||
height: "172%",
|
height: "172%",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
background:
|
background:
|
||||||
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%);",
|
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%)",
|
||||||
transition: "all ease 0.3s",
|
transition: "all ease 0.3s",
|
||||||
transform: "translateY(-36%)",
|
transform: "translateY(-36%)",
|
||||||
opacity: "0.5",
|
opacity: "0.5",
|
||||||
@ -188,3 +189,15 @@ export const defaultAvatarWrapper = style({
|
|||||||
border: `solid 1px ${vars.color.border}`,
|
border: `solid 1px ${vars.color.border}`,
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const achievementsProgressBar = style({
|
||||||
|
width: "100%",
|
||||||
|
height: "4px",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
"::-webkit-progress-bar": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
|
"::-webkit-progress-value": {
|
||||||
|
backgroundColor: vars.color.muted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { userProfileContext } from "@renderer/context";
|
import { userProfileContext } from "@renderer/context";
|
||||||
import { useContext, useEffect, useMemo } from "react";
|
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
|
||||||
import * as styles from "./profile-content.css";
|
import * as styles from "./profile-content.css";
|
||||||
import { TelescopeIcon } from "@primer/octicons-react";
|
import { ClockIcon, TelescopeIcon, TrophyIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { LockedProfile } from "./locked-profile";
|
import { LockedProfile } from "./locked-profile";
|
||||||
@ -15,7 +15,11 @@ import { ReportProfile } from "../report-profile/report-profile";
|
|||||||
import { FriendsBox } from "./friends-box";
|
import { FriendsBox } from "./friends-box";
|
||||||
import { RecentGamesBox } from "./recent-games-box";
|
import { RecentGamesBox } from "./recent-games-box";
|
||||||
import { UserGame } from "@types";
|
import { UserGame } from "@types";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import {
|
||||||
|
buildGameDetailsPath,
|
||||||
|
formatDownloadProgress,
|
||||||
|
} from "@renderer/helpers";
|
||||||
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
|
|
||||||
export function ProfileContent() {
|
export function ProfileContent() {
|
||||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||||
@ -43,9 +47,25 @@ export function ProfileContent() {
|
|||||||
const buildUserGameDetailsPath = (game: UserGame) =>
|
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||||
buildGameDetailsPath({
|
buildGameDetailsPath({
|
||||||
...game,
|
...game,
|
||||||
objectID: game.objectId,
|
objectId: game.objectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formatPlayTime = useCallback(
|
||||||
|
(playTimeInSeconds = 0) => {
|
||||||
|
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) });
|
||||||
|
},
|
||||||
|
[numberFormatter, t]
|
||||||
|
);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (!userProfile) return null;
|
if (!userProfile) return null;
|
||||||
|
|
||||||
@ -98,6 +118,7 @@ export function ProfileContent() {
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
}}
|
}}
|
||||||
className={styles.game}
|
className={styles.game}
|
||||||
>
|
>
|
||||||
@ -109,13 +130,93 @@ export function ProfileContent() {
|
|||||||
className={styles.gameCover}
|
className={styles.gameCover}
|
||||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
background:
|
||||||
|
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)",
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
style={{
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
color: vars.color.muted,
|
||||||
|
border: `solid 1px ${vars.color.border}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClockIcon size={11} />
|
||||||
|
{formatPlayTime(game.playTimeInSeconds)}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: vars.color.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={13} />
|
||||||
|
<span>
|
||||||
|
{game.unlockedAchievementCount} /{" "}
|
||||||
|
{game.achievementCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{formatDownloadProgress(
|
||||||
|
game.unlockedAchievementCount /
|
||||||
|
game.achievementCount
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<progress
|
||||||
|
max={1}
|
||||||
|
value={
|
||||||
|
game.unlockedAchievementCount /
|
||||||
|
game.achievementCount
|
||||||
|
}
|
||||||
|
className={styles.achievementsProgressBar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.cover(game.objectId)}
|
src={steamUrlBuilder.cover(game.objectId)}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@ -143,6 +244,7 @@ export function ProfileContent() {
|
|||||||
userStats,
|
userStats,
|
||||||
numberFormatter,
|
numberFormatter,
|
||||||
t,
|
t,
|
||||||
|
formatPlayTime,
|
||||||
navigate,
|
navigate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ export function RecentGamesBox() {
|
|||||||
const buildUserGameDetailsPath = (game: UserGame) =>
|
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||||
buildGameDetailsPath({
|
buildGameDetailsPath({
|
||||||
...game,
|
...game,
|
||||||
objectID: game.objectId,
|
objectId: game.objectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userProfile?.recentGames.length) return null;
|
if (!userProfile?.recentGames.length) return null;
|
||||||
|
@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css";
|
|||||||
export const profileContentBox = style({
|
export const profileContentBox = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileAvatarButton = style({
|
export const profileAvatarButton = style({
|
||||||
@ -69,7 +70,7 @@ export const heroPanel = style({
|
|||||||
|
|
||||||
export const userInformation = style({
|
export const userInformation = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
|
padding: `${SPACING_UNIT * 6}px ${SPACING_UNIT * 3}px`,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
|
||||||
import * as styles from "./profile-hero.css";
|
import * as styles from "./profile-hero.css";
|
||||||
import { useCallback, useContext, useMemo, useState } from "react";
|
import { useCallback, useContext, useMemo, useState } from "react";
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
PersonAddIcon,
|
PersonAddIcon,
|
||||||
PersonIcon,
|
PersonIcon,
|
||||||
SignOutIcon,
|
SignOutIcon,
|
||||||
|
UploadIcon,
|
||||||
XCircleFillIcon,
|
XCircleFillIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
@ -36,8 +37,7 @@ export function ProfileHero() {
|
|||||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||||
|
|
||||||
const { isMe, heroBackground, getUserProfile, userProfile } =
|
const { isMe, getUserProfile, userProfile } = useContext(userProfileContext);
|
||||||
useContext(userProfileContext);
|
|
||||||
const {
|
const {
|
||||||
signOut,
|
signOut,
|
||||||
updateFriendRequestState,
|
updateFriendRequestState,
|
||||||
@ -48,6 +48,8 @@ export function ProfileHero() {
|
|||||||
|
|
||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||||
|
|
||||||
|
const [hero, setHero] = useState("");
|
||||||
|
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
const { formatDistance } = useDate();
|
const { formatDistance } = useDate();
|
||||||
|
|
||||||
@ -124,6 +126,7 @@ export function ProfileHero() {
|
|||||||
theme="outline"
|
theme="outline"
|
||||||
onClick={() => setShowEditProfileModal(true)}
|
onClick={() => setShowEditProfileModal(true)}
|
||||||
disabled={isPerformingAction}
|
disabled={isPerformingAction}
|
||||||
|
style={{ borderColor: vars.color.body }}
|
||||||
>
|
>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
{t("edit_profile")}
|
{t("edit_profile")}
|
||||||
@ -148,6 +151,7 @@ export function ProfileHero() {
|
|||||||
theme="outline"
|
theme="outline"
|
||||||
onClick={() => handleFriendAction(userProfile.id, "SEND")}
|
onClick={() => handleFriendAction(userProfile.id, "SEND")}
|
||||||
disabled={isPerformingAction}
|
disabled={isPerformingAction}
|
||||||
|
style={{ borderColor: vars.color.body }}
|
||||||
>
|
>
|
||||||
<PersonAddIcon />
|
<PersonAddIcon />
|
||||||
{t("add_friend")}
|
{t("add_friend")}
|
||||||
@ -198,6 +202,7 @@ export function ProfileHero() {
|
|||||||
handleFriendAction(userProfile.relation!.BId, "CANCEL")
|
handleFriendAction(userProfile.relation!.BId, "CANCEL")
|
||||||
}
|
}
|
||||||
disabled={isPerformingAction}
|
disabled={isPerformingAction}
|
||||||
|
style={{ borderColor: vars.color.body }}
|
||||||
>
|
>
|
||||||
<XCircleFillIcon /> {t("cancel_request")}
|
<XCircleFillIcon /> {t("cancel_request")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -212,11 +217,12 @@ export function ProfileHero() {
|
|||||||
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
|
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
|
||||||
}
|
}
|
||||||
disabled={isPerformingAction}
|
disabled={isPerformingAction}
|
||||||
|
style={{ borderColor: vars.color.body }}
|
||||||
>
|
>
|
||||||
<CheckCircleFillIcon /> {t("accept_request")}
|
<CheckCircleFillIcon /> {t("accept_request")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
theme="outline"
|
theme="danger"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleFriendAction(userProfile.relation!.AId, "REFUSED")
|
handleFriendAction(userProfile.relation!.AId, "REFUSED")
|
||||||
}
|
}
|
||||||
@ -246,7 +252,6 @@ export function ProfileHero() {
|
|||||||
if (gameRunning)
|
if (gameRunning)
|
||||||
return {
|
return {
|
||||||
...gameRunning,
|
...gameRunning,
|
||||||
objectId: gameRunning.objectID,
|
|
||||||
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
|
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -255,6 +260,35 @@ export function ProfileHero() {
|
|||||||
return userProfile?.currentGame;
|
return userProfile?.currentGame;
|
||||||
}, [isMe, userProfile, gameRunning]);
|
}, [isMe, userProfile, gameRunning]);
|
||||||
|
|
||||||
|
const handleChangeCoverClick = async () => {
|
||||||
|
const { filePaths } = await window.electron.showOpenDialog({
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "Image",
|
||||||
|
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePaths && filePaths.length > 0) {
|
||||||
|
const path = filePaths[0];
|
||||||
|
|
||||||
|
setHero(path);
|
||||||
|
|
||||||
|
// onChange(imagePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageUrl = () => {
|
||||||
|
if (hero) return `local:${hero}`;
|
||||||
|
// if (userDetails?.profileImageUrl) return userDetails.profileImageUrl;
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// const imageUrl = getImageUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* <ConfirmationModal
|
{/* <ConfirmationModal
|
||||||
@ -270,66 +304,104 @@ export function ProfileHero() {
|
|||||||
onClose={() => setShowEditProfileModal(false)}
|
onClose={() => setShowEditProfileModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section className={styles.profileContentBox}>
|
||||||
className={styles.profileContentBox}
|
<img
|
||||||
style={{ background: heroBackground }}
|
src={getImageUrl()}
|
||||||
>
|
alt=""
|
||||||
<div className={styles.userInformation}>
|
style={{
|
||||||
<button
|
position: "absolute",
|
||||||
type="button"
|
width: "100%",
|
||||||
className={styles.profileAvatarButton}
|
height: "100%",
|
||||||
onClick={handleAvatarClick}
|
objectFit: "cover",
|
||||||
>
|
}}
|
||||||
{userProfile?.profileImageUrl ? (
|
/>
|
||||||
<img
|
<div
|
||||||
className={styles.profileAvatar}
|
style={{
|
||||||
alt={userProfile?.displayName}
|
background:
|
||||||
src={userProfile?.profileImageUrl}
|
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
|
||||||
/>
|
width: "100%",
|
||||||
) : (
|
height: "100%",
|
||||||
<PersonIcon size={72} />
|
zIndex: 1,
|
||||||
)}
|
}}
|
||||||
</button>
|
>
|
||||||
|
<div className={styles.userInformation}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.profileAvatarButton}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
{userProfile?.profileImageUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.profileAvatar}
|
||||||
|
alt={userProfile?.displayName}
|
||||||
|
src={userProfile?.profileImageUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PersonIcon size={72} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className={styles.profileInformation}>
|
<div className={styles.profileInformation}>
|
||||||
{userProfile ? (
|
{userProfile ? (
|
||||||
<h2 className={styles.profileDisplayName}>
|
<h2 className={styles.profileDisplayName}>
|
||||||
{userProfile?.displayName}
|
{userProfile?.displayName}
|
||||||
</h2>
|
</h2>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton width={150} height={28} />
|
<Skeleton width={150} height={28} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentGame && (
|
{currentGame && (
|
||||||
<div className={styles.currentGameWrapper}>
|
<div className={styles.currentGameWrapper}>
|
||||||
<div className={styles.currentGameDetails}>
|
<div className={styles.currentGameDetails}>
|
||||||
<Link
|
<Link
|
||||||
to={buildGameDetailsPath({
|
to={buildGameDetailsPath({
|
||||||
...currentGame,
|
...currentGame,
|
||||||
objectID: currentGame.objectId,
|
objectId: currentGame.objectID,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{currentGame.title}
|
{currentGame.title}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small>
|
<small>
|
||||||
{t("playing_for", {
|
{t("playing_for", {
|
||||||
amount: formatDistance(
|
amount: formatDistance(
|
||||||
addSeconds(
|
addSeconds(
|
||||||
new Date(),
|
new Date(),
|
||||||
-currentGame.sessionDurationInSeconds
|
-currentGame.sessionDurationInSeconds
|
||||||
|
),
|
||||||
|
new Date()
|
||||||
),
|
),
|
||||||
new Date()
|
})}
|
||||||
),
|
</small>
|
||||||
})}
|
</div>
|
||||||
</small>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
borderColor: vars.color.body,
|
||||||
|
}}
|
||||||
|
onClick={handleChangeCoverClick}
|
||||||
|
>
|
||||||
|
<UploadIcon />
|
||||||
|
Upload cover
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.heroPanel}>
|
<div
|
||||||
|
className={styles.heroPanel}
|
||||||
|
// style={{ background: heroBackground }}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -3,20 +3,21 @@ import { formatName } from "@shared";
|
|||||||
import { GameRepack } from "@types";
|
import { GameRepack } from "@types";
|
||||||
import flexSearch from "flexsearch";
|
import flexSearch from "flexsearch";
|
||||||
|
|
||||||
const index = new flexSearch.Index();
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
repacks: [] as any[],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
|
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
|
||||||
uris: string;
|
uris: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
repacks: [] as SerializedGameRepack[],
|
||||||
|
index: null as flexSearch.Index | null,
|
||||||
|
};
|
||||||
|
|
||||||
self.onmessage = async (
|
self.onmessage = async (
|
||||||
event: MessageEvent<[string, string] | "INDEX_REPACKS">
|
event: MessageEvent<[string, string] | "INDEX_REPACKS">
|
||||||
) => {
|
) => {
|
||||||
if (event.data === "INDEX_REPACKS") {
|
if (event.data === "INDEX_REPACKS") {
|
||||||
|
state.index = new flexSearch.Index();
|
||||||
|
|
||||||
repacksTable
|
repacksTable
|
||||||
.toCollection()
|
.toCollection()
|
||||||
.sortBy("uploadDate")
|
.sortBy("uploadDate")
|
||||||
@ -26,7 +27,7 @@ self.onmessage = async (
|
|||||||
for (let i = 0; i < state.repacks.length; i++) {
|
for (let i = 0; i < state.repacks.length; i++) {
|
||||||
const repack = state.repacks[i];
|
const repack = state.repacks[i];
|
||||||
const formattedTitle = formatName(repack.title);
|
const formattedTitle = formatName(repack.title);
|
||||||
index.add(i, formattedTitle);
|
state.index!.add(i, formattedTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.postMessage("INDEXING_COMPLETE");
|
self.postMessage("INDEXING_COMPLETE");
|
||||||
@ -34,7 +35,7 @@ self.onmessage = async (
|
|||||||
} else {
|
} else {
|
||||||
const [requestId, query] = event.data;
|
const [requestId, query] = event.data;
|
||||||
|
|
||||||
const results = index.search(formatName(query)).map((index) => {
|
const results = state.index!.search(formatName(query)).map((index) => {
|
||||||
const repack = state.repacks.at(index as number) as SerializedGameRepack;
|
const repack = state.repacks.at(index as number) as SerializedGameRepack;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
461
src/shared/char-map.ts
Normal file
461
src/shared/char-map.ts
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
export const charMap = {
|
||||||
|
À: "A",
|
||||||
|
Á: "A",
|
||||||
|
Â: "A",
|
||||||
|
Ã: "A",
|
||||||
|
Ä: "A",
|
||||||
|
Å: "A",
|
||||||
|
Ấ: "A",
|
||||||
|
Ắ: "A",
|
||||||
|
Ẳ: "A",
|
||||||
|
Ẵ: "A",
|
||||||
|
Ặ: "A",
|
||||||
|
Æ: "AE",
|
||||||
|
Ầ: "A",
|
||||||
|
Ằ: "A",
|
||||||
|
Ȃ: "A",
|
||||||
|
Ả: "A",
|
||||||
|
Ạ: "A",
|
||||||
|
Ẩ: "A",
|
||||||
|
Ẫ: "A",
|
||||||
|
Ậ: "A",
|
||||||
|
Ç: "C",
|
||||||
|
Ḉ: "C",
|
||||||
|
È: "E",
|
||||||
|
É: "E",
|
||||||
|
Ê: "E",
|
||||||
|
Ë: "E",
|
||||||
|
Ế: "E",
|
||||||
|
Ḗ: "E",
|
||||||
|
Ề: "E",
|
||||||
|
Ḕ: "E",
|
||||||
|
Ḝ: "E",
|
||||||
|
Ȇ: "E",
|
||||||
|
Ẻ: "E",
|
||||||
|
Ẽ: "E",
|
||||||
|
Ẹ: "E",
|
||||||
|
Ể: "E",
|
||||||
|
Ễ: "E",
|
||||||
|
Ệ: "E",
|
||||||
|
Ì: "I",
|
||||||
|
Í: "I",
|
||||||
|
Î: "I",
|
||||||
|
Ï: "I",
|
||||||
|
Ḯ: "I",
|
||||||
|
Ȋ: "I",
|
||||||
|
Ỉ: "I",
|
||||||
|
Ị: "I",
|
||||||
|
Ð: "D",
|
||||||
|
Ñ: "N",
|
||||||
|
Ò: "O",
|
||||||
|
Ó: "O",
|
||||||
|
Ô: "O",
|
||||||
|
Õ: "O",
|
||||||
|
Ö: "O",
|
||||||
|
Ø: "O",
|
||||||
|
Ố: "O",
|
||||||
|
Ṍ: "O",
|
||||||
|
Ṓ: "O",
|
||||||
|
Ȏ: "O",
|
||||||
|
Ỏ: "O",
|
||||||
|
Ọ: "O",
|
||||||
|
Ổ: "O",
|
||||||
|
Ỗ: "O",
|
||||||
|
Ộ: "O",
|
||||||
|
Ờ: "O",
|
||||||
|
Ở: "O",
|
||||||
|
Ỡ: "O",
|
||||||
|
Ớ: "O",
|
||||||
|
Ợ: "O",
|
||||||
|
Ù: "U",
|
||||||
|
Ú: "U",
|
||||||
|
Û: "U",
|
||||||
|
Ü: "U",
|
||||||
|
Ủ: "U",
|
||||||
|
Ụ: "U",
|
||||||
|
Ử: "U",
|
||||||
|
Ữ: "U",
|
||||||
|
Ự: "U",
|
||||||
|
Ý: "Y",
|
||||||
|
à: "a",
|
||||||
|
á: "a",
|
||||||
|
â: "a",
|
||||||
|
ã: "a",
|
||||||
|
ä: "a",
|
||||||
|
å: "a",
|
||||||
|
ấ: "a",
|
||||||
|
ắ: "a",
|
||||||
|
ẳ: "a",
|
||||||
|
ẵ: "a",
|
||||||
|
ặ: "a",
|
||||||
|
æ: "ae",
|
||||||
|
ầ: "a",
|
||||||
|
ằ: "a",
|
||||||
|
ȃ: "a",
|
||||||
|
ả: "a",
|
||||||
|
ạ: "a",
|
||||||
|
ẩ: "a",
|
||||||
|
ẫ: "a",
|
||||||
|
ậ: "a",
|
||||||
|
ç: "c",
|
||||||
|
ḉ: "c",
|
||||||
|
è: "e",
|
||||||
|
é: "e",
|
||||||
|
ê: "e",
|
||||||
|
ë: "e",
|
||||||
|
ế: "e",
|
||||||
|
ḗ: "e",
|
||||||
|
ề: "e",
|
||||||
|
ḕ: "e",
|
||||||
|
ḝ: "e",
|
||||||
|
ȇ: "e",
|
||||||
|
ẻ: "e",
|
||||||
|
ẽ: "e",
|
||||||
|
ẹ: "e",
|
||||||
|
ể: "e",
|
||||||
|
ễ: "e",
|
||||||
|
ệ: "e",
|
||||||
|
ì: "i",
|
||||||
|
í: "i",
|
||||||
|
î: "i",
|
||||||
|
ï: "i",
|
||||||
|
ḯ: "i",
|
||||||
|
ȋ: "i",
|
||||||
|
ỉ: "i",
|
||||||
|
ị: "i",
|
||||||
|
ð: "d",
|
||||||
|
ñ: "n",
|
||||||
|
ò: "o",
|
||||||
|
ó: "o",
|
||||||
|
ô: "o",
|
||||||
|
õ: "o",
|
||||||
|
ö: "o",
|
||||||
|
ø: "o",
|
||||||
|
ố: "o",
|
||||||
|
ṍ: "o",
|
||||||
|
ṓ: "o",
|
||||||
|
ȏ: "o",
|
||||||
|
ỏ: "o",
|
||||||
|
ọ: "o",
|
||||||
|
ổ: "o",
|
||||||
|
ỗ: "o",
|
||||||
|
ộ: "o",
|
||||||
|
ờ: "o",
|
||||||
|
ở: "o",
|
||||||
|
ỡ: "o",
|
||||||
|
ớ: "o",
|
||||||
|
ợ: "o",
|
||||||
|
ù: "u",
|
||||||
|
ú: "u",
|
||||||
|
û: "u",
|
||||||
|
ü: "u",
|
||||||
|
ủ: "u",
|
||||||
|
ụ: "u",
|
||||||
|
ử: "u",
|
||||||
|
ữ: "u",
|
||||||
|
ự: "u",
|
||||||
|
ý: "y",
|
||||||
|
ÿ: "y",
|
||||||
|
Ā: "A",
|
||||||
|
ā: "a",
|
||||||
|
Ă: "A",
|
||||||
|
ă: "a",
|
||||||
|
Ą: "A",
|
||||||
|
ą: "a",
|
||||||
|
Ć: "C",
|
||||||
|
ć: "c",
|
||||||
|
Ĉ: "C",
|
||||||
|
ĉ: "c",
|
||||||
|
Ċ: "C",
|
||||||
|
ċ: "c",
|
||||||
|
Č: "C",
|
||||||
|
č: "c",
|
||||||
|
C̆: "C",
|
||||||
|
c̆: "c",
|
||||||
|
Ď: "D",
|
||||||
|
ď: "d",
|
||||||
|
Đ: "D",
|
||||||
|
đ: "d",
|
||||||
|
Ē: "E",
|
||||||
|
ē: "e",
|
||||||
|
Ĕ: "E",
|
||||||
|
ĕ: "e",
|
||||||
|
Ė: "E",
|
||||||
|
ė: "e",
|
||||||
|
Ę: "E",
|
||||||
|
ę: "e",
|
||||||
|
Ě: "E",
|
||||||
|
ě: "e",
|
||||||
|
Ĝ: "G",
|
||||||
|
Ǵ: "G",
|
||||||
|
ĝ: "g",
|
||||||
|
ǵ: "g",
|
||||||
|
Ğ: "G",
|
||||||
|
ğ: "g",
|
||||||
|
Ġ: "G",
|
||||||
|
ġ: "g",
|
||||||
|
Ģ: "G",
|
||||||
|
ģ: "g",
|
||||||
|
Ĥ: "H",
|
||||||
|
ĥ: "h",
|
||||||
|
Ħ: "H",
|
||||||
|
ħ: "h",
|
||||||
|
Ḫ: "H",
|
||||||
|
ḫ: "h",
|
||||||
|
Ĩ: "I",
|
||||||
|
ĩ: "i",
|
||||||
|
Ī: "I",
|
||||||
|
ī: "i",
|
||||||
|
Ĭ: "I",
|
||||||
|
ĭ: "i",
|
||||||
|
Į: "I",
|
||||||
|
į: "i",
|
||||||
|
İ: "I",
|
||||||
|
ı: "i",
|
||||||
|
IJ: "IJ",
|
||||||
|
ij: "ij",
|
||||||
|
Ĵ: "J",
|
||||||
|
ĵ: "j",
|
||||||
|
Ķ: "K",
|
||||||
|
ķ: "k",
|
||||||
|
Ḱ: "K",
|
||||||
|
ḱ: "k",
|
||||||
|
K̆: "K",
|
||||||
|
k̆: "k",
|
||||||
|
Ĺ: "L",
|
||||||
|
ĺ: "l",
|
||||||
|
Ļ: "L",
|
||||||
|
ļ: "l",
|
||||||
|
Ľ: "L",
|
||||||
|
ľ: "l",
|
||||||
|
Ŀ: "L",
|
||||||
|
ŀ: "l",
|
||||||
|
Ł: "l",
|
||||||
|
ł: "l",
|
||||||
|
Ḿ: "M",
|
||||||
|
ḿ: "m",
|
||||||
|
M̆: "M",
|
||||||
|
m̆: "m",
|
||||||
|
Ń: "N",
|
||||||
|
ń: "n",
|
||||||
|
Ņ: "N",
|
||||||
|
ņ: "n",
|
||||||
|
Ň: "N",
|
||||||
|
ň: "n",
|
||||||
|
ʼn: "n",
|
||||||
|
N̆: "N",
|
||||||
|
n̆: "n",
|
||||||
|
Ō: "O",
|
||||||
|
ō: "o",
|
||||||
|
Ŏ: "O",
|
||||||
|
ŏ: "o",
|
||||||
|
Ő: "O",
|
||||||
|
ő: "o",
|
||||||
|
Œ: "OE",
|
||||||
|
œ: "oe",
|
||||||
|
P̆: "P",
|
||||||
|
p̆: "p",
|
||||||
|
Ŕ: "R",
|
||||||
|
ŕ: "r",
|
||||||
|
Ŗ: "R",
|
||||||
|
ŗ: "r",
|
||||||
|
Ř: "R",
|
||||||
|
ř: "r",
|
||||||
|
R̆: "R",
|
||||||
|
r̆: "r",
|
||||||
|
Ȓ: "R",
|
||||||
|
ȓ: "r",
|
||||||
|
Ś: "S",
|
||||||
|
ś: "s",
|
||||||
|
Ŝ: "S",
|
||||||
|
ŝ: "s",
|
||||||
|
Ş: "S",
|
||||||
|
Ș: "S",
|
||||||
|
ș: "s",
|
||||||
|
ş: "s",
|
||||||
|
Š: "S",
|
||||||
|
š: "s",
|
||||||
|
Ţ: "T",
|
||||||
|
ţ: "t",
|
||||||
|
ț: "t",
|
||||||
|
Ț: "T",
|
||||||
|
Ť: "T",
|
||||||
|
ť: "t",
|
||||||
|
Ŧ: "T",
|
||||||
|
ŧ: "t",
|
||||||
|
T̆: "T",
|
||||||
|
t̆: "t",
|
||||||
|
Ũ: "U",
|
||||||
|
ũ: "u",
|
||||||
|
Ū: "U",
|
||||||
|
ū: "u",
|
||||||
|
Ŭ: "U",
|
||||||
|
ŭ: "u",
|
||||||
|
Ů: "U",
|
||||||
|
ů: "u",
|
||||||
|
Ű: "U",
|
||||||
|
ű: "u",
|
||||||
|
Ų: "U",
|
||||||
|
ų: "u",
|
||||||
|
Ȗ: "U",
|
||||||
|
ȗ: "u",
|
||||||
|
V̆: "V",
|
||||||
|
v̆: "v",
|
||||||
|
Ŵ: "W",
|
||||||
|
ŵ: "w",
|
||||||
|
Ẃ: "W",
|
||||||
|
ẃ: "w",
|
||||||
|
X̆: "X",
|
||||||
|
x̆: "x",
|
||||||
|
Ŷ: "Y",
|
||||||
|
ŷ: "y",
|
||||||
|
Ÿ: "Y",
|
||||||
|
Y̆: "Y",
|
||||||
|
y̆: "y",
|
||||||
|
Ź: "Z",
|
||||||
|
ź: "z",
|
||||||
|
Ż: "Z",
|
||||||
|
ż: "z",
|
||||||
|
Ž: "Z",
|
||||||
|
ž: "z",
|
||||||
|
ſ: "s",
|
||||||
|
ƒ: "f",
|
||||||
|
Ơ: "O",
|
||||||
|
ơ: "o",
|
||||||
|
Ư: "U",
|
||||||
|
ư: "u",
|
||||||
|
Ǎ: "A",
|
||||||
|
ǎ: "a",
|
||||||
|
Ǐ: "I",
|
||||||
|
ǐ: "i",
|
||||||
|
Ǒ: "O",
|
||||||
|
ǒ: "o",
|
||||||
|
Ǔ: "U",
|
||||||
|
ǔ: "u",
|
||||||
|
Ǖ: "U",
|
||||||
|
ǖ: "u",
|
||||||
|
Ǘ: "U",
|
||||||
|
ǘ: "u",
|
||||||
|
Ǚ: "U",
|
||||||
|
ǚ: "u",
|
||||||
|
Ǜ: "U",
|
||||||
|
ǜ: "u",
|
||||||
|
Ứ: "U",
|
||||||
|
ứ: "u",
|
||||||
|
Ṹ: "U",
|
||||||
|
ṹ: "u",
|
||||||
|
Ǻ: "A",
|
||||||
|
ǻ: "a",
|
||||||
|
Ǽ: "AE",
|
||||||
|
ǽ: "ae",
|
||||||
|
Ǿ: "O",
|
||||||
|
ǿ: "o",
|
||||||
|
Þ: "TH",
|
||||||
|
þ: "th",
|
||||||
|
Ṕ: "P",
|
||||||
|
ṕ: "p",
|
||||||
|
Ṥ: "S",
|
||||||
|
ṥ: "s",
|
||||||
|
X́: "X",
|
||||||
|
x́: "x",
|
||||||
|
Ѓ: "Г",
|
||||||
|
ѓ: "г",
|
||||||
|
Ќ: "К",
|
||||||
|
ќ: "к",
|
||||||
|
A̋: "A",
|
||||||
|
a̋: "a",
|
||||||
|
E̋: "E",
|
||||||
|
e̋: "e",
|
||||||
|
I̋: "I",
|
||||||
|
i̋: "i",
|
||||||
|
Ǹ: "N",
|
||||||
|
ǹ: "n",
|
||||||
|
Ồ: "O",
|
||||||
|
ồ: "o",
|
||||||
|
Ṑ: "O",
|
||||||
|
ṑ: "o",
|
||||||
|
Ừ: "U",
|
||||||
|
ừ: "u",
|
||||||
|
Ẁ: "W",
|
||||||
|
ẁ: "w",
|
||||||
|
Ỳ: "Y",
|
||||||
|
ỳ: "y",
|
||||||
|
Ȁ: "A",
|
||||||
|
ȁ: "a",
|
||||||
|
Ȅ: "E",
|
||||||
|
ȅ: "e",
|
||||||
|
Ȉ: "I",
|
||||||
|
ȉ: "i",
|
||||||
|
Ȍ: "O",
|
||||||
|
ȍ: "o",
|
||||||
|
Ȑ: "R",
|
||||||
|
ȑ: "r",
|
||||||
|
Ȕ: "U",
|
||||||
|
ȕ: "u",
|
||||||
|
B̌: "B",
|
||||||
|
b̌: "b",
|
||||||
|
Č̣: "C",
|
||||||
|
č̣: "c",
|
||||||
|
Ê̌: "E",
|
||||||
|
ê̌: "e",
|
||||||
|
F̌: "F",
|
||||||
|
f̌: "f",
|
||||||
|
Ǧ: "G",
|
||||||
|
ǧ: "g",
|
||||||
|
Ȟ: "H",
|
||||||
|
ȟ: "h",
|
||||||
|
J̌: "J",
|
||||||
|
ǰ: "j",
|
||||||
|
Ǩ: "K",
|
||||||
|
ǩ: "k",
|
||||||
|
M̌: "M",
|
||||||
|
m̌: "m",
|
||||||
|
P̌: "P",
|
||||||
|
p̌: "p",
|
||||||
|
Q̌: "Q",
|
||||||
|
q̌: "q",
|
||||||
|
Ř̩: "R",
|
||||||
|
ř̩: "r",
|
||||||
|
Ṧ: "S",
|
||||||
|
ṧ: "s",
|
||||||
|
V̌: "V",
|
||||||
|
v̌: "v",
|
||||||
|
W̌: "W",
|
||||||
|
w̌: "w",
|
||||||
|
X̌: "X",
|
||||||
|
x̌: "x",
|
||||||
|
Y̌: "Y",
|
||||||
|
y̌: "y",
|
||||||
|
A̧: "A",
|
||||||
|
a̧: "a",
|
||||||
|
B̧: "B",
|
||||||
|
b̧: "b",
|
||||||
|
Ḑ: "D",
|
||||||
|
ḑ: "d",
|
||||||
|
Ȩ: "E",
|
||||||
|
ȩ: "e",
|
||||||
|
Ɛ̧: "E",
|
||||||
|
ɛ̧: "e",
|
||||||
|
Ḩ: "H",
|
||||||
|
ḩ: "h",
|
||||||
|
I̧: "I",
|
||||||
|
i̧: "i",
|
||||||
|
Ɨ̧: "I",
|
||||||
|
ɨ̧: "i",
|
||||||
|
M̧: "M",
|
||||||
|
m̧: "m",
|
||||||
|
O̧: "O",
|
||||||
|
o̧: "o",
|
||||||
|
Q̧: "Q",
|
||||||
|
q̧: "q",
|
||||||
|
U̧: "U",
|
||||||
|
u̧: "u",
|
||||||
|
X̧: "X",
|
||||||
|
x̧: "x",
|
||||||
|
Z̧: "Z",
|
||||||
|
z̧: "z",
|
||||||
|
й: "и",
|
||||||
|
Й: "И",
|
||||||
|
ё: "е",
|
||||||
|
Ё: "Е",
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
|
import { charMap } from "./char-map";
|
||||||
import { Downloader } from "./constants";
|
import { Downloader } from "./constants";
|
||||||
|
|
||||||
export * from "./constants";
|
export * from "./constants";
|
||||||
@ -51,6 +52,12 @@ export const replaceUnderscoreWithSpace = (name: string) =>
|
|||||||
name.replace(/_/g, " ");
|
name.replace(/_/g, " ");
|
||||||
|
|
||||||
export const formatName = pipe<string>(
|
export const formatName = pipe<string>(
|
||||||
|
(str) =>
|
||||||
|
str.replace(
|
||||||
|
new RegExp(Object.keys(charMap).join("|"), "g"),
|
||||||
|
(match) => charMap[match]
|
||||||
|
),
|
||||||
|
(str) => str.toLowerCase(),
|
||||||
removeReleaseYearFromName,
|
removeReleaseYearFromName,
|
||||||
removeSpecialEditionFromName,
|
removeSpecialEditionFromName,
|
||||||
replaceUnderscoreWithSpace,
|
replaceUnderscoreWithSpace,
|
||||||
@ -91,14 +98,14 @@ export const getDownloadersForUris = (uris: string[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const steamUrlBuilder = {
|
export const steamUrlBuilder = {
|
||||||
library: (objectID: string) =>
|
library: (objectId: string) =>
|
||||||
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
|
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`,
|
||||||
libraryHero: (objectID: string) =>
|
libraryHero: (objectId: string) =>
|
||||||
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
|
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`,
|
||||||
logo: (objectID: string) =>
|
logo: (objectId: string) =>
|
||||||
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
|
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`,
|
||||||
cover: (objectID: string) =>
|
cover: (objectId: string) =>
|
||||||
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/library_600x900.jpg`,
|
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`,
|
||||||
icon: (objectID: string, clientIcon: string) =>
|
icon: (objectId: string, clientIcon: string) =>
|
||||||
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`,
|
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
|
||||||
};
|
};
|
||||||
|
14
src/types/howlongtobeat.types.ts
Normal file
14
src/types/howlongtobeat.types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export interface HowLongToBeatCategory {
|
||||||
|
title: string;
|
||||||
|
duration: string;
|
||||||
|
accuracy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HowLongToBeatResult {
|
||||||
|
game_id: number;
|
||||||
|
game_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HowLongToBeatSearchResponse {
|
||||||
|
data: HowLongToBeatResult[];
|
||||||
|
}
|
@ -39,7 +39,7 @@ export interface GameAchievement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShopDetails = SteamAppDetails & {
|
export type ShopDetails = SteamAppDetails & {
|
||||||
objectID: string;
|
objectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TorrentFile {
|
export interface TorrentFile {
|
||||||
@ -49,7 +49,7 @@ export interface TorrentFile {
|
|||||||
|
|
||||||
/* Used by the catalogue */
|
/* Used by the catalogue */
|
||||||
export interface CatalogueEntry {
|
export interface CatalogueEntry {
|
||||||
objectID: string;
|
objectId: string;
|
||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
title: string;
|
title: string;
|
||||||
/* Epic Games covers cannot be guessed with objectID */
|
/* Epic Games covers cannot be guessed with objectID */
|
||||||
@ -64,6 +64,8 @@ export interface UserGame {
|
|||||||
cover: string;
|
cover: string;
|
||||||
playTimeInSeconds: number;
|
playTimeInSeconds: number;
|
||||||
lastTimePlayed: Date | null;
|
lastTimePlayed: Date | null;
|
||||||
|
unlockedAchievementCount: number;
|
||||||
|
achievementCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadQueue {
|
export interface DownloadQueue {
|
||||||
@ -128,15 +130,9 @@ export interface UserPreferences {
|
|||||||
runAtStartup: boolean;
|
runAtStartup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HowLongToBeatCategory {
|
|
||||||
title: string;
|
|
||||||
duration: string;
|
|
||||||
accuracy: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Steam250Game {
|
export interface Steam250Game {
|
||||||
title: string;
|
title: string;
|
||||||
objectID: string;
|
objectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SteamGame {
|
export interface SteamGame {
|
||||||
@ -152,7 +148,7 @@ export type AppUpdaterEvent =
|
|||||||
/* Events */
|
/* Events */
|
||||||
export interface StartGameDownloadPayload {
|
export interface StartGameDownloadPayload {
|
||||||
repackId: number;
|
repackId: number;
|
||||||
objectID: string;
|
objectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
uri: string;
|
uri: string;
|
||||||
@ -197,7 +193,7 @@ export interface UserRelation {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectID"> {
|
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectId"> {
|
||||||
objectId: string;
|
objectId: string;
|
||||||
sessionDurationInSeconds: number;
|
sessionDurationInSeconds: number;
|
||||||
}
|
}
|
||||||
@ -290,5 +286,16 @@ export type GameAchievementFiles = {
|
|||||||
[id: string]: AchievementFile[];
|
[id: string]: AchievementFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GameArtifact {
|
||||||
|
id: string;
|
||||||
|
artifactLengthInBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
hostname: string;
|
||||||
|
downloadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export * from "./steam.types";
|
export * from "./steam.types";
|
||||||
export * from "./real-debrid.types";
|
export * from "./real-debrid.types";
|
||||||
|
export * from "./ludusavi.types";
|
||||||
|
export * from "./howlongtobeat.types";
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user