fix: fixing seeding on level

This commit is contained in:
Chubby Granny Chaser 2025-02-03 13:57:03 +00:00
commit 5574f6cb20
No known key found for this signature in database
231 changed files with 5236 additions and 4761 deletions

View file

@ -6,7 +6,6 @@ import {
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
import { sentryVitePlugin } from "@sentry/vite-plugin";
@ -55,7 +54,6 @@ export default defineConfig(({ mode }) => {
plugins: [
svgr(),
react(),
vanillaExtractPlugin(),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",

View file

@ -41,9 +41,6 @@
"@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^8.47.0",
"@sentry/vite-plugin": "^2.22.7",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.2",
"@vanilla-extract/recipes": "^0.5.2",
"auto-launch": "^5.0.6",
"axios": "^1.7.9",
"better-sqlite3": "^11.7.0",
@ -90,9 +87,8 @@
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",
"@types/folder-hash": "^4.0.4",
"@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/jsonwebtoken": "^9.0.8",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.7",
"@types/parse-torrent": "^5.8.7",
@ -100,14 +96,13 @@
"@types/react-dom": "^18.2.18",
"@types/sound-play": "^1.1.3",
"@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^31.7.6",
"electron": "^31.7.7",
"electron-builder": "^25.1.8",
"electron-vite": "^2.0.0",
"electron-vite": "^2.3.0",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^9.1.7",
"prettier": "^3.4.2",

View file

@ -11,11 +11,12 @@ class HttpDownloader:
)
)
def start_download(self, url: str, save_path: str, header: str):
def start_download(self, url: str, save_path: str, header: str, out: str = None):
if self.download:
self.aria2.resume([self.download])
else:
downloads = self.aria2.add(url, options={"header": header, "dir": save_path})
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
self.download = downloads[0]
def pause_download(self):

View file

@ -28,14 +28,14 @@ if start_download_payload:
torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader
try:
torrent_downloader.start_download(initial_download['url'], initial_download['save_path'], "")
torrent_downloader.start_download(initial_download['url'], initial_download['save_path'])
except Exception as e:
print("Error starting torrent download", e)
else:
http_downloader = HttpDownloader()
downloads[initial_download['game_id']] = http_downloader
try:
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'))
http_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
except Exception as e:
print("Error starting http download", e)
@ -45,7 +45,7 @@ if start_seeding_payload:
torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode)
downloads[seed['game_id']] = torrent_downloader
try:
torrent_downloader.start_download(seed['url'], seed['save_path'], "")
torrent_downloader.start_download(seed['url'], seed['save_path'])
except Exception as e:
print("Error starting seeding", e)
@ -140,18 +140,18 @@ def action():
if url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'], "")
existing_downloader.start_download(url, data['save_path'])
else:
torrent_downloader = TorrentDownloader(torrent_session)
downloads[game_id] = torrent_downloader
torrent_downloader.start_download(url, data['save_path'], "")
torrent_downloader.start_download(url, data['save_path'])
else:
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'))
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
else:
http_downloader = HttpDownloader()
downloads[game_id] = http_downloader
http_downloader.start_download(url, data['save_path'], data.get('header'))
http_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
downloading_game_id = game_id
@ -167,7 +167,7 @@ def action():
elif action == 'resume_seeding':
torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode)
downloads[game_id] = torrent_downloader
torrent_downloader.start_download(data['url'], data['save_path'], "")
torrent_downloader.start_download(data['url'], data['save_path'])
elif action == 'pause_seeding':
downloader = downloads.get(game_id)
if downloader:

View file

@ -102,7 +102,7 @@ class TorrentDownloader:
"http://bvarf.tracker.sh:2086/announce",
]
def start_download(self, magnet: str, save_path: str, header: str):
def start_download(self, magnet: str, save_path: str):
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers, 'flags': self.flags}
self.torrent_handle = self.session.add_torrent(params)
self.torrent_handle.resume()

File diff suppressed because one or more lines are too long

View file

@ -236,13 +236,13 @@
"behavior": "السلوك",
"download_sources": "مصادر التنزيل",
"language": "اللغة",
"real_debrid_api_token": "رمز API",
"api_token": "رمز API",
"enable_real_debrid": "تفعيل Real-Debrid",
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
"real_debrid_invalid_token": "رمز API غير صالح",
"real_debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"debrid_invalid_token": "رمز API غير صالح",
"debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
"real_debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
"debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
"save_changes": "حفظ التغييرات",
"changes_saved": "تم حفظ التغييرات بنجاح",
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",

View file

@ -230,13 +230,13 @@
"behavior": "Поведение",
"download_sources": "Източници за изтегляне",
"language": "Език",
"real_debrid_api_token": "API Токен",
"api_token": "API Токен",
"enable_real_debrid": "Включи Real-Debrid",
"real_debrid_description": "Real-Debrid е неограничен даунлоудър, който ви позволява бързо да изтегляте файлове, ограничени само от скоростта на интернет..",
"real_debrid_invalid_token": "Невалиден API токен",
"real_debrid_api_token_hint": "Вземете своя API токен <0>тук</0>",
"debrid_invalid_token": "Невалиден API токен",
"debrid_api_token_hint": "Вземете своя API токен <0>тук</0>",
"real_debrid_free_account_error": "Акаунтът \"{{username}}\" е безплатен акаунт. Моля абонирай се за Real-Debrid",
"real_debrid_linked_message": "Акаунтът \"{{username}}\" е свързан",
"debrid_linked_message": "Акаунтът \"{{username}}\" е свързан",
"save_changes": "Запази промените",
"changes_saved": "Промените са успешно запазни",
"download_sources_description": "Hydra ще извлича връзките за изтегляне от тези източници. URL адресът на източника трябва да е директна връзка към .json файл, съдържащ връзките за изтегляне.",

View file

@ -161,13 +161,13 @@
"behavior": "Comportament",
"download_sources": "Fonts de descàrrega",
"language": "Idioma",
"real_debrid_api_token": "Testimoni API",
"api_token": "Testimoni API",
"enable_real_debrid": "Activa el Real Debrid",
"real_debrid_description": "Real-Debrid és un programa de descàrrega sense restriccions que us permet descarregar fitxers a l'instant i al màxim de la vostra velocitat d'Internet.",
"real_debrid_invalid_token": "Invalida el testimoni de l'API",
"real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.",
"debrid_invalid_token": "Invalida el testimoni de l'API",
"debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí</0>.",
"real_debrid_free_account_error": "L'usuari \"{{username}}\" és un compte gratuït. Si us plau subscriu-te a Real-Debrid",
"real_debrid_linked_message": "Compte \"{{username}}\" vinculat",
"debrid_linked_message": "Compte \"{{username}}\" vinculat",
"save_changes": "Desa els canvis",
"changes_saved": "Els canvis s'han desat correctament",
"download_sources_description": "Hydra buscarà els enllaços de descàrrega d'aquestes fonts. L'URL d'origen ha de ser un enllaç directe a un fitxer .json que contingui els enllaços de descàrrega.",

View file

@ -214,13 +214,13 @@
"behavior": "Chování",
"download_sources": "Zdroje stahování",
"language": "Jazyk",
"real_debrid_api_token": "API Token",
"api_token": "API Token",
"enable_real_debrid": "Povolit Real-Debrid",
"real_debrid_description": "Real-Debrid je neomezený správce stahování, který umožňuje stahovat soubory v nejvyšší rychlosti vašeho internetu.",
"real_debrid_invalid_token": "Neplatný API token",
"real_debrid_api_token_hint": "API token můžeš sehnat <0>zde</0>",
"debrid_invalid_token": "Neplatný API token",
"debrid_api_token_hint": "API token můžeš sehnat <0>zde</0>",
"real_debrid_free_account_error": "Účet \"{{username}}\" má základní úroveň. Prosím předplaťte si Real-Debrid",
"real_debrid_linked_message": "Účet \"{{username}}\" je propojen",
"debrid_linked_message": "Účet \"{{username}}\" je propojen",
"save_changes": "Uložit změny",
"changes_saved": "Změny úspěšně uloženy",
"download_sources_description": "Hydra bude odsud sbírat soubory. Zdrojový odkaz musí být .json soubor obsahující odkazy na soubory.",

View file

@ -177,13 +177,13 @@
"behavior": "Opførsel",
"download_sources": "Download kilder",
"language": "Sprog",
"real_debrid_api_token": "API nøgle",
"api_token": "API nøgle",
"enable_real_debrid": "Slå Real-Debrid til",
"real_debrid_description": "Real-Debrid er en ubegrænset downloader der gør det muligt for dig at downloade filer med det samme og med den bedste udnyttelse af din internet hastighed.",
"real_debrid_invalid_token": "Ugyldig API nøgle",
"real_debrid_api_token_hint": "Du kan få din API nøgle <0>her</0>",
"debrid_invalid_token": "Ugyldig API nøgle",
"debrid_api_token_hint": "Du kan få din API nøgle <0>her</0>",
"real_debrid_free_account_error": "Brugeren \"{{username}}\" er en gratis bruger. Venligst abbonér på Real-Debrid",
"real_debrid_linked_message": "Brugeren \"{{username}}\" er forbundet",
"debrid_linked_message": "Brugeren \"{{username}}\" er forbundet",
"save_changes": "Gem ændringer",
"changes_saved": "Ændringer gemt successfuldt",
"download_sources_description": "Hydra vil hente download links fra disse kilder. Kilde URLen skal være et direkte link til en .json fil der indeholder download linkene.",

View file

@ -161,13 +161,13 @@
"behavior": "Verhalten",
"download_sources": "Download-Quellen",
"language": "Sprache",
"real_debrid_api_token": "API Token",
"api_token": "API Token",
"enable_real_debrid": "Real-Debrid aktivieren",
"real_debrid_description": "Real-Debrid ist ein unrestriktiver Downloader, der es dir ermöglicht Dateien sofort und mit deiner maximalen Internetgeschwindigkeit herunterzuladen.",
"real_debrid_invalid_token": "API token nicht gültig",
"real_debrid_api_token_hint": "<0>Hier</0> kannst du dir deinen API Token holen",
"debrid_invalid_token": "API token nicht gültig",
"debrid_api_token_hint": "<0>Hier</0> kannst du dir deinen API Token holen",
"real_debrid_free_account_error": "Das Konto \"{{username}}\" ist ein gratis account. Bitte abonniere Real-Debrid",
"real_debrid_linked_message": "Konto \"{{username}}\" verknüpft",
"debrid_linked_message": "Konto \"{{username}}\" verknüpft",
"save_changes": "Änderungen speichern",
"changes_saved": "Änderungen erfolgreich gespeichert",
"download_sources_description": "Hydra wird die Download-Links von diesen Quellen abrufen. Die Quell-URL muss ein direkter Link zu einer .json Datei, welche die Download-Links enthält, sein.",

View file

@ -240,13 +240,13 @@
"behavior": "Behavior",
"download_sources": "Download sources",
"language": "Language",
"real_debrid_api_token": "API Token",
"api_token": "API Token",
"enable_real_debrid": "Enable Real-Debrid",
"real_debrid_description": "Real-Debrid is an unrestricted downloader that allows you to quickly download files, only limited by your internet speed.",
"real_debrid_invalid_token": "Invalid API token",
"real_debrid_api_token_hint": "You can get your API token <0>here</0>",
"debrid_invalid_token": "Invalid API token",
"debrid_api_token_hint": "You can get your API token <0>here</0>",
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
"real_debrid_linked_message": "Account \"{{username}}\" linked",
"debrid_linked_message": "Account \"{{username}}\" linked",
"save_changes": "Save changes",
"changes_saved": "Changes successfully saved",
"download_sources_description": "Hydra will fetch the download links from these sources. The source URL must be a direct link to a .json file containing the download links.",
@ -300,7 +300,11 @@
"become_subscriber": "Be Hydra Cloud",
"subscription_renew_cancelled": "Automatic renewal is disabled",
"subscription_renews_on": "Your subscription renews on {{date}}",
"bill_sent_until": "Your next bill will be sent until this day"
"bill_sent_until": "Your next bill will be sent until this day",
"enable_torbox": "Enable Torbox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
"real_debrid_account_linked": "Real-Debrid account linked"
},
"notifications": {
"download_complete": "Download complete",

View file

@ -236,13 +236,13 @@
"behavior": "Otros",
"download_sources": "Fuentes de descarga",
"language": "Idioma",
"real_debrid_api_token": "Token API",
"api_token": "Token API",
"enable_real_debrid": "Activar Real-Debrid",
"real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.",
"real_debrid_invalid_token": "Token de API inválido",
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
"debrid_invalid_token": "Token de API inválido",
"debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
"real_debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"save_changes": "Guardar cambios",
"changes_saved": "Ajustes guardados exitosamente",
"download_sources_description": "Hydra buscará los enlaces de descarga de estas fuentes. La URL de origen debe ser un enlace directo a un archivo .json que contenga los enlaces de descarga",

View file

@ -213,13 +213,13 @@
"behavior": "Käitumine",
"download_sources": "Allalaadimise allikad",
"language": "Keel",
"real_debrid_api_token": "API Võti",
"api_token": "API Võti",
"enable_real_debrid": "Luba Real-Debrid",
"real_debrid_description": "Real-Debrid on piiranguteta allalaadija, mis võimaldab sul faile alla laadida koheselt ja sinu internetiühenduse parima kiirusega.",
"real_debrid_invalid_token": "Vigane API võti",
"real_debrid_api_token_hint": "Sa saad oma API võtme <0>siit</0>",
"debrid_invalid_token": "Vigane API võti",
"debrid_api_token_hint": "Sa saad oma API võtme <0>siit</0>",
"real_debrid_free_account_error": "Konto \"{{username}}\" on tasuta konto. Palun telli Real-Debrid",
"real_debrid_linked_message": "Konto \"{{username}}\" ühendatud",
"debrid_linked_message": "Konto \"{{username}}\" ühendatud",
"save_changes": "Salvesta muudatused",
"changes_saved": "Muudatused edukalt salvestatud",
"download_sources_description": "Hydra laeb allalaadimise lingid nendest allikatest. Allika URL peab olema otsene link .json failile, mis sisaldab allalaadimise linke.",

View file

@ -110,7 +110,7 @@
"general": "کلی",
"behavior": "رفتار",
"enable_real_debrid": "فعال‌سازی Real-Debrid",
"real_debrid_api_token_hint": "کلید API خود را از <ب0>اینجا</0> بگیرید.",
"debrid_api_token_hint": "کلید API خود را از <ب0>اینجا</0> بگیرید.",
"save_changes": "ذخیره تغییرات"
},
"notifications": {

View file

@ -161,13 +161,13 @@
"behavior": "Perilaku",
"download_sources": "Sumber unduhan",
"language": "Bahasa",
"real_debrid_api_token": "Token API",
"api_token": "Token API",
"enable_real_debrid": "Aktifkan Real-Debrid",
"real_debrid_description": "Real-Debrid adalah downloader tanpa batas yang memungkinkan kamu untuk mengunduh file dengan cepat dan pada kecepatan terbaik dari Internet kamu.",
"real_debrid_invalid_token": "Token API tidak valid",
"real_debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini</0>",
"debrid_invalid_token": "Token API tidak valid",
"debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini</0>",
"real_debrid_free_account_error": "Akun \"{{username}}\" adalah akun gratis. Silakan berlangganan Real-Debrid",
"real_debrid_linked_message": "Akun \"{{username}}\" terhubung",
"debrid_linked_message": "Akun \"{{username}}\" terhubung",
"save_changes": "Simpan perubahan",
"changes_saved": "Perubahan disimpan berhasil",
"download_sources_description": "Hydra akan mencari link unduhan dari sini. URL harus menuju file .json dengan link unduhan.",

View file

@ -118,7 +118,7 @@
"general": "Generale",
"behavior": "Comportamento",
"enable_real_debrid": "Abilita Real Debrid",
"real_debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here</0>",
"debrid_api_token_hint": "Puoi trovare la tua chiave API <0>here</0>",
"save_changes": "Salva modifiche"
},
"notifications": {

View file

@ -159,13 +159,13 @@
"behavior": "Мінез-құлық",
"download_sources": "Жүктеу көздері",
"language": "Тіл",
"real_debrid_api_token": "API Кілті",
"api_token": "API Кілті",
"enable_real_debrid": "Real-Debrid-ті қосу",
"real_debrid_description": "Real-Debrid - бұл шектеусіз жүктеуші, ол интернетте орналастырылған файлдарды тез жүктеуге немесе жеке желі арқылы кез келген блоктарды айналып өтіп, оларды бірден плеерге беруге мүмкіндік береді.",
"real_debrid_invalid_token": "Қате API кілті",
"real_debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады",
"debrid_invalid_token": "Қате API кілті",
"debrid_api_token_hint": "API кілтін <0>осы жерден</0> алуға болады",
"real_debrid_free_account_error": "\"{{username}}\" аккаунты жазылымға ие емес. Real-Debrid жазылымын алыңыз",
"real_debrid_linked_message": "\"{{username}}\" аккаунты байланған",
"debrid_linked_message": "\"{{username}}\" аккаунты байланған",
"save_changes": "Өзгерістерді сақтау",
"changes_saved": "Өзгерістер сәтті сақталды",
"download_sources_description": "Hydra осы көздерден жүктеу сілтемелерін алады. URL-да жүктеу сілтемелері бар .json файлына тікелей сілтеме болуы керек.",

View file

@ -110,7 +110,7 @@
"general": "일반",
"behavior": "행동",
"enable_real_debrid": "Real-Debrid 활성화",
"real_debrid_api_token_hint": "API 키를 <0>이곳</0>에서 얻으세요.",
"debrid_api_token_hint": "API 키를 <0>이곳</0>에서 얻으세요.",
"save_changes": "변경 사항 저장"
},
"notifications": {

View file

@ -177,13 +177,13 @@
"behavior": "Oppførsel",
"download_sources": "Nedlastingskilder",
"language": "Språk",
"real_debrid_api_token": "API nøkkel",
"api_token": "API nøkkel",
"enable_real_debrid": "Slå på Real-Debrid",
"real_debrid_description": "Real-Debrid er en ubegrenset nedlaster som gør det mulig for deg å laste ned filer med en gang og med den beste utnyttelsen av internethastigheten din.",
"real_debrid_invalid_token": "Ugyldig API nøkkel",
"real_debrid_api_token_hint": "Du kan få API nøkkelen din <0>her</0>",
"debrid_invalid_token": "Ugyldig API nøkkel",
"debrid_api_token_hint": "Du kan få API nøkkelen din <0>her</0>",
"real_debrid_free_account_error": "Brukeren \"{{username}}\" er en gratis bruker. Vennligst abboner på Real-Debrid",
"real_debrid_linked_message": "Brukeren \"{{username}}\" er forbunnet",
"debrid_linked_message": "Brukeren \"{{username}}\" er forbunnet",
"save_changes": "Lagre endringer",
"changes_saved": "Lagring av endringer vellykket",
"download_sources_description": "Hydra vil hente nedlastingslenker fra disse kildene. Kilde URLen skal være en direkte lenke til en .json fil som inneholder nedlastingslenkene.",

View file

@ -111,7 +111,7 @@
"general": "Algemeen",
"behavior": "Gedrag",
"enable_real_debrid": "Enable Real-Debrid",
"real_debrid_api_token_hint": "U kunt uw API-sleutel <0>hier</0> verkrijgen.",
"debrid_api_token_hint": "U kunt uw API-sleutel <0>hier</0> verkrijgen.",
"save_changes": "Wijzigingen opslaan"
},
"notifications": {

View file

@ -119,7 +119,7 @@
"behavior": "Zachowania",
"language": "Język",
"enable_real_debrid": "Włącz Real-Debrid",
"real_debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj</0>",
"debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj</0>",
"save_changes": "Zapisz zmiany"
},
"notifications": {

View file

@ -229,13 +229,13 @@
"behavior": "Comportamento",
"download_sources": "Fontes de download",
"language": "Idioma",
"real_debrid_api_token": "Token de API",
"api_token": "Token de API",
"enable_real_debrid": "Habilitar Real-Debrid",
"real_debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>",
"debrid_api_token_hint": "Você pode obter seu token de API <0>aqui</0>",
"real_debrid_description": "O Real-Debrid é um downloader sem restrições que permite baixar arquivos instantaneamente e com a melhor velocidade da sua Internet.",
"real_debrid_invalid_token": "Token de API inválido",
"debrid_invalid_token": "Token de API inválido",
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"debrid_linked_message": "Conta \"{{username}}\" vinculada",
"save_changes": "Salvar mudanças",
"changes_saved": "Ajustes salvos com sucesso",
"download_sources_description": "Hydra vai buscar links de download em todas as fontes habilitadas. A URL da fonte deve ser um link direto para um arquivo .json contendo uma lista de links.",
@ -289,7 +289,11 @@
"become_subscriber": "Seja Hydra Cloud",
"subscription_renew_cancelled": "A renovação automática está desativada",
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia"
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
"enable_torbox": "Habilitar Torbox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
"real_debrid_account_linked": "Conta Real-Debrid associada"
},
"notifications": {
"download_complete": "Download concluído",

View file

@ -205,13 +205,13 @@
"behavior": "Comportamento",
"download_sources": "Fontes de transferência",
"language": "Idioma",
"real_debrid_api_token": "Token de API",
"api_token": "Token de API",
"enable_real_debrid": "Ativar Real-Debrid",
"real_debrid_api_token_hint": "Podes obter o teu token de API <0>aqui</0>",
"debrid_api_token_hint": "Podes obter o teu token de API <0>aqui</0>",
"real_debrid_description": "O Real-Debrid é um downloader sem restrições que permite descarregar ficheiros instantaneamente e com a melhor velocidade da tua Internet.",
"real_debrid_invalid_token": "Token de API inválido",
"debrid_invalid_token": "Token de API inválido",
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, subscreve o Real-Debrid",
"real_debrid_linked_message": "Conta \"{{username}}\" associada",
"debrid_linked_message": "Conta \"{{username}}\" associada",
"save_changes": "Guardar alterações",
"changes_saved": "Alterações guardadas com sucesso",
"download_sources_description": "O Hydra vai procurar links de download em todas as fontes ativadas. O URL da fonte deve ser um link direto para um ficheiro .json que contenha uma lista de links.",

View file

@ -124,13 +124,13 @@
"general": "General",
"behavior": "Comportament",
"language": "Limbă",
"real_debrid_api_token": "Token API",
"api_token": "Token API",
"enable_real_debrid": "Activează Real-Debrid",
"real_debrid_description": "Real-Debrid este un descărcător fără restricții care îți permite să descarci fișiere instantaneu și la cea mai bună viteză a internetului tău.",
"real_debrid_invalid_token": "Token API invalid",
"real_debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>",
"debrid_invalid_token": "Token API invalid",
"debrid_api_token_hint": "Poți obține token-ul tău API <0>aici</0>",
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
"real_debrid_linked_message": "Contul \"{{username}}\" a fost legat",
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
"save_changes": "Salvează modificările",
"changes_saved": "Modificările au fost salvate cu succes"
},

View file

@ -237,13 +237,13 @@
"behavior": "Поведение",
"download_sources": "Источники загрузки",
"language": "Язык",
"real_debrid_api_token": "API Ключ",
"api_token": "API Ключ",
"enable_real_debrid": "Включить Real-Debrid",
"real_debrid_description": "Real-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы, размещенные в Интернете, или мгновенно передавать их в плеер через частную сеть, позволяющую обходить любые блокировки.",
"real_debrid_invalid_token": "Неверный API ключ",
"real_debrid_api_token_hint": "API ключ можно получить <0>здесь</0>",
"debrid_invalid_token": "Неверный API ключ",
"debrid_api_token_hint": "API ключ можно получить <0>здесь</0>",
"real_debrid_free_account_error": "Аккаунт \"{{username}}\" - не имеет подписки. Пожалуйста, оформите подписку на Real-Debrid",
"real_debrid_linked_message": "Привязан аккаунт \"{{username}}\"",
"debrid_linked_message": "Привязан аккаунт \"{{username}}\"",
"save_changes": "Сохранить изменения",
"changes_saved": "Изменения успешно сохранены",
"download_sources_description": "Hydra будет получать ссылки на загрузки из этих источников. URL должна содержать прямую ссылку на .json-файл с ссылками для загрузок.",

View file

@ -236,13 +236,13 @@
"behavior": "Davranış",
"download_sources": "İndirme kaynakları",
"language": "Dil",
"real_debrid_api_token": "API Anahtarı",
"api_token": "API Anahtarı",
"enable_real_debrid": "Real-Debrid'i Etkinleştir",
"real_debrid_description": "Real-Debrid, yalnızca internet hızınızla sınırlı olarak hızlı dosya indirmenizi sağlayan sınırsız bir indirici.",
"real_debrid_invalid_token": "Geçersiz API anahtarı",
"real_debrid_api_token_hint": "API anahtarınızı <0>buradan</0> alabilirsiniz",
"debrid_invalid_token": "Geçersiz API anahtarı",
"debrid_api_token_hint": "API anahtarınızı <0>buradan</0> alabilirsiniz",
"real_debrid_free_account_error": "\"{{username}}\" hesabı ücretsiz bir hesaptır. Lütfen Real-Debrid abonesi olun",
"real_debrid_linked_message": "\"{{username}}\" hesabı bağlandı",
"debrid_linked_message": "\"{{username}}\" hesabı bağlandı",
"save_changes": "Değişiklikleri Kaydet",
"changes_saved": "Değişiklikler başarıyla kaydedildi",
"download_sources_description": "Hydra, indirme bağlantılarını bu kaynaklardan alacak. Kaynak URL, indirme bağlantılarını içeren bir .json dosyasına doğrudan bir bağlantı olmalıdır.",

View file

@ -174,13 +174,13 @@
"import": "Імпортувати",
"insert_valid_json_url": "Вставте дійсний URL JSON-файлу",
"language": "Мова",
"real_debrid_api_token": "API-токен",
"real_debrid_api_token_hint": "API токен можливо отримати <0>тут</0>",
"api_token": "API-токен",
"debrid_api_token_hint": "API токен можливо отримати <0>тут</0>",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"real_debrid_description": "Real-Debrid — це необмежений завантажувач, який дозволяє швидко завантажувати файли, розміщені в Інтернеті, або миттєво передавати їх у плеєр через приватну мережу, що дозволяє обходити будь-які блокування.",
"real_debrid_free_account_error": "Акаунт \"{{username}}\" - не має наявної підписки. Будь ласка, оформіть підписку на Real-Debrid",
"real_debrid_invalid_token": "Невірний API-токен",
"real_debrid_linked_message": "Акаунт \"{{username}}\" привязаний",
"debrid_invalid_token": "Невірний API-токен",
"debrid_linked_message": "Акаунт \"{{username}}\" привязаний",
"remove_download_source": "Видалити",
"removed_download_source": "Джерело завантажень було видалено",
"save_changes": "Зберегти зміни",

View file

@ -213,13 +213,13 @@
"behavior": "行为",
"download_sources": "下载源",
"language": "语言",
"real_debrid_api_token": "API 令牌",
"api_token": "API 令牌",
"enable_real_debrid": "启用 Real-Debrid",
"real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。",
"real_debrid_invalid_token": "无效的 API 令牌",
"real_debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.",
"debrid_invalid_token": "无效的 API 令牌",
"debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.",
"real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid",
"real_debrid_linked_message": "账户 \"{{username}}\" 已链接",
"debrid_linked_message": "账户 \"{{username}}\" 已链接",
"save_changes": "保存更改",
"changes_saved": "更改已成功保存",
"download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。",

View file

@ -1,6 +1,5 @@
import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { PythonRPC } from "@main/services/python-rpc";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
@ -25,9 +24,6 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
/* Cancels any ongoing downloads */
DownloadManager.cancelDownload();
/* Disconnects libtorrent */
PythonRPC.kill();
HydraApi.handleSignOut();
await Promise.all([

View file

@ -46,6 +46,7 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/put-download-source";
import "./auth/sign-out";
import "./auth/open-auth-window";

View file

@ -46,9 +46,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game!);
updateLocalUnlockedAchivements(game);
createGame(game!).catch(() => {});
createGame(game).catch(() => {});
}
};

View file

@ -76,10 +76,10 @@ const startGameDownload = async (
queued: true,
};
await downloadsSublevel.put(gameKey, download);
try {
await DownloadManager.startDownload(download);
await DownloadManager.startDownload(download).then(() => {
return downloadsSublevel.put(gameKey, download);
});
const updatedGame = await gamesSublevel.get(gameKey);
@ -113,6 +113,10 @@ const startGameDownload = async (
error: DownloadError.RealDebridAccountNotAuthorized,
};
}
if (downloader === Downloader.TorBox) {
return { ok: false, error: err.response?.data?.detail };
}
}
if (err instanceof Error) {

View file

@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import { TorBoxClient } from "@main/services/download/torbox";
const authenticateTorBox = async (
_event: Electron.IpcMainInvokeEvent,
apiToken: string
) => {
TorBoxClient.authorize(apiToken);
const user = await TorBoxClient.getUser();
return user;
};
registerEvent("authenticateTorBox", authenticateTorBox);

View file

@ -15,6 +15,12 @@ const getUserPreferences = async () =>
);
}
if (userPreferences?.torBoxApiToken) {
userPreferences.torBoxApiToken = Crypto.decrypt(
userPreferences.torBoxApiToken
);
}
return userPreferences;
});

View file

@ -30,6 +30,10 @@ const updateUserPreferences = async (
);
}
if (preferences.torBoxApiToken) {
preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken);
}
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}

View file

@ -1,3 +1,2 @@
export { db } from "./level";
export * from "./sublevels";

View file

@ -2,5 +2,4 @@ export * from "./downloads";
export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";

View file

@ -20,6 +20,7 @@ import {
} from "./level";
import { Auth, User, type UserPreferences } from "@types";
import { knexClient } from "./knex-client";
import { TorBoxClient } from "./services/download/torbox";
export const loadState = async () => {
const userPreferences = await migrateFromSqlite().then(async () => {
@ -42,6 +43,10 @@ export const loadState = async () => {
);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
}
Ludusavi.addManifestToLudusaviConfig();
HydraApi.setupApi().then(() => {

View file

@ -15,6 +15,7 @@ import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
export class DownloadManager {
private static downloadingGameId: string | null = null;
@ -233,7 +234,9 @@ export class DownloadManager {
});
WindowManager.mainWindow?.setProgressBar(-1);
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
}
}
@ -275,6 +278,7 @@ export class DownloadManager {
}
case Downloader.PixelDrain: {
const id = download.uri.split("/").pop();
return {
action: "start",
game_id: downloadId,
@ -329,6 +333,18 @@ export class DownloadManager {
save_path: download.downloadPath,
};
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return;
return {
action: "start",
game_id: downloadId,
url,
save_path: download.downloadPath,
out: name,
};
}
}
}

View file

@ -6,24 +6,23 @@ import type {
TorBoxAddTorrentRequest,
TorBoxRequestLinkRequest,
} from "@types";
import { logger } from "../logger";
export class TorBoxClient {
private static instance: AxiosInstance;
private static readonly baseURL = "https://api.torbox.app/v1/api";
public static apiToken: string;
private static apiToken: string;
static authorize(apiToken: string) {
this.apiToken = apiToken;
this.instance = axios.create({
baseURL: this.baseURL,
headers: {
Authorization: `Bearer ${apiToken}`,
},
});
this.apiToken = apiToken;
}
static async addMagnet(magnet: string) {
private static async addMagnet(magnet: string) {
const form = new FormData();
form.append("magnet", magnet);
@ -32,6 +31,10 @@ export class TorBoxClient {
form
);
if (!response.data.success) {
throw new Error(response.data.detail);
}
return response.data.data;
}
@ -55,22 +58,16 @@ export class TorBoxClient {
}
static async requestLink(id: number) {
const searchParams = new URLSearchParams({});
searchParams.set("token", this.apiToken);
searchParams.set("torrent_id", id.toString());
searchParams.set("zip_link", "true");
const searchParams = new URLSearchParams({
token: this.apiToken,
torrent_id: id.toString(),
zip_link: "true",
});
const response = await this.instance.get<TorBoxRequestLinkRequest>(
"/torrents/requestdl?" + searchParams.toString()
);
if (response.status !== 200) {
logger.error(response.data.error);
logger.error(response.data.detail);
return null;
}
return response.data.data;
}
@ -81,7 +78,7 @@ export class TorBoxClient {
return response.data.data;
}
static async getTorrentId(magnetUri: string) {
private static async getTorrentIdAndName(magnetUri: string) {
const userTorrents = await this.getAllTorrentsFromUser();
const { infoHash } = await parseTorrent(magnetUri);
@ -89,9 +86,18 @@ export class TorBoxClient {
(userTorrent) => userTorrent.hash === infoHash
);
if (userTorrent) return userTorrent.id;
if (userTorrent) return { id: userTorrent.id, name: userTorrent.name };
const torrent = await this.addMagnet(magnetUri);
return torrent.torrent_id;
return { id: torrent.torrent_id, name: torrent.name };
}
static async getDownloadInfo(uri: string) {
const torrentData = await this.getTorrentIdAndName(uri);
const url = await this.requestLink(torrentData.id);
const name = torrentData.name ? `${torrentData.name}.zip` : undefined;
return { url, name };
}
}

View file

@ -35,22 +35,20 @@ export const mergeWithRemoteGames = async () => {
name: "getById",
});
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false,
});
}
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false,
});
}
}
})

View file

@ -15,7 +15,7 @@ export const uploadGamesBatch = async () => {
);
});
const gamesChunks = chunk(games, 200);
const gamesChunks = chunk(games, 50);
for (const chunk of gamesChunks) {
await HydraApi.post(

View file

@ -21,11 +21,18 @@ export const getSteamAppDetails = async (
});
return axios
.get(
.get<SteamAppDetailsResponse>(
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
)
.then((response) => {
if (response.data[objectId].success) return response.data[objectId].data;
if (response.data[objectId].success) {
const data = response.data[objectId].data;
return {
...data,
objectId,
};
}
return null;
})
.catch((err) => {

View file

@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameSeed", shop, objectId),
resumeGameSeed: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resumeGameSeed", shop, objectId),
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: DownloadProgress
value: DownloadProgress | null
) => cb(value);
ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener);
@ -92,6 +92,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
authenticateTorBox: (apiToken: string) =>
ipcRenderer.invoke("authenticateTorBox", apiToken),
/* Download sources */
putDownloadSource: (objectIds: string[]) =>

View file

@ -1,134 +0,0 @@
import {
ComplexStyleRule,
createContainer,
globalStyle,
style,
} from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
export const appContainer = createContainer();
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("::-webkit-scrollbar", {
width: "9px",
backgroundColor: vars.color.darkBackground,
});
globalStyle("::-webkit-scrollbar-track", {
backgroundColor: "rgba(255, 255, 255, 0.03)",
});
globalStyle("::-webkit-scrollbar-thumb", {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
});
globalStyle("::-webkit-scrollbar-thumb:hover", {
backgroundColor: "rgba(255, 255, 255, 0.16)",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
globalStyle("body", {
overflow: "hidden",
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
color: vars.color.body,
margin: "0",
});
globalStyle("button", {
padding: "0",
backgroundColor: "transparent",
border: "none",
fontFamily: "inherit",
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
globalStyle("p", {
lineHeight: "20px",
});
globalStyle("#root, main", {
display: "flex",
});
globalStyle("#root", {
flexDirection: "column",
});
globalStyle("main", {
overflow: "hidden",
});
globalStyle(
"input::-webkit-outer-spin-button, input::-webkit-inner-spin-button",
{
WebkitAppearance: "none",
margin: "0",
}
);
globalStyle("label", {
fontSize: vars.size.body,
});
globalStyle("input[type=number]", {
MozAppearance: "textfield",
});
globalStyle("img", {
WebkitUserDrag: "none",
} as Record<string, string>);
globalStyle("progress[value]", {
WebkitAppearance: "none",
});
export const container = style({
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
containerName: appContainer,
containerType: "inline-size",
});
export const content = style({
overflowY: "auto",
alignItems: "center",
display: "flex",
flexDirection: "column",
position: "relative",
height: "100%",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 50%, ${vars.color.background} 100%)`,
});
export const titleBar = style({
display: "flex",
width: "100%",
height: "35px",
minHeight: "35px",
backgroundColor: vars.color.darkBackground,
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: vars.zIndex.titleBar,
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);
export const cloudText = style({
background: "linear-gradient(270deg, #16B195 50%, #3E62C0 100%)",
backgroundClip: "text",
color: "transparent",
});

136
src/renderer/src/app.scss Normal file
View file

@ -0,0 +1,136 @@
@use "./scss/globals.scss";
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 9px;
background-color: globals.$dark-background-color;
}
::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.03);
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 24px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.16);
}
html,
body,
#root,
main {
height: 100%;
}
body {
overflow: hidden;
user-select: none;
font-family:
Noto Sans,
sans-serif;
font-size: globals.$body-font-size;
color: globals.$body-color;
margin: 0;
}
button {
padding: 0;
background-color: transparent;
border: none;
font-family: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}
p {
line-height: 20px;
}
#root,
main {
display: flex;
}
#root {
flex-direction: column;
}
main {
overflow: hidden;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
label {
font-size: globals.$body-font-size;
}
img {
-webkit-user-drag: none;
}
progress[value] {
-webkit-appearance: none;
}
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
container-name: globals.$app-container;
container-type: inline-size;
&__content {
overflow-y: auto;
align-items: center;
display: flex;
flex-direction: column;
position: relative;
height: 100%;
background: linear-gradient(
0deg,
globals.$dark-background-color 50%,
globals.$background-color 100%
);
}
}
.title-bar {
display: flex;
width: 100%;
height: 35px;
min-height: 35px;
background-color: globals.$dark-background-color;
align-items: center;
padding: 0 calc(globals.$spacing-unit * 2);
-webkit-app-region: drag;
z-index: 4;
border-bottom: 1px solid globals.$border-color;
&__cloud-text {
background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
background-clip: text;
color: transparent;
}
}

View file

@ -12,8 +12,6 @@ import {
useUserDetails,
} from "@renderer/hooks";
import * as styles from "./app.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setUserPreferences,
@ -29,7 +27,8 @@ import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { SPACING_UNIT } from "./theme.css";
import "./app.scss";
export interface AppProps {
children: React.ReactNode;
@ -85,7 +84,7 @@ export function App() {
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.progress === 1) {
if (downloadProgress?.progress === 1) {
clearDownload();
updateLibrary();
return;
@ -257,34 +256,24 @@ export function App() {
return (
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<div className="title-bar">
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
<span className="title-bar__cloud-text"> Cloud</span>
)}
</h4>
</div>
)}
<div
style={{
position: "absolute",
bottom: `${26 + SPACING_UNIT * 2}px`,
right: "16px",
maxWidth: "420px",
width: "420px",
}}
>
<Toast
visible={toast.visible}
title={toast.title}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
duration={toast.duration}
/>
</div>
<Toast
visible={toast.visible}
title={toast.title}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
duration={toast.duration}
/>
<HydraCloudModal
visible={isHydraCloudModalVisible}
@ -304,10 +293,10 @@ export function App() {
<main>
<Sidebar />
<article className={styles.container}>
<article className="container">
<Header />
<section ref={contentRef} className={styles.content}>
<section ref={contentRef} className="container__content">
<Outlet />
</section>
</article>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,54 +0,0 @@
import { keyframes } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const backdropFadeIn = keyframes({
"0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
"100%": {
backdropFilter: "blur(2px)",
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
});
export const backdropFadeOut = keyframes({
"0%": { backdropFilter: "blur(2px)", backgroundColor: "rgba(0, 0, 0, 0.7)" },
"100%": {
backdropFilter: "blur(0px)",
backgroundColor: "rgba(0, 0, 0, 0)",
},
});
export const backdrop = recipe({
base: {
animationName: backdropFadeIn,
animationDuration: "0.4s",
backgroundColor: "rgba(0, 0, 0, 0.7)",
position: "absolute",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: vars.zIndex.backdrop,
top: "0",
padding: `${SPACING_UNIT * 3}px`,
backdropFilter: "blur(2px)",
transition: "all ease 0.2s",
},
variants: {
closing: {
true: {
animationName: backdropFadeOut,
backdropFilter: "blur(0px)",
backgroundColor: "rgba(0, 0, 0, 0)",
},
},
windows: {
true: {
// SPACING_UNIT * 3 + title bar spacing
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
},
},
},
});

View file

@ -0,0 +1,50 @@
@use "../../scss/globals.scss";
.backdrop {
animation-name: backdrop-fade-in;
animation-duration: 0.4s;
background-color: rgba(0, 0, 0, 0.7);
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: globals.$backdrop-z-index;
top: 0;
padding: calc(globals.$spacing-unit * 3);
backdrop-filter: blur(2px);
transition: all ease 0.2s;
&--closing {
animation-name: backdrop-fade-out;
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0);
}
&--windows {
padding-top: calc(#{globals.$spacing-unit * 3} + 35);
}
}
@keyframes backdrop-fade-in {
0% {
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0.5);
}
100% {
backdrop-filter: blur(2px);
background-color: rgba(0, 0, 0, 0.7);
}
}
@keyframes backdrop-fade-out {
0% {
backdrop-filter: blur(2px);
background-color: rgba(0, 0, 0, 0.7);
}
100% {
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0);
}
}

View file

@ -1,4 +1,5 @@
import * as styles from "./backdrop.css";
import "./backdrop.scss";
import cn from "classnames";
export interface BackdropProps {
isClosing?: boolean;
@ -8,9 +9,9 @@ export interface BackdropProps {
export function Backdrop({ isClosing = false, children }: BackdropProps) {
return (
<div
className={styles.backdrop({
closing: isClosing,
windows: window.electron.platform === "win32",
className={cn("backdrop", {
"backdrop--closing": isClosing,
"backdrop--windows": window.electron.platform === "win32",
})}
>
{children}

View file

@ -7,5 +7,6 @@
border: solid 1px globals.$muted-color;
border-radius: 4px;
display: flex;
gap: 4px;
align-items: center;
}

View file

@ -19,8 +19,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading = !!lastPacket;
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
@ -33,9 +31,11 @@ export function BottomPanel() {
}, [userDetails?.id]);
const status = useMemo(() => {
if (isGameDownloading) {
const game = library.find((game) => game.id === lastPacket?.gameId)!;
const game = lastPacket
? library.find((game) => game.id === lastPacket?.gameId)
: undefined;
if (game) {
if (lastPacket?.isCheckingFiles)
return t("checking_files", {
title: game.title,
@ -64,7 +64,7 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]);
}, [t, library, lastPacket, progress, eta, downloadSpeed]);
return (
<footer className="bottom-panel">

View file

@ -1,57 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const checkboxField = style({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
});
export const checkbox = recipe({
base: {
width: "20px",
height: "20px",
borderRadius: "4px",
backgroundColor: vars.color.darkBackground,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
minWidth: "20px",
minHeight: "20px",
color: vars.color.darkBackground,
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
variants: {
checked: {
true: {
backgroundColor: vars.color.muted,
},
},
},
});
export const checkboxInput = style({
width: "100%",
height: "100%",
position: "absolute",
margin: "0",
padding: "0",
opacity: "0",
cursor: "pointer",
});
export const checkboxLabel = style({
cursor: "pointer",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
});

View file

@ -0,0 +1,58 @@
@use "../../scss/globals.scss";
.checkbox-field {
display: flex;
flex-direction: row;
align-items: center;
gap: globals.$spacing-unit;
cursor: pointer;
&:has(input:disabled) {
cursor: not-allowed;
opacity: globals.$disabled-opacity;
}
&__checkbox {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
border-radius: 4px;
background-color: globals.$dark-background-color;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: all ease 0.2s;
border: solid 1px globals.$border-color;
&:hover:not(:has(input:disabled)) {
border-color: rgba(255, 255, 255, 0.5);
}
}
&__input {
width: 100%;
height: 100%;
position: absolute;
margin: 0;
padding: 0;
opacity: 0;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
&__label {
cursor: pointer;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&:has(+ input:disabled) {
cursor: not-allowed;
}
}
}

View file

@ -1,6 +1,6 @@
import { useId } from "react";
import * as styles from "./checkbox-field.css";
import { CheckIcon } from "@primer/octicons-react";
import "./checkbox-field.scss";
export interface CheckboxFieldProps
extends React.DetailedHTMLProps<
@ -14,17 +14,19 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
const id = useId();
return (
<div className={styles.checkboxField}>
<div className={styles.checkbox({ checked: props.checked })}>
<div className="checkbox-field">
<div
className={`checkbox-field__checkbox ${props.checked ? "checked" : ""}`}
>
<input
id={id}
type="checkbox"
className={styles.checkboxInput}
className="checkbox-field__input"
{...props}
/>
{props.checked && <CheckIcon />}
</div>
<label htmlFor={id} className={styles.checkboxLabel}>
<label htmlFor={id} className="checkbox-field__label">
{label}
</label>
</div>

View file

@ -1,13 +0,0 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const actions = style({
display: "flex",
alignSelf: "flex-end",
gap: `${SPACING_UNIT * 2}px`,
});
export const descriptionText = style({
fontSize: "16px",
lineHeight: "24px",
});

View file

@ -0,0 +1,17 @@
@use "../../scss/globals.scss";
.confirmation-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__actions {
display: flex;
align-self: flex-end;
gap: calc(globals.$spacing-unit * 2);
}
&__description {
font-size: 16px;
line-height: 24px;
}
}

View file

@ -1,7 +1,7 @@
import { Button } from "../button/button";
import { Modal, type ModalProps } from "../modal/modal";
import * as styles from "./confirmation-modal.css";
import "./confirmation-modal.scss";
export interface ConfirmationModalProps extends Omit<ModalProps, "children"> {
confirmButtonLabel: string;
@ -31,10 +31,10 @@ export function ConfirmationModal({
return (
<Modal {...props}>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p className={styles.descriptionText}>{descriptionText}</p>
<div className="confirmation-modal">
<p className="confirmation-modal__description">{descriptionText}</p>
<div className={styles.actions}>
<div className="confirmation-modal__actions">
<Button theme="outline" onClick={handleCancelClick}>
{cancelButtonLabel}
</Button>

View file

@ -1,106 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const card = style({
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
});
export const backdrop = style({
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%)",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "flex-end",
flexDirection: "column",
position: "relative",
});
export const cover = style({
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center",
position: "absolute",
zIndex: "-1",
transition: "all ease 0.2s",
selectors: {
[`${card}:hover &`]: {
transform: "scale(1.05)",
},
},
});
export const content = style({
color: "#DADBE1",
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
transition: "all ease 0.2s",
transform: "translateY(24px)",
selectors: {
[`${card}:hover &`]: {
transform: "translateY(0px)",
},
},
});
export const title = style({
fontSize: "16px",
fontWeight: "bold",
textAlign: "left",
});
export const downloadOptions = style({
display: "flex",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT}px`,
flexWrap: "wrap",
listStyle: "none",
});
export const specifics = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
justifyContent: "center",
});
export const specificsItem = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.muted,
fontSize: "12px",
alignItems: "flex-end",
});
export const titleContainer = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
});
export const shopIcon = style({
width: "20px",
height: "20px",
minWidth: "20px",
});
export const noDownloadsLabel = style({
color: vars.color.body,
fontWeight: "bold",
});

View file

@ -0,0 +1,102 @@
@use "../../scss/globals.scss";
.game-card {
width: 100%;
height: 180px;
box-shadow: 0px 0px 15px 0px #000000;
overflow: hidden;
border-radius: 4px;
transition: all ease 0.2s;
border: solid 1px globals.$border-color;
cursor: pointer;
z-index: 1;
&:active {
opacity: globals.$active-opacity;
}
&__backdrop {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%);
width: 100%;
height: 100%;
display: flex;
justify-content: flex-end;
flex-direction: column;
position: relative;
}
&__cover {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
position: absolute;
z-index: -1;
transition: all ease 0.2s;
}
&__content {
color: #dadbe1;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
display: flex;
align-items: flex-start;
gap: globals.$spacing-unit;
flex-direction: column;
transition: all ease 0.2s;
transform: translateY(24px);
}
&__title {
font-size: 16px;
font-weight: bold;
text-align: left;
}
&__download-options {
display: flex;
margin: 0;
padding: 0;
gap: globals.$spacing-unit;
flex-wrap: wrap;
list-style: none;
}
&__specifics {
display: flex;
gap: calc(globals.$spacing-unit * 2);
justify-content: center;
}
&__specifics-item {
gap: globals.$spacing-unit;
display: flex;
color: globals.$muted-color;
font-size: 12px;
align-items: flex-end;
}
&__title-container {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
}
&__shop-icon {
width: 20px;
height: 20px;
min-width: 20px;
}
&__no-download-label {
color: globals.$body-color;
font-weight: bold;
}
&:hover &__cover {
transform: scale(1.05);
}
&:hover &__content {
transform: translateY(0px);
}
}

View file

@ -3,7 +3,8 @@ import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useState } from "react";
@ -19,7 +20,7 @@ export interface GameCardProps
}
const shopIcon = {
steam: <SteamLogo className={styles.shopIcon} />,
steam: <SteamLogo className="game-card__shop-icon" />,
};
export function GameCard({ game, ...props }: GameCardProps) {
@ -48,25 +49,25 @@ export function GameCard({ game, ...props }: GameCardProps) {
<button
{...props}
type="button"
className={styles.card}
className="game-card"
onMouseEnter={handleHover}
>
<div className={styles.backdrop}>
<div className="game-card__backdrop">
<img
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
className={styles.cover}
className="game-card__cover"
loading="lazy"
/>
<div className={styles.content}>
<div className={styles.titleContainer}>
<div className="game-card__content">
<div className="game-card__title-container">
{shopIcon[game.shop]}
<p className={styles.title}>{game.title}</p>
<p className="game-card__title">{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
<ul className={styles.downloadOptions}>
<ul className="game-card__download-options">
{uniqueRepackers.map((repacker) => (
<li key={repacker}>
<Badge>{repacker}</Badge>
@ -74,17 +75,17 @@ export function GameCard({ game, ...props }: GameCardProps) {
))}
</ul>
) : (
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
<p className="game-card__no-download-label">{t("no_downloads")}</p>
)}
<div className={styles.specifics}>
<div className={styles.specificsItem}>
<div className="game-card__specifics">
<div className="game-card__specifics-item">
<DownloadIcon />
<span>
{stats ? numberFormatter.format(stats.downloadCount) : "…"}
</span>
</div>
<div className={styles.specificsItem}>
<div className="game-card__specifics-item">
<PeopleIcon />
<span>
{stats ? numberFormatter.format(stats?.playerCount) : "…"}

View file

@ -0,0 +1,32 @@
@use "../../scss/globals.scss";
.auto-update-sub-header {
border-bottom: solid 1px globals.$body-color;
padding: calc(globals.$spacing-unit / 2) calc(globals.$spacing-unit * 3);
&__new-version-link {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
color: #8e919b;
font-size: 12px;
}
&__new-version-icon {
color: globals.$success-color;
}
&__new-version-button {
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$body-color;
font-size: 12px;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}

View file

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { SyncIcon } from "@primer/octicons-react";
import { Link } from "../link/link";
import * as styles from "./header.css";
import "./auto-update-header.scss";
import type { AppUpdaterEvent } from "@types";
export const releasesPageUrl =
@ -45,9 +45,15 @@ export function AutoUpdateSubHeader() {
if (!isAutoInstallAvailable) {
return (
<header className={styles.subheader}>
<Link to={releasesPageUrl} className={styles.newVersionLink}>
<SyncIcon className={styles.newVersionIcon} size={12} />
<header className="auto-update-sub-header">
<Link
to={releasesPageUrl}
className="auto-update-sub-header__new-version-link"
>
<SyncIcon
className="auto-update-sub-header__new-version-icon"
size={12}
/>
{t("version_available_download", { version: newVersion })}
</Link>
</header>
@ -56,13 +62,16 @@ export function AutoUpdateSubHeader() {
if (isReadyToInstall) {
return (
<header className={styles.subheader}>
<header className="auto-update-sub-header">
<button
type="button"
className={styles.newVersionButton}
className="auto-update-sub-header__new-version-button"
onClick={handleClickInstallUpdate}
>
<SyncIcon className={styles.newVersionIcon} size={12} />
<SyncIcon
className="auto-update-sub-header__new-version-icon"
size={12}
/>
{t("version_available_install", { version: newVersion })}
</button>
</header>

View file

@ -1,182 +0,0 @@
import type { ComplexStyleRule } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const slideIn = keyframes({
"0%": { transform: "translateX(20px)", opacity: "0" },
"100%": {
transform: "translateX(0)",
opacity: "1",
},
});
export const slideOut = keyframes({
"0%": { transform: "translateX(0px)", opacity: "1" },
"100%": {
transform: "translateX(20px)",
opacity: "0",
},
});
export const header = recipe({
base: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
backgroundColor: vars.color.darkBackground,
} as ComplexStyleRule,
variants: {
draggingDisabled: {
true: {
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
},
isWindows: {
true: {
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
},
},
});
export const search = recipe({
base: {
backgroundColor: vars.color.background,
display: "inline-flex",
transition: "all ease 0.2s",
width: "200px",
alignItems: "center",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
height: "40px",
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
variants: {
focused: {
true: {
width: "250px",
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
},
});
export const searchInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
":focus": {
cursor: "text",
},
});
export const actionButton = style({
color: "inherit",
cursor: "pointer",
transition: "all ease 0.2s",
padding: `${SPACING_UNIT}px`,
":hover": {
color: "#DADBE1",
},
});
export const section = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
height: "100%",
overflow: "hidden",
});
export const backButton = recipe({
base: {
color: vars.color.body,
cursor: "pointer",
WebkitAppRegion: "no-drag",
position: "absolute",
transition: "transform ease 0.2s",
animationDuration: "0.2s",
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
} as ComplexStyleRule,
variants: {
enabled: {
true: {
animationName: slideIn,
},
false: {
opacity: "0",
pointerEvents: "none",
animationName: slideOut,
},
},
},
});
export const title = recipe({
base: {
transition: "all ease 0.2s",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
},
variants: {
hasBackButton: {
true: {
transform: "translateX(28px)",
width: "calc(100% - 28px)",
},
},
},
});
export const subheader = style({
borderBottom: `solid 1px ${vars.color.border}`,
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 3}px`,
});
export const newVersionButton = style({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.body,
fontSize: "12px",
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});
export const newVersionLink = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: "#8e919b",
fontSize: "12px",
});
export const newVersionIcon = style({
color: vars.color.success,
});

View file

@ -0,0 +1,137 @@
@use "../../scss/globals.scss";
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
-webkit-app-region: drag;
width: 100%;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
color: globals.$muted-color;
border-bottom: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
&--dragging-disabled {
-webkit-app-region: no-drag;
}
&--is-windows {
-webkit-app-region: no-drag;
}
&__search {
background-color: globals.$background-color;
display: inline-flex;
transition: all ease 0.2s;
width: 200px;
align-items: center;
border-radius: 8px;
border: solid 1px globals.$border-color;
height: 40px;
-webkit-app-region: no-drag;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--focused {
width: 250px;
border-color: #dadbe1;
}
}
&__search-input {
background-color: transparent;
border: none;
width: 100%;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
text-overflow: ellipsis;
&:focus {
cursor: text;
}
}
&__action-button {
color: inherit;
cursor: pointer;
transition: all ease 0.2s;
padding: globals.$spacing-unit;
&:hover {
color: #dadbe1;
}
}
&__section {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
height: 100%;
overflow: hidden;
&--left {
flex: 1;
}
}
&__back-button {
color: globals.$body-color;
cursor: pointer;
-webkit-app-region: no-drag;
position: absolute;
transition: transform ease 0.2s;
animation-duration: 0.2s;
width: 16px;
height: 16px;
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
animation-name: slide-out;
&--enabled {
animation: slide-in;
opacity: 1;
pointer-events: all;
}
}
&__title {
transition: all ease 0.2s;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
&--has-back-button {
transform: translateX(28px);
width: calc(100% - 28px);
}
}
}
@keyframes slide-in {
0% {
transform: translateX(20px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out {
0% {
transform: translateX(0px);
opacity: 1;
}
100% {
transform: translateX(20px);
opacity: 0;
}
}

View file

@ -5,9 +5,10 @@ import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css";
import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters } from "@renderer/features";
import cn from "classnames";
const pathTitle: Record<string, string> = {
"/": "home",
@ -75,16 +76,16 @@ export function Header() {
return (
<>
<header
className={styles.header({
draggingDisabled,
isWindows: window.electron.platform === "win32",
className={cn("header", {
"header--dragging-disabled": draggingDisabled,
"header--is-windows": window.electron.platform === "win32",
})}
>
<section className={styles.section} style={{ flex: 1 }}>
<section className="header__section header__section--left">
<button
type="button"
className={styles.backButton({
enabled: location.key !== "default",
className={cn("header__back-button", {
"header__back-button--enabled": location.key !== "default",
})}
onClick={handleBackButtonClick}
disabled={location.key === "default"}
@ -93,19 +94,23 @@ export function Header() {
</button>
<h3
className={styles.title({
hasBackButton: location.key !== "default",
className={cn("header__title", {
"header__title--has-back-button": location.key !== "default",
})}
>
{title}
</h3>
</section>
<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>
<section className="header__section">
<div
className={cn("header__search", {
"header__search--focused": isFocused,
})}
>
<button
type="button"
className={styles.actionButton}
className="header__action-button"
onClick={focusInput}
>
<SearchIcon />
@ -117,7 +122,7 @@ export function Header() {
name="search"
placeholder={t("search")}
value={searchValue}
className={styles.searchInput}
className="header__search-input"
onChange={(event) => handleSearch(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
@ -127,7 +132,7 @@ export function Header() {
<button
type="button"
onClick={() => dispatch(setFilters({ title: "" }))}
className={styles.actionButton}
className="header__action-button"
>
<XIcon />
</button>

View file

@ -1,60 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const hero = style({
width: "100%",
height: "280px",
minHeight: "280px",
maxHeight: "280px",
borderRadius: "4px",
color: "#DADBE1",
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
cursor: "pointer",
border: `solid 1px ${vars.color.border}`,
zIndex: "1",
});
export const heroMedia = style({
objectFit: "cover",
objectPosition: "center",
position: "absolute",
zIndex: "-1",
width: "100%",
height: "100%",
transition: "all ease 0.2s",
imageRendering: "revert",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.02)",
},
},
});
export const backdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 25%, transparent 100%)",
position: "relative",
display: "flex",
overflow: "hidden",
});
export const description = style({
maxWidth: "700px",
color: vars.color.muted,
textAlign: "left",
lineHeight: "20px",
marginTop: `${SPACING_UNIT * 2}px`,
});
export const content = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
});

View file

@ -0,0 +1,56 @@
@use "../../scss/globals.scss";
.hero {
width: 100%;
height: 280px;
min-height: 280px;
max-height: 280px;
border-radius: 4px;
color: #dadbe1;
overflow: hidden;
box-shadow: 0px 0px 15px 0px #000000;
cursor: pointer;
border: solid 1px globals.$border-color;
z-index: 1;
&__media {
object-fit: cover;
object-position: center;
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
transition: all ease 0.2s;
image-rendering: revert;
}
&:hover &__media {
transform: scale(1.02);
}
&__backdrop {
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 25%, transparent 100%);
position: relative;
display: flex;
overflow: hidden;
}
&__description {
max-width: 700px;
color: globals.$muted-color;
text-align: left;
line-height: 20px;
margin-top: calc(globals.$spacing-unit * 2);
}
&__content {
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}

View file

@ -1,9 +1,9 @@
import { useNavigate } from "react-router-dom";
import * as styles from "./hero.css";
import { useEffect, useState } from "react";
import type { TrendingGame } from "@types";
import { useTranslation } from "react-i18next";
import Skeleton from "react-loading-skeleton";
import "./hero.scss";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] = useState<
@ -29,7 +29,7 @@ export function Hero() {
}, [i18n.language]);
if (isLoading) {
return <Skeleton className={styles.hero} />;
return <Skeleton className="hero" />;
}
if (featuredGameDetails?.length) {
@ -37,17 +37,17 @@ export function Hero() {
<button
type="button"
onClick={() => navigate(game.uri)}
className={styles.hero}
className="hero"
key={index}
>
<div className={styles.backdrop}>
<div className="hero__backdrop">
<img
src={game.background}
alt={game.description}
className={styles.heroMedia}
className="hero__media"
/>
<div className={styles.content}>
<div className="hero__content">
{game.logo && (
<img
src={game.logo}
@ -56,7 +56,7 @@ export function Hero() {
loading="eager"
/>
)}
<p className={styles.description}>{game.description}</p>
<p className="hero__description">{game.description}</p>
</div>
</div>
</button>

View file

@ -1,9 +0,0 @@
import { style } from "@vanilla-extract/css";
export const link = style({
textDecoration: "none",
color: "#C0C1C7",
":hover": {
textDecoration: "underline",
},
});

View file

@ -0,0 +1,7 @@
.link {
text-decoration: none;
color: #c0c1c7;
&:hover {
text-decoration: underline;
}
}

View file

@ -1,6 +1,6 @@
import { Link as ReactRouterDomLink, LinkProps } from "react-router-dom";
import cn from "classnames";
import * as styles from "./link.css";
import "./link.scss";
export function Link({ children, to, className, ...props }: LinkProps) {
const openExternal = (event: React.MouseEvent) => {
@ -12,7 +12,7 @@ export function Link({ children, to, className, ...props }: LinkProps) {
return (
<a
href={to}
className={cn(styles.link, className)}
className={cn("link", className)}
onClick={openExternal}
{...props}
>
@ -22,11 +22,7 @@ export function Link({ children, to, className, ...props }: LinkProps) {
}
return (
<ReactRouterDomLink
className={cn(styles.link, className)}
to={to}
{...props}
>
<ReactRouterDomLink className={cn("link", className)} to={to} {...props}>
{children}
</ReactRouterDomLink>
);

View file

@ -1,78 +0,0 @@
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const scaleFadeIn = keyframes({
"0%": { opacity: "0", scale: "0.5" },
"100%": {
opacity: "1",
scale: "1",
},
});
export const scaleFadeOut = keyframes({
"0%": { opacity: "1", scale: "1" },
"100%": {
opacity: "0",
scale: "0.5",
},
});
export const modal = recipe({
base: {
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
backgroundColor: vars.color.background,
borderRadius: "4px",
minWidth: "400px",
maxWidth: "600px",
color: vars.color.body,
maxHeight: "100%",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
variants: {
closing: {
true: {
animationName: scaleFadeOut,
opacity: "0",
},
},
large: {
true: {
width: "800px",
maxWidth: "800px",
},
},
},
});
export const modalContent = style({
height: "100%",
overflow: "auto",
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
});
export const modalHeader = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT * 2}px`,
borderBottom: `solid 1px ${vars.color.border}`,
justifyContent: "space-between",
alignItems: "center",
});
export const closeModalButton = style({
cursor: "pointer",
transition: "all ease 0.2s",
alignSelf: "flex-start",
":hover": {
opacity: "0.75",
},
});
export const closeModalButtonIcon = style({
color: vars.color.body,
});

View file

@ -0,0 +1,83 @@
@use "../../scss/globals.scss";
.modal {
animation: scale-fade-in 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none
running;
background-color: globals.$background-color;
border-radius: 4px;
min-width: 400px;
max-width: 600px;
color: globals.$body-color;
max-height: 100%;
border: solid 1px globals.$border-color;
overflow: hidden;
display: flex;
flex-direction: column;
&--closing {
animation-name: scale-fade-out;
opacity: 0;
}
&--large {
width: 800px;
max-width: 800px;
}
&__content {
height: 100%;
overflow: auto;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
}
&__header {
display: flex;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
border-bottom: solid 1px globals.$border-color;
justify-content: space-between;
align-items: center;
&-title {
display: flex;
gap: 4px;
flex-direction: column;
}
}
&__close-button {
cursor: pointer;
transition: all ease 0.2s;
align-self: flex-start;
&:hover {
opacity: 0.75;
}
}
&__close-button-icon {
color: globals.$body-color;
}
}
@keyframes scale-fade-in {
0% {
opacity: 0;
scale: 0.5;
}
100% {
opacity: 1;
scale: 1;
}
}
@keyframes scale-fade-out {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 0.5;
}
}

View file

@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import * as styles from "./modal.css";
import "./modal.scss";
import { Backdrop } from "../backdrop/backdrop";
import { useTranslation } from "react-i18next";
import cn from "classnames";
export interface ModalProps {
visible: boolean;
@ -109,15 +110,18 @@ export function Modal({
return createPortal(
<Backdrop isClosing={isClosing}>
<div
className={styles.modal({ closing: isClosing, large })}
className={cn("modal", {
"modal--closing": isClosing,
"modal--large": large,
})}
role="dialog"
aria-labelledby={title}
aria-describedby={description}
ref={modalContentRef}
data-hydra-dialog
>
<div className={styles.modalHeader}>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<div className="modal__header">
<div className="modal__header-title">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
@ -125,13 +129,13 @@ export function Modal({
<button
type="button"
onClick={handleCloseClick}
className={styles.closeModalButton}
className="modal__close-button"
aria-label={t("close")}
>
<XIcon className={styles.closeModalButtonIcon} size={24} />
<XIcon className="modal__close-button-icon" size={24} />
</button>
</div>
<div className={styles.modalContent}>{children}</div>
<div className="modal__content">{children}</div>
</div>
</Backdrop>,
document.body

View file

@ -1,59 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const select = recipe({
base: {
display: "inline-flex",
transition: "all ease 0.2s",
width: "fit-content",
alignItems: "center",
borderRadius: "8px",
border: `1px solid ${vars.color.border}`,
height: "40px",
minHeight: "40px",
},
variants: {
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
theme: {
primary: {
backgroundColor: vars.color.darkBackground,
},
dark: {
backgroundColor: vars.color.background,
},
},
},
});
export const option = style({
backgroundColor: vars.color.darkBackground,
borderRight: "4px solid",
borderColor: "transparent",
borderRadius: "8px",
width: "fit-content",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
fontSize: vars.size.body,
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
});
export const label = style({
marginBottom: `${SPACING_UNIT}px`,
display: "block",
color: vars.color.body,
});

View file

@ -0,0 +1,54 @@
@use "../../scss/globals.scss";
.select-field {
display: inline-flex;
transition: all ease 0.2s;
width: fit-content;
align-items: center;
border-radius: 8px;
border: 1px solid globals.$border-color;
height: 40px;
min-height: 40px;
&__container {
flex: 1;
}
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--focused {
border-color: #dadbe1;
}
&--primary {
background-color: globals.$dark-background-color;
}
&--dark {
background-color: globals.$background-color;
}
&__option {
background-color: globals.$dark-background-color;
border-right: 4px solid;
border-color: transparent;
border-radius: 8px;
width: fit-content;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
font-size: globals.$body-font-size;
text-overflow: ellipsis;
padding: globals.$spacing-unit;
}
&__label {
margin-bottom: globals.$spacing-unit;
display: block;
color: globals.$body-color;
}
}

View file

@ -1,13 +1,13 @@
import { useId, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./select-field.css";
import "./select-field.scss";
import cn from "classnames";
export interface SelectProps
extends React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> {
theme?: NonNullable<RecipeVariants<typeof styles.select>>["theme"];
theme?: "primary" | "dark";
label?: string;
options?: { key: string; value: string; label: string }[];
}
@ -23,18 +23,22 @@ export function SelectField({
const id = useId();
return (
<div style={{ flex: 1 }}>
<div className="select-field__container">
{label && (
<label htmlFor={id} className={styles.label}>
<label htmlFor={id} className="select-field__label">
{label}
</label>
)}
<div className={styles.select({ focused: isFocused, theme })}>
<div
className={cn("select-field", `select-field--${theme}`, {
"select-field--focused": isFocused,
})}
>
<select
id={id}
value={value}
className={styles.option}
className="select-field__option"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={onChange}

View file

@ -1,79 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainer = style({
position: "relative",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
width: "100%",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
width: "100%",
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const friendsButton = style({
color: vars.color.muted,
cursor: "pointer",
borderRadius: "50%",
width: "40px",
minWidth: "40px",
minHeight: "40px",
height: "40px",
backgroundColor: vars.color.background,
position: "relative",
transition: "all ease 0.3s",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendsButtonBadge = style({
backgroundColor: vars.color.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
height: "20px",
borderRadius: "50%",
position: "absolute",
top: "-5px",
right: "-5px",
});

View file

@ -0,0 +1,89 @@
@use "../../scss/globals.scss";
.sidebar-profile {
position: relative;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
&__button {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit globals.$spacing-unit;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__button-content {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit + globals.$spacing-unit / 2);
width: 100%;
}
&__button-information {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
}
&__button-title {
font-weight: bold;
font-size: globals.$body-font-size;
width: 100%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__friends-button {
color: globals.$muted-color;
cursor: pointer;
border-radius: 50%;
width: 40px;
min-width: 40px;
min-height: 40px;
height: 40px;
background-color: globals.$background-color;
position: relative;
transition: all ease 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__friends-button-badge {
background-color: globals.$success-color;
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
position: absolute;
top: -5px;
right: -5px;
}
&__game-running-icon {
border-radius: 4px;
}
&__button-game-running-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: left;
}
}

View file

@ -1,6 +1,5 @@
import { useNavigate } from "react-router-dom";
import { PeopleIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
@ -8,6 +7,7 @@ import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-mo
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
import "./sidebar-profile.scss";
const LONG_POLLING_INTERVAL = 120_000;
@ -50,14 +50,14 @@ export function SidebarProfile() {
return (
<button
type="button"
className={styles.friendsButton}
className="sidebar-profile__friends-button"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{friendRequestCount > 0 && (
<small className={styles.friendsButtonBadge}>
<small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount}
</small>
)}
@ -73,9 +73,9 @@ export function SidebarProfile() {
if (gameRunning.iconUrl) {
return (
<img
className="sidebar-profile__game-running-icon"
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
);
@ -85,34 +85,26 @@ export function SidebarProfile() {
};
return (
<div className={styles.profileContainer}>
<div className="sidebar-profile">
<button
type="button"
className={styles.profileButton}
className="sidebar-profile__button"
onClick={handleProfileClick}
>
<div className={styles.profileButtonContent}>
<div className="sidebar-profile__button-content">
<Avatar
size={35}
src={userDetails?.profileImageUrl}
alt={userDetails?.displayName}
/>
<div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}>
<div className="sidebar-profile__button-information">
<p className="sidebar-profile__button-title">
{userDetails ? userDetails.displayName : t("sign_in")}
</p>
{userDetails && gameRunning && (
<div
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
width: "100%",
textAlign: "left",
}}
>
<div className="sidebar-profile__button-game-running-title">
<small>{gameRunning.title}</small>
</div>
)}

View file

@ -101,6 +101,12 @@
font-weight: bold;
}
&__container {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__section {
gap: calc(globals.$spacing-unit * 2);
display: flex;

View file

@ -180,14 +180,7 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
>
<div className="sidebar__container">
<SidebarProfile />
<div className="sidebar__content">

View file

@ -1,89 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const textFieldContainer = style({
flex: "1",
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
});
export const textField = recipe({
base: {
display: "inline-flex",
transition: "all ease 0.2s",
width: "100%",
alignItems: "center",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
height: "40px",
minHeight: "40px",
},
variants: {
theme: {
primary: {
backgroundColor: vars.color.darkBackground,
},
dark: {
backgroundColor: vars.color.background,
},
},
hasError: {
true: {
borderColor: vars.color.danger,
},
},
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
},
});
export const textFieldInput = recipe({
base: {
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
},
variants: {
readOnly: {
true: {
textOverflow: "inherit",
},
},
},
});
export const togglePasswordButton = style({
cursor: "pointer",
color: vars.color.muted,
padding: `${SPACING_UNIT}px`,
});
export const textFieldWrapper = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const errorLabel = style({
color: vars.color.danger,
});

View file

@ -0,0 +1,79 @@
@use "../../scss/globals.scss";
.text-field-container {
flex: 1;
gap: globals.$spacing-unit;
display: flex;
flex-direction: column;
&__text-field {
display: inline-flex;
transition: all ease 0.2s;
width: 100%;
align-items: center;
border-radius: 8px;
border: solid 1px globals.$border-color;
height: 40px;
min-height: 40px;
flex: 1;
min-width: 0;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--primary {
background-color: globals.$dark-background-color;
}
&--dark {
background-color: globals.$background-color;
}
&--has-error {
border-color: globals.$danger-color;
}
&--focused {
border-color: #dadbe1;
}
}
&__text-field-input {
background-color: transparent;
border: none;
width: 100%;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
text-overflow: ellipsis;
padding: globals.$spacing-unit;
&:focus {
cursor: text;
}
&--read-only {
text-overflow: inherit;
}
}
&__toggle-password-button {
cursor: pointer;
color: globals.$muted-color;
padding: globals.$spacing-unit;
}
&__text-field-wrapper {
display: flex;
gap: globals.$spacing-unit;
width: 100%;
align-items: center;
}
&__error-label {
color: globals.$danger-color;
}
}

View file

@ -1,16 +1,17 @@
import React, { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import * as styles from "./text-field.css";
import cn from "classnames";
import "./text-field.scss";
export interface TextFieldProps
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
theme?: NonNullable<RecipeVariants<typeof styles.textField>>["theme"];
theme?: "primary" | "dark";
label?: string | React.ReactNode;
hint?: string | React.ReactNode;
textFieldProps?: React.DetailedHTMLProps<
@ -54,7 +55,10 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
}, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => {
if (error) return <small className={styles.errorLabel}>{error}</small>;
if (error)
return (
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>;
return null;
@ -73,22 +77,28 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
const hasError = !!error;
return (
<div className={styles.textFieldContainer} {...containerProps}>
<div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
<div className={styles.textFieldWrapper}>
<div className="text-field-container__text-field-wrapper">
<div
className={styles.textField({
theme,
hasError,
focused: isFocused,
})}
className={cn(
"text-field-container__text-field",
`text-field-container__text-field--${theme}`,
{
"text-field-container__text-field--has-error": hasError,
"text-field-container__text-field--focused": isFocused,
}
)}
{...textFieldProps}
>
<input
ref={ref}
id={id}
className={styles.textFieldInput({ readOnly: props.readOnly })}
className={cn("text-field-container__text-field-input", {
"text-field-container__text-field-input--read-only":
props.readOnly,
})}
{...props}
onFocus={handleFocus}
onBlur={handleBlur}
@ -98,7 +108,7 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
className="text-field-container__toggle-password-button"
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>

View file

@ -7,8 +7,8 @@
background-color: globals.$dark-background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
right: 0;
bottom: 0;
right: 16px;
bottom: 26px + globals.$spacing-unit;
overflow: hidden;
display: flex;
flex-direction: column;
@ -31,6 +31,16 @@
align-items: center;
}
&__message-container {
display: flex;
gap: globals.$spacing-unit;
flex-direction: column;
}
&__message {
font-weight: bold;
}
&__progress {
width: 100%;
height: 5px;
@ -38,6 +48,7 @@
&::-webkit-progress-bar {
background-color: globals.$dark-background-color;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}

View file

@ -87,13 +87,7 @@ export function Toast({
})}
>
<div className="toast__content">
<div
style={{
display: "flex",
gap: `8px`,
flexDirection: "column",
}}
>
<div className="toast__message-container">
<div
style={{
display: "flex",

View file

@ -10,6 +10,7 @@ export const DOWNLOADER_NAME = {
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.TorBox]: "TorBox",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View file

@ -28,6 +28,7 @@ import type {
CatalogueSearchPayload,
LibraryGame,
GameRunning,
TorBoxUser,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@ -49,7 +50,7 @@ declare global {
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
onDownloadProgress: (
cb: (value: DownloadProgress) => void
cb: (value: DownloadProgress | null) => void
) => () => Electron.IpcRenderer;
onSeedingStatus: (
cb: (value: SeedingStatus[]) => void
@ -144,6 +145,7 @@ declare global {
minimized: boolean;
}) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
/* Download sources */

View file

@ -18,9 +18,9 @@ export const downloadSlice = createSlice({
name: "download",
initialState,
reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.gameId;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
},
clearDownload: (state) => {
state.lastPacket = null;

View file

@ -114,7 +114,7 @@ export function useDownload() {
pauseSeeding,
resumeSeeding,
clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: DownloadProgress) =>
setLastPacket: (packet: DownloadProgress | null) =>
dispatch(setLastPacket(packet)),
};
}

View file

@ -1,71 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const panel = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
backgroundColor: vars.color.background,
display: "flex",
flexDirection: "column",
alignItems: "start",
justifyContent: "space-between",
borderBottom: `solid 1px ${vars.color.border}`,
});
export const content = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
});
export const progressBar = recipe({
base: {
position: "absolute",
bottom: "0",
left: "0",
width: "100%",
height: "3px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "transparent",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
},
variants: {
disabled: {
true: {
opacity: vars.opacity.disabled,
},
},
},
});
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View file

@ -0,0 +1,97 @@
@use "../../scss/globals.scss";
.achievement-panel {
width: 100%;
padding: globals.$spacing-unit * 2 globals.$spacing-unit * 3;
background-color: globals.$background-color;
display: flex;
flex-direction: column;
align-items: start;
justify-content: space-between;
border-bottom: solid 1px globals.$border-color;
&__content {
display: flex;
gap: globals.$spacing-unit;
justify-content: center;
align-items: center;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
}
&__download-details-row {
gap: globals.$spacing-unit;
display: flex;
color: globals.$body-color;
align-items: center;
}
&__downloads-link {
color: globals.$body-color;
text-decoration: underline;
}
&__progress-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: transparent;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
&--disabled {
opacity: globals.$disabled-opacity;
}
}
&__link {
text-align: start;
color: globals.$body-color;
background: none;
border: none;
padding: 0;
&:hover {
text-decoration: underline;
cursor: pointer;
}
&--warning {
color: globals.$warning-color;
}
}
&__grid {
display: grid;
gap: globals.$spacing-unit * 2;
}
&__grid--with-subscription {
grid-template-columns: 3fr 1fr 1fr;
}
&__grid--without-subscription {
grid-template-columns: 3fr 2fr;
}
&__points-container {
display: flex;
gap: globals.$spacing-unit;
}
&__content-icon {
width: 18px;
height: 18px;
}
}

View file

@ -3,8 +3,7 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { UserAchievement } from "@types";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { useUserDetails } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import * as styles from "./achievement-panel.css";
import "./achievement-panel.scss";
export interface AchievementPanelProps {
achievements: UserAchievement[];
@ -28,17 +27,18 @@ export function AchievementPanel({ achievements }: AchievementPanelProps) {
if (!hasActiveSubscription) {
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
<div className="achievement-panel">
<div className="achievement-panel__content">
{t("earned_points")}{" "}
<HydraIcon className="achievement-panel__content-icon" />
??? / ???
</div>
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
className="achievement-panel__link"
>
<small style={{ color: vars.color.warning }}>
<small className="achievement-panel__link--warning">
{t("how_to_earn_achievements_points")}
</small>
</button>
@ -47,9 +47,10 @@ export function AchievementPanel({ achievements }: AchievementPanelProps) {
}
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
<div className="achievement-panel">
<div className="achievement-panel__content">
{t("earned_points")}{" "}
<HydraIcon className="achievement-panel__content-icon" />
{achievementsPointsEarnedSum} / {achievementsPointsTotal}
</div>
</div>

View file

@ -0,0 +1,248 @@
@use "../../scss/globals.scss";
$hero-height: 150px;
$logo-height: 100px;
$logo-max-width: 200px;
.achievements-content {
&__comparison {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
position: relative;
padding: globals.$spacing-unit;
&__container {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
flex-direction: row;
gap: globals.$spacing-unit;
border-radius: 4px;
justify-content: center;
&__subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: globals.$spacing-unit / 2;
color: globals.$body-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
&__blured-avatar {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
height: 62px;
position: relative;
filter: blur(4px);
h1 {
margin-bottom: 8px;
}
}
&__small-avatar {
height: 32px;
width: 32px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
object-fit: cover;
}
}
&__subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: 8px;
color: globals.$body-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__user-summary {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
&__container {
display: flex;
flex-direction: column;
width: 100%;
h1 {
margin-bottom: 8px;
}
&__stats {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: globals.$muted-color;
&__trophy-count {
display: flex;
align-items: center;
gap: 8px;
}
&__progress-bar {
width: 100%;
height: 8px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
border-radius: 4px;
}
}
}
}
}
&__achievements-list {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
&__image {
display: none;
}
&__section {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
&__container {
display: flex;
flex-direction: column;
background: linear-gradient(
0deg,
globals.$background-color 0%,
globals.$background-color 100%
);
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
&__content {
padding: globals.$spacing-unit * 2;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&__game-logo {
width: $logo-max-width;
height: $logo-height;
object-fit: contain;
transition: all ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
}
}
&__achievements-summary-wrapper {
display: flex;
flex-direction: column;
width: 100%;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit;
}
}
&__table-header {
width: 100%;
background-color: globals.$background-color;
transition: all ease 0.2s;
border-bottom: 1px solid globals.$border-color;
position: sticky;
top: 0;
z-index: 1;
&--stuck {
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
&__container {
display: grid;
gap: globals.$spacing-unit * 2;
padding: globals.$spacing-unit * 3;
&--has-no-active-subscription {
grid-template-columns: 3fr 2fr;
}
&--has-active-subscription {
grid-template-columns: 3fr 1fr 1fr;
}
&__user-avatar {
display: flex;
justify-content: center;
}
&__other-user-avatar {
display: flex;
justify-content: center;
}
}
}
}
}
&__profile-avatar {
height: 54px;
width: 54px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: globals.$background-color;
position: relative;
object-fit: cover;
}
}

View file

@ -8,18 +8,17 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context";
import type { ComparedAchievements } from "@types";
import { average } from "color.js";
import Color from "color";
import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
import * as styles from "./achievements.css";
import { AchievementList } from "./achievement-list";
import { AchievementPanel } from "./achievement-panel";
import { ComparedAchievementPanel } from "./compared-achievement-panel";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./achievements-content.scss";
interface UserInfo {
id: string;
@ -48,10 +47,10 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
user: Pick<UserInfo, "profileImageUrl" | "displayName">
) => {
return (
<div className={styles.profileAvatar}>
<div className="achievements-content__profile-avatar">
{user.profileImageUrl ? (
<img
className={styles.profileAvatar}
className="achievements-content__profile-avatar"
src={user.profileImageUrl}
alt={user.displayName}
/>
@ -64,91 +63,33 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
if (isComparison && userDetails?.id == user.id && !hasActiveSubscription) {
return (
<div
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
position: "relative",
padding: `${SPACING_UNIT}px`,
}}
>
<div
style={{
position: "absolute",
zIndex: 2,
inset: 0,
width: "100%",
height: "100%",
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
borderRadius: "4px",
justifyContent: "center",
}}
>
<div className="achievements-content__comparison">
<div className="achievements-content__comparison__container">
<LockIcon size={24} />
<h3>
<button
className={styles.subscriptionRequiredButton}
className="achievements-content__comparison__container__subscription-required-button"
onClick={() => showHydraCloudModal("achievements")}
>
{t("subscription_needed")}
</button>
</h3>
</div>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
height: "62px",
position: "relative",
filter: "blur(4px)",
}}
>
<div className="achievements-content__comparison__blured-avatar">
{getProfileImage(user)}
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
<h1>{user.displayName}</h1>
</div>
</div>
);
}
return (
<div
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT}px`,
}}
>
<div className="achievements-content__user-summary">
{getProfileImage(user)}
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<div className="achievements-content__user-summary__container">
<h1>{user.displayName}</h1>
<div className="achievements-content__user-summary__container__stats">
<div className="achievements-content__user-summary__container__stats__trophy-count">
<TrophyIcon size={13} />
<span>
{user.unlockedAchievementCount} / {user.totalAchievementCount}
@ -164,7 +105,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
<progress
max={1}
value={user.unlockedAchievementCount / user.totalAchievementCount}
className={styles.achievementsProgressBar}
className="achievements-content__user-summary__container__stats__progress-bar"
/>
</div>
</div>
@ -203,7 +144,7 @@ export function AchievementsContent({
};
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
const heroHeight = heroRef.current?.clientHeight ?? 150;
const scrollY = (event.target as HTMLDivElement).scrollTop;
if (scrollY >= heroHeight && !isHeaderStuck) {
@ -219,10 +160,10 @@ export function AchievementsContent({
user: Pick<UserInfo, "profileImageUrl" | "displayName">
) => {
return (
<div className={styles.profileAvatarSmall}>
<div className="achievements-content__comparison__small-avatar">
{user.profileImageUrl ? (
<img
className={styles.profileAvatarSmall}
className="achievements-content__comparison__small-avatar"
src={user.profileImageUrl}
alt={user.displayName}
/>
@ -236,10 +177,10 @@ export function AchievementsContent({
if (!objectId || !shop || !gameTitle || !userDetails) return null;
return (
<div className={styles.wrapper}>
<div className="achievements-content__achievements-list">
<img
src={steamUrlBuilder.libraryHero(objectId)}
style={{ display: "none" }}
className="achievements-content__achievements-list__image"
alt={gameTitle}
onLoad={handleHeroLoad}
/>
@ -247,38 +188,32 @@ export function AchievementsContent({
<section
ref={containerRef}
onScroll={onScroll}
className={styles.container}
className="achievements-content__achievements-list__section"
>
<div
className="achievements-content__achievements-list__section__container"
style={{
display: "flex",
flexDirection: "column",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 0%, ${gameColor} 100%)`,
background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
}}
>
<div ref={heroRef} className={styles.hero}>
<div className={styles.heroContent}>
<div
ref={heroRef}
className="achievements-content__achievements-list__section__container__hero"
>
<div className="achievements-content__achievements-list__section__container__hero__content">
<Link
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
>
<img
src={steamUrlBuilder.logo(objectId)}
className={styles.gameLogo}
className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle}
/>
</Link>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
>
<div className="achievements-content__achievements-list__section__container__achievements-summary-wrapper">
<AchievementSummary
user={{
...userDetails,
@ -298,24 +233,19 @@ export function AchievementsContent({
</div>
{otherUser && (
<div className={styles.tableHeader({ stuck: isHeaderStuck })}>
<div
className={`achievements-content__achievements-list__section__table-header ${isHeaderStuck ? "achievements-content__achievements-list__section__table-header--stuck" : ""}`}
>
<div
style={{
display: "grid",
gridTemplateColumns: hasActiveSubscription
? "3fr 1fr 1fr"
: "3fr 2fr",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 3}px`,
}}
className={`achievements-content__achievements-list__section__table-header__container ${hasActiveSubscription ? "achievements-content__achievements-list__section__table-header__container--has-active-subscription" : "achievements-content__achievements-list__section__table-header__container--has-no-active-subscription"}`}
>
<div></div>
{hasActiveSubscription && (
<div style={{ display: "flex", justifyContent: "center" }}>
<div className="achievements-content__achievements-list__section__table-header__container__user-avatar">
{getProfileImage({ ...userDetails })}
</div>
)}
<div style={{ display: "flex", justifyContent: "center" }}>
<div className="achievements-content__achievements-list__section__table-header__container__other-user-avatar">
{getProfileImage(otherUser)}
</div>
</div>

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