Merge branch 'main' into feat/logs-python-process-errors

This commit is contained in:
Zamitto 2024-08-06 11:22:47 -03:00 committed by GitHub
commit 1dcf746fa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1884 additions and 286 deletions

View File

@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.</strong>
<strong>Hydra is a game launcher with its own embedded bittorrent client.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@ -50,17 +50,15 @@
## About
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client** and a **self-managed repack scraper**.
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client**.
<br>
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent.
## Features
- Self-Managed repack scraper among all the most reliable websites on the [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Own embedded bittorrent client
- How Long To Beat (HLTB) integration on game page
- Downloads path customization
- Repack list update notifications
- Windows and Linux support
- Constantly updated
- And more ...
@ -134,9 +132,8 @@ pip install -r requirements.txt
## Environment variables
You'll need an SteamGridDB API Key in order to fetch the game icons on installation.
If you want to have onlinefix as a repacker you'll need to add your credentials to the .env
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`.
## Running

View File

@ -40,6 +40,7 @@
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",

View File

@ -1,4 +1,7 @@
{
"app": {
"successfully_signed_in": "Has entrat correctament"
},
"home": {
"featured": "Destacats",
"trending": "Populars",
@ -14,7 +17,10 @@
"paused": "{{title}} (Pausat)",
"downloading": "{{title}} ({{percentage}} - S'està baixant…)",
"filter": "Filtra la biblioteca",
"home": "Inici"
"home": "Inici",
"queued": "{{title}} (En espera)",
"game_has_no_executable": "El joc encara no té un executable seleccionat",
"sign_in": "Entra"
},
"header": {
"search": "Cerca jocs",
@ -29,7 +35,9 @@
"bottom_panel": {
"no_downloads_in_progress": "Cap baixada en curs",
"downloading_metadata": "S'estan baixant les metadades de: {{title}}…",
"downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}"
"downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}",
"calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…",
"checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)"
},
"catalogue": {
"next_page": "Pàgina següent",
@ -47,12 +55,14 @@
"cancel": "Cancel·la",
"remove": "Elimina",
"space_left_on_disk": "{{space}} lliures al disc",
"eta": "Finalització: {{eta}}",
"eta": "Finalitza en: {{eta}}",
"calculating_eta": "Calculant temps estimat…",
"downloading_metadata": "S'estan baixant les metadades…",
"filter": "Filtra els reempaquetats",
"requirements": "Requisits del sistema",
"minimum": "Mínims",
"recommended": "Recomanats",
"paused": "Paused",
"release_date": "Publicat el {{date}}",
"publisher": "Publicat per {{publisher}}",
"hours": "hores",
@ -81,7 +91,29 @@
"previous_screenshot": "Captura anterior",
"next_screenshot": "Captura següent",
"screenshot": "Captura {{number}}",
"open_screenshot": "Obre la captura {{number}}"
"open_screenshot": "Obre la captura {{number}}",
"download_settings": "Configuració de descàrrega",
"downloader": "Descarregador",
"select_executable": "Selecciona",
"no_executable_selected": "No hi ha executable selccionat",
"open_folder": "Obre carpeta",
"open_download_location": "Visualitzar fitxers descarregats",
"create_shortcut": "Crear accés directe a l'escriptori",
"remove_files": "Elimina fitxers",
"remove_from_library_title": "Segur?",
"remove_from_library_description": "Això eliminarà el videojoc {{game}} del teu catàleg",
"options": "Opcions",
"executable_section_title": "Executable",
"executable_section_description": "Directori del fitxer des d'on s'executarà quan es cliqui a \"Executar\"",
"downloads_secion_title": "Descàrregues",
"downloads_section_description": "Comprova actualitzacions o altres versions del videojoc",
"danger_zone_section_title": "Zona de perill",
"danger_zone_section_description": "Elimina aquest videojoc del teu catàleg o els fitxers descarregats per Hydra",
"download_in_progress": "Descàrrega en progrés",
"download_paused": "Descàrrega en pausa",
"last_downloaded_option": "Opció de l'última descàrrega",
"create_shortcut_success": "Accés directe creat satisfactòriament",
"create_shortcut_error": "Error al crear l'accés directe"
},
"activation": {
"title": "Activa l'Hydra",
@ -98,6 +130,7 @@
"paused": "Pausada",
"verifying": "S'està verificant…",
"completed": "Completada",
"removed": "No descarregat",
"cancel": "Cancel·la",
"filter": "Filtra els jocs baixats",
"remove": "Elimina",
@ -106,7 +139,14 @@
"delete": "Elimina l'instal·lador",
"delete_modal_title": "N'estàs segur?",
"delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació",
"install": "Instal·la"
"install": "Instal·la",
"download_in_progress": "En progrés",
"queued_downloads": "Descàrregues en espera",
"downloads_completed": "Completat",
"queued": "En espera",
"no_downloads_title": "Buit",
"no_downloads_description": "No has descarregat res amb Hydra encara, però mai és tard per començar a fer-ho.",
"checking_files": "Comprovant fitxers…"
},
"settings": {
"downloads_path": "Ruta de baixades",
@ -119,16 +159,49 @@
"launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema",
"general": "General",
"behavior": "Comportament",
"download_sources": "Fonts de descàrrega",
"language": "Idioma",
"real_debrid_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>.",
"save_changes": "Desa els canvis"
"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",
"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.",
"validate_download_source": "Valida",
"remove_download_source": "Elimina",
"add_download_source": "Afegeix font",
"download_count_zero": "No hi ha baixades a la llista",
"download_count_one": "{{countFormatted}} a la llista de baixades",
"download_count_other": "{{countFormatted}} baixades a la llista",
"download_options_zero": "No hi ha cap descàrrega disponible",
"download_options_one": "{{countFormatted}} descàrrega disponible",
"download_options_other": "{{countFormatted}} baixades disponibles",
"download_source_url": "Descarrega l'URL de la font",
"add_download_source_description": "Inseriu la URL que conté el fitxer .json",
"download_source_up_to_date": "Actualitzat",
"download_source_errored": "S'ha produït un error",
"sync_download_sources": "Sincronitza fonts",
"removed_download_source": "S'ha eliminat la font de descàrrega",
"added_download_source": "Added download source",
"download_sources_synced": "Totes les fonts de descàrrega estan sincronitzades",
"insert_valid_json_url": "Insereix una URL JSON vàlida",
"found_download_option_zero": "No s'ha trobat cap opció de descàrrega",
"found_download_option_one": "S'ha trobat l'opció de baixada de {{countFormatted}}",
"found_download_option_other": "S'han trobat {{countFormatted}} opcions de baixada",
"import": "Import"
},
"notifications": {
"download_complete": "La baixada ha finalitzat",
"game_ready_to_install": "{{title}} ja es pot instal·lar",
"repack_list_updated": "S'ha actualitzat la llista de reempaquetats",
"repack_count_one": "S'ha afegit {{count}} reempaquetat",
"repack_count_other": "S'han afegit {{count}} reempaquetats"
"repack_count_other": "S'han afegit {{count}} reempaquetats",
"new_update_available": "Versió {{version}} disponible",
"restart_to_install_update": "Reinicieu Hydra per instal·lar l'actualització"
},
"system_tray": {
"open": "Obre l'Hydra",
@ -144,5 +217,39 @@
},
"modal": {
"close": "Botó de tancar"
},
"forms": {
"toggle_password_visibility": "Commuta la visibilitat de la contrasenya"
},
"user_profile": {
"amount_hours": "{{amount}} hores",
"amount_minutes": "{{amount}} minuts",
"last_time_played": "Última partida {{period}}",
"activity": "Activitat recent",
"library": "Biblioteca",
"total_play_time": "Temps total de joc:{{amount}}",
"no_recent_activity_title": "Hmmm… encara no res",
"no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!",
"display_name": "Nom de visualització",
"saving": "Desant",
"save": "Desa",
"edit_profile": "Edita el Perfil",
"saved_successfully": "S'ha desat correctament",
"try_again": "Siusplau torna-ho a provar",
"sign_out_modal_title": "Segur?",
"cancel": "Cancel·la",
"successfully_signed_out": "S'ha tancat la sessió correctament",
"sign_out": "Tanca sessió",
"playing_for": "Jugant per {{amount}}",
"sign_out_modal_text": "La vostra biblioteca està enllaçada amb el vostre compte actual. Quan tanqueu la sessió, la vostra biblioteca ja no serà visible i cap progrés no es desarà. Voleu continuar amb tancar la sessió?",
"add_friends": "Afegeix amics",
"add": "Afegeix",
"friend_code": "Codi de l'amic",
"see_profile": "Veure Perfil",
"sending": "Enviant",
"friend_request_sent": "Sol·licitud d'amistat enviada",
"friends": "Amistats",
"friends_list": "Llista d'amistats",
"user_not_found": "Usuari no trobat"
}
}

View File

@ -241,6 +241,26 @@
"successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out",
"playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?"
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
"add_friends": "Add Friends",
"add": "Add",
"friend_code": "Friend code",
"see_profile": "See profile",
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"friends_list": "Friends list",
"user_not_found": "User not found",
"block_user": "Block user",
"add_friend": "Add friend",
"request_sent": "Request sent",
"request_received": "Request received",
"accept_request": "Accept request",
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}"
}
}

View File

@ -241,6 +241,15 @@
"successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}",
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?"
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
"add_friends": "Añadir amigos",
"add": "Añadir",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amigos",
"friends_list": "Lista de amigos",
"user_not_found": "Usuario no encontrado"
}
}

View File

@ -1,134 +1,255 @@
{
"app": {
"successfully_signed_in": "Berhasil masuk"
},
"home": {
"featured": "Unggulan",
"trending": "Trending",
"surprise_me": "Kejutkan Saya",
"no_results": "Tidak ada hasil"
"trending": "Sedang Tren",
"surprise_me": "Kejutkan saya",
"no_results": "Tidak ada hasil ditemukan"
},
"sidebar": {
"catalogue": "Katalog",
"downloads": "Unduhan",
"settings": "Pengaturan",
"my_library": "Koleksi saya",
"my_library": "Perpustakaan saya",
"downloading_metadata": "{{title}} (Mengunduh metadata…)",
"paused": "{{title}} (Terhenti)",
"paused": "{{title}} (Dijeda)",
"downloading": "{{title}} ({{percentage}} - Mengunduh…)",
"filter": "Filter koleksi",
"home": "Beranda"
"filter": "Filter perpustakaan",
"home": "Beranda",
"queued": "{{title}} (Antrian)",
"game_has_no_executable": "Game tidak punya file eksekusi yang dipilih",
"sign_in": "Masuk"
},
"header": {
"search": "Pencarian",
"search": "Cari game",
"home": "Beranda",
"catalogue": "Katalog",
"downloads": "Unduhan",
"search_results": "Hasil pencarian",
"settings": "Pengaturan"
"settings": "Pengaturan",
"version_available_install": "Versi {{version}} tersedia. Klik di sini untuk restart dan instal.",
"version_available_download": "Versi {{version}} tersedia. Klik di sini untuk unduh."
},
"bottom_panel": {
"no_downloads_in_progress": "Tidak ada unduhan berjalan",
"downloading_metadata": "Mengunduh metadata {{title}}...",
"downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Perkiraan {{eta}} - {{speed}}"
"no_downloads_in_progress": "Tidak ada unduhan yang sedang berjalan",
"downloading_metadata": "Mengunduh metadata {{title}}…",
"downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Estimasi selesai {{eta}} - {{speed}}",
"calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…",
"checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)"
},
"catalogue": {
"next_page": "Halaman berikutnya",
"previous_page": "Halaman sebelumnya"
"next_page": "Halaman Berikutnya",
"previous_page": "Halaman Sebelumnya"
},
"game_details": {
"open_download_options": "Buka opsi unduhan",
"download_options_zero": "Tidak ada opsi unduhan",
"download_options_one": "{{count}} opsi unduhan",
"download_options_other": "{{count}} opsi unduhan",
"updated_at": "Diperbarui {{updated_at}}",
"install": "Install",
"updated_at": "Diperbarui pada {{updated_at}}",
"install": "Instal",
"resume": "Lanjutkan",
"pause": "Hentikan sementara",
"cancel": "Batalkan",
"pause": "Jeda",
"cancel": "Batal",
"remove": "Hapus",
"space_left_on_disk": "{{space}} tersisa pada disk",
"eta": "Perkiraan {{eta}}",
"space_left_on_disk": "{{space}} tersisa di disk",
"eta": "Estimasi {{eta}}",
"calculating_eta": "Menghitung waktu yang tersisa…",
"downloading_metadata": "Mengunduh metadata…",
"filter": "Saring repacks",
"requirements": "Keperluan sistem",
"filter": "Filter repack",
"requirements": "Persyaratan sistem",
"minimum": "Minimum",
"recommended": "Rekomendasi",
"recommended": "Dianjurkan",
"paused": "Dijeda",
"release_date": "Dirilis pada {{date}}",
"publisher": "Dipublikasikan oleh {{publisher}}",
"publisher": "Diterbitkan oleh {{publisher}}",
"hours": "jam",
"minutes": "menit",
"amount_hours": "{{amount}} jam",
"amount_minutes": "{{amount}} menit",
"accuracy": "{{accuracy}}% akurasi",
"add_to_library": "Tambahkan ke koleksi",
"remove_from_library": "Hapus dari koleksi",
"no_downloads": "Tidak ada unduhan tersedia",
"add_to_library": "Tambah ke perpustakaan",
"remove_from_library": "Hapus dari perpustakaan",
"no_downloads": "Tidak ada yang bisa diunduh",
"play_time": "Dimainkan selama {{amount}}",
"last_time_played": "Terakhir dimainkan {{period}}",
"not_played_yet": "Kamu belum memainkan {{title}}",
"next_suggestion": "Rekomendasi berikutnya",
"play": "Mainkan",
"next_suggestion": "Saran berikutnya",
"play": "Main",
"deleting": "Menghapus installer…",
"close": "Tutup",
"playing_now": "Memainkan sekarang",
"playing_now": "Sedang dimainkan",
"change": "Ubah",
"repacks_modal_description": "Pilih repack yang kamu ingin unduh",
"select_folder_hint": "Untuk merubah folder bawaan, akses melalui",
"download_now": "Unduh sekarang"
"repacks_modal_description": "Pilih repack yang ingin kamu unduh",
"select_folder_hint": "Untuk ganti folder default, buka <0>Pengaturan</0>",
"download_now": "Unduh sekarang",
"no_shop_details": "Gagal mendapatkan detail toko.",
"download_options": "Opsi unduhan",
"download_path": "Path unduhan",
"previous_screenshot": "Screenshot sebelumnya",
"next_screenshot": "Screenshot berikutnya",
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Buka screenshot {{number}}",
"download_settings": "Pengaturan unduhan",
"downloader": "Pengunduh",
"select_executable": "Pilih",
"no_executable_selected": "Tidak ada file eksekusi yang dipilih",
"open_folder": "Buka folder",
"open_download_location": "Lihat file yang diunduh",
"create_shortcut": "Buat pintasan desktop",
"remove_files": "Hapus file",
"remove_from_library_title": "Apa kamu yakin?",
"remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu",
"options": "Opsi",
"executable_section_title": "Eksekusi",
"executable_section_description": "Path file eksekusi saat \"Main\" diklik",
"downloads_secion_title": "Unduhan",
"downloads_section_description": "Cek update atau versi lain dari game ini",
"danger_zone_section_title": "Zona Berbahaya",
"danger_zone_section_description": "Hapus game ini dari perpustakaan kamu atau file yang diunduh oleh Hydra",
"download_in_progress": "Sedang mengunduh",
"download_paused": "Unduhan dijeda",
"last_downloaded_option": "Opsi terakhir diunduh",
"create_shortcut_success": "Pintasan berhasil dibuat",
"create_shortcut_error": "Gagal membuat pintasan"
},
"activation": {
"title": "Aktivasi Hydra",
"installation_id": "ID instalasi:",
"enter_activation_code": "Masukkan kode aktivasi",
"message": "Jika kamu tidak tau dimana bertanya untuk ini, maka kamu tidak seharusnya memiliki ini.",
"title": "Aktifkan Hydra",
"installation_id": "ID Instalasi:",
"enter_activation_code": "Masukkan kode aktivasi kamu",
"message": "Kalau tidak tahu harus tanya ke siapa, berarti kamu tidak perlu ini.",
"activate": "Aktifkan",
"loading": "Memuat…"
},
"downloads": {
"resume": "Lanjutkan",
"pause": "Hentikan sementara",
"eta": "Perkiraan {{eta}}",
"paused": "Terhenti sementara",
"verifying": "Memeriksa…",
"pause": "Jeda",
"eta": "Estimasi {{eta}}",
"paused": "Dijeda",
"verifying": "Verifikasi…",
"completed": "Selesai",
"cancel": "Batalkan",
"filter": "Saring game yang diunduh",
"removed": "Tidak diunduh",
"cancel": "Batal",
"filter": "Filter game yang diunduh",
"remove": "Hapus",
"downloading_metadata": "Mengunduh metadata…",
"deleting": "Menghapus file instalasi…",
"delete": "Hapus file instalasi",
"delete_modal_title": "Kamu yakin?",
"delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu",
"install": "Install"
"deleting": "Menghapus installer…",
"delete": "Hapus installer",
"delete_modal_title": "Apa kamu yakin?",
"delete_modal_description": "Ini akan menghapus semua file instalasi dari komputer kamu",
"install": "Instal",
"download_in_progress": "Sedang berlangsung",
"queued_downloads": "Unduhan dalam antrian",
"downloads_completed": "Selesai",
"queued": "Dalam antrian",
"no_downloads_title": "Kosong",
"no_downloads_description": "Kamu belum mengunduh apa pun dengan Hydra, tapi belum terlambat untuk mulai.",
"checking_files": "Memeriksa file…"
},
"settings": {
"downloads_path": "Lokasi unduhan",
"change": "Perbarui",
"notifications": "Pengingat",
"downloads_path": "Path unduhan",
"change": "Ganti",
"notifications": "Notifikasi",
"enable_download_notifications": "Saat unduhan selesai",
"enable_repack_list_notifications": "Saat repack terbaru ditambahkan",
"enable_repack_list_notifications": "Saat ada repack baru",
"real_debrid_api_token_label": "Token API Real-Debrid",
"quit_app_instead_hiding": "Jangan sembunyikan Hydra saat ditutup",
"launch_with_system": "Jalankan Hydra saat sistem dinyalakan",
"general": "Umum",
"behavior": "Perilaku",
"quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi",
"launch_with_system": "Jalankan saat memulai sistem"
"download_sources": "Sumber unduhan",
"language": "Bahasa",
"real_debrid_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>",
"real_debrid_free_account_error": "Akun \"{{username}}\" adalah akun gratis. Silakan berlangganan Real-Debrid",
"real_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.",
"validate_download_source": "Validasi",
"remove_download_source": "Hapus",
"add_download_source": "Tambahkan sumber",
"download_count_zero": "Tidak ada unduhan dalam daftar",
"download_count_one": "{{countFormatted}} unduhan dalam daftar",
"download_count_other": "{{countFormatted}} unduhan dalam daftar",
"download_options_zero": "Tidak ada unduhan tersedia",
"download_options_one": "{{countFormatted}} unduhan tersedia",
"download_options_other": "{{countFormatted}} unduhan tersedia",
"download_source_url": "URL sumber unduhan",
"add_download_source_description": "Masukkan URL yang berisi file .json",
"download_source_up_to_date": "Terkini",
"download_source_errored": "Terjadi kesalahan",
"sync_download_sources": "Sinkronkan sumber",
"removed_download_source": "Sumber unduhan dihapus",
"added_download_source": "Sumber unduhan ditambahkan",
"download_sources_synced": "Semua sumber unduhan disinkronkan",
"insert_valid_json_url": "Masukkan URL JSON yang valid",
"found_download_option_zero": "Tidak ada opsi unduhan ditemukan",
"found_download_option_one": "Ditemukan {{countFormatted}} opsi unduhan",
"found_download_option_other": "Ditemukan {{countFormatted}} opsi unduhan",
"import": "Impor"
},
"notifications": {
"download_complete": "Unduhan selesai",
"game_ready_to_install": "{{title}} sudah siap untuk instalasi",
"game_ready_to_install": "{{title}} siap untuk diinstal",
"repack_list_updated": "Daftar repack diperbarui",
"repack_count_one": "{{count}} repack ditambahkan",
"repack_count_other": "{{count}} repack ditambahkan"
"repack_count_other": "{{count}} repack ditambahkan",
"new_update_available": "Versi {{version}} tersedia",
"restart_to_install_update": "Restart Hydra untuk instal pembaruan"
},
"system_tray": {
"open": "Buka Hydra",
"quit": "Tutup"
"quit": "Keluar"
},
"game_card": {
"no_downloads": "Tidak ada unduhan tersedia"
"no_downloads": "Tidak ada unduhan yang tersedia"
},
"binary_not_found_modal": {
"title": "Program tidak terinstal",
"description": "Wine atau Lutris exe tidak ditemukan pada sistem kamu",
"instructions": "Periksa cara instalasi yang benar pada Linux distro-mu agar game dapat dimainkan dengan benar"
"title": "Program tidak terpasang",
"description": "Executable Wine atau Lutris tidak ditemukan di sistem kamu",
"instructions": "Cek cara instalasi yang benar di distro Linux kamu agar game bisa jalan normal"
},
"modal": {
"close": "Tombol tutup"
"close": "Tutup"
},
"forms": {
"toggle_password_visibility": "Tampilkan/Sembunyikan kata sandi"
},
"user_profile": {
"amount_hours": "{{amount}} jam",
"amount_minutes": "{{amount}} menit",
"last_time_played": "Terakhir dimainkan {{period}}",
"activity": "Aktivitas terbaru",
"library": "Perpustakaan",
"total_play_time": "Total waktu bermain: {{amount}}",
"no_recent_activity_title": "Hmm… kosong di sini",
"no_recent_activity_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!",
"display_name": "Nama tampilan",
"saving": "Menyimpan",
"save": "Simpan",
"edit_profile": "Edit Profil",
"saved_successfully": "Berhasil disimpan",
"try_again": "Coba lagi yuk",
"sign_out_modal_title": "Apa kamu yakin?",
"cancel": "Batal",
"successfully_signed_out": "Berhasil keluar",
"sign_out": "Keluar",
"playing_for": "Bermain selama {{amount}}",
"sign_out_modal_text": "Perpustakaan kamu terhubung dengan akun saat ini. Saat keluar, perpustakaan kamu tidak akan terlihat lagi, dan progres tidak akan disimpan. Lanjutkan keluar?",
"add_friends": "Tambah Teman",
"add": "Tambah",
"friend_code": "Kode teman",
"see_profile": "Lihat profil",
"sending": "Mengirim",
"friend_request_sent": "Permintaan teman terkirim",
"friends": "Teman",
"friends_list": "Daftar teman",
"user_not_found": "Pengguna tidak ditemukan"
}
}

View File

@ -12,11 +12,11 @@
"catalogue": "Catálogo",
"downloads": "Downloads",
"settings": "Ajustes",
"my_library": "Minha biblioteca",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca",
"filter": "Buscar",
"home": "Início",
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado",
@ -54,7 +54,7 @@
"calculating_eta": "Calculando tempo restante…",
"downloading_metadata": "Baixando metadados…",
"filter": "Filtrar repacks",
"requirements": "Requisitos do sistema",
"requirements": "Requisitos de sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"paused": "Pausado",
@ -68,16 +68,16 @@
"add_to_library": "Adicionar à biblioteca",
"remove_from_library": "Remover da biblioteca",
"no_downloads": "Nenhum download disponível",
"play_time": "Jogado por {{amount}}",
"play_time": "Jogou por {{amount}}",
"next_suggestion": "Próxima sugestão",
"install": "Instalar",
"last_time_played": "Jogou por último {{period}}",
"last_time_played": "Última sessão {{period}}",
"play": "Jogar",
"not_played_yet": "Você ainda não jogou {{title}}",
"close": "Fechar",
"deleting": "Excluindo instalador…",
"playing_now": "Jogando agora",
"change": "Mudar",
"change": "Explorar",
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download",
@ -90,13 +90,13 @@
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"downloader": "Downloader",
"select_executable": "Selecionar",
"select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos",
"options": "Opções",
"options": "Gerenciar",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
@ -120,7 +120,7 @@
"loading": "Carregando…"
},
"downloads": {
"resume": "Resumir",
"resume": "Retomar",
"pause": "Pausar",
"eta": "Conclusão {{eta}}",
"paused": "Pausado",
@ -146,12 +146,12 @@
},
"settings": {
"downloads_path": "Diretório dos downloads",
"change": "Mudar",
"change": "Explorar...",
"notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
"launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral",
"behavior": "Comportamento",
@ -208,7 +208,7 @@
},
"binary_not_found_modal": {
"title": "Programas não instalados",
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
"description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.",
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
},
"catalogue": {
@ -224,8 +224,8 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"last_time_played": "Jogou {{period}}",
"activity": "Atividade recente",
"last_time_played": "Última sessão {{period}}",
"activity": "Atividades recentes",
"library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}",
"no_recent_activity_title": "Hmmm… nada por aqui",
@ -233,7 +233,7 @@
"display_name": "Nome de exibição",
"saving": "Salvando…",
"save": "Salvar",
"edit_profile": "Editar Perfil",
"edit_profile": "Editar perfil",
"saved_successfully": "Salvo com sucesso",
"try_again": "Por favor, tente novamente",
"cancel": "Cancelar",
@ -241,6 +241,26 @@
"sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}",
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?"
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
"add_friends": "Adicionar Amigos",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
"user_not_found": "Usuário não encontrado",
"block_user": "Bloquear",
"add_friend": "Adicionar amigo",
"request_sent": "Pedido enviado",
"request_received": "Pedido recebido",
"accept_request": "Aceitar pedido",
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}"
}
}

View File

@ -43,8 +43,15 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request";
import "./profile/update-profile";
import "./profile/send-friend-request";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { FriendRequest } from "@types";
const getFriendRequests = async (
_event: Electron.IpcMainInvokeEvent
): Promise<FriendRequest[]> => {
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
};
registerEvent("getFriendRequests", getFriendRequests);

View File

@ -9,9 +9,7 @@ const getMe = async (
_event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`)
.then((response) => {
const me = response.data;
.then((me) => {
userAuthRepository.upsert(
{
id: 1,
@ -26,12 +24,18 @@ const getMe = async (
return me;
})
.catch((err) => {
.catch(async (err) => {
if (err instanceof UserNotLoggedInError) {
return null;
}
return userAuthRepository.findOne({ where: { id: 1 } });
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
if (loggedUser) {
return { ...loggedUser, id: loggedUser.userId };
}
return null;
});
};

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const sendFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
};
registerEvent("sendFriendRequest", sendFriendRequest);

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const undoFriendship = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.delete(`/profile/friends/${userId}`);
};
registerEvent("undoFriendship", undoFriendship);

View File

@ -0,0 +1,19 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { FriendRequestAction } from "@types";
const updateFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
action: FriendRequestAction
) => {
if (action == "CANCEL") {
return HydraApi.delete(`/profile/friend-requests/${userId}`);
}
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
requestState: action,
});
};
registerEvent("updateFriendRequest", updateFriendRequest);

View File

@ -26,11 +26,9 @@ const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
displayName: string,
newProfileImagePath: string | null
) => {
): Promise<UserProfile> => {
if (!newProfileImagePath) {
return patchUserProfile(displayName).then(
(response) => response.data as UserProfile
);
return patchUserProfile(displayName);
}
const stats = fs.statSync(newProfileImagePath);
@ -42,7 +40,7 @@ const updateProfile = async (
imageLength: fileSizeInBytes,
})
.then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data;
const { presignedUrl, profileImageUrl } = preSignedResponse;
const mimeType = await fileTypeFromFile(newProfileImagePath);
@ -51,13 +49,11 @@ const updateProfile = async (
"Content-Type": mimeType?.mime,
},
});
return profileImageUrl;
return profileImageUrl as string;
})
.catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl).then(
(response) => response.data as UserProfile
);
return patchUserProfile(displayName, profileImageUrl);
};
registerEvent("updateProfile", updateProfile);

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const blockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/user/${userId}/block`);
};
registerEvent("blockUser", blockUser);

View File

@ -0,0 +1,29 @@
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserFriends } from "@types";
export const getUserFriends = async (
userId: string,
take: number,
skip: number
): Promise<UserFriends> => {
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
if (loggedUser?.userId === userId) {
return HydraApi.get(`/profile/friends`, { take, skip });
}
return HydraApi.get(`/user/${userId}/friends`, { take, skip });
};
const getUserFriendsEvent = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number,
skip: number
) => {
return getUserFriends(userId, take, skip);
};
registerEvent("getUserFriends", getUserFriendsEvent);

View File

@ -4,14 +4,19 @@ import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserProfile | null> => {
try {
const response = await HydraApi.get(`/user/${userId}`);
const profile = response.data;
const [profile, friends] = await Promise.all([
HydraApi.get(`/user/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
const recentGames = await Promise.all(
profile.recentGames.map(async (game) => {
@ -47,7 +52,13 @@ const getUser = async (
})
);
return { ...profile, libraryGames, recentGames };
return {
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
};
} catch (err) {
return null;
}

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const unblockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/user/${userId}/unblock`);
};
registerEvent("unblockUser", unblockUser);

View File

@ -20,6 +20,8 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
@ -121,6 +123,7 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
/* Disconnects libtorrent */
PythonInstance.kill();
logger.log("Quit Hydra");
});
app.on("activate", () => {

View File

@ -10,7 +10,7 @@ import { UserNotLoggedInError } from "@shared";
export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@ -45,6 +45,8 @@ export class HydraApi {
expirationTimestamp: tokenExpirationTimestamp,
};
logger.log("Sign in received", this.userAuth);
await userAuthRepository.upsert(
{
id: 1,
@ -70,11 +72,11 @@ export class HydraApi {
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.data);
logger.log(request.method, request.url, request.params, request.data);
return request;
},
(error) => {
logger.log("request error", error);
logger.error("request error", error);
return Promise.reject(error);
}
);
@ -95,12 +97,18 @@ export class HydraApi {
const { config } = error;
logger.error(config.method, config.baseURL, config.url, config.headers);
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error(error.response.status, error.response.data);
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error(error.request);
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
@ -146,6 +154,8 @@ export class HydraApi {
this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
logger.log("Token refreshed", this.userAuth);
userAuthRepository.upsert(
{
id: 1,
@ -170,6 +180,8 @@ export class HydraApi {
private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) {
logger.error("401 - Current credentials:", this.userAuth);
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
@ -184,48 +196,53 @@ export class HydraApi {
throw err;
};
static async get(url: string) {
static async get<T = any>(url: string, params?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance
.get(url, this.getAxiosConfig())
.get<T>(url, { params, ...this.getAxiosConfig() })
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async post(url: string, data?: any) {
static async post<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance
.post(url, data, this.getAxiosConfig())
.post<T>(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async put(url: string, data?: any) {
static async put<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance
.put(url, data, this.getAxiosConfig())
.put<T>(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async patch(url: string, data?: any) {
static async patch<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance
.patch(url, data, this.getAxiosConfig())
.patch<T>(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async delete(url: string) {
static async delete<T = any>(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
return this.instance
.delete(url, this.getAxiosConfig())
.delete<T>(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
}

View File

@ -10,11 +10,7 @@ export const createGame = async (game: Game) => {
lastTimePlayed: game.lastTimePlayed,
})
.then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
gameRepository.update(
{ objectID: game.objectID },

View File

@ -6,7 +6,7 @@ import { getSteamAppAsset } from "@main/helpers";
export const mergeWithRemoteGames = async () => {
return HydraApi.get("/games")
.then(async (response) => {
for (const game of response.data) {
for (const game of response) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,

View File

@ -9,6 +9,7 @@ import type {
AppUpdaterEvent,
StartGameDownloadPayload,
GameRunning,
FriendRequestAction,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@ -134,11 +135,22 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */
getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>
ipcRenderer.invoke("sendFriendRequest", userId),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip),
/* Auth */
signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com https://video.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1c">

View File

@ -25,6 +25,7 @@ import {
setGameRunning,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
export interface AppProps {
children: React.ReactNode;
@ -38,7 +39,15 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
const {
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
fetchFriendRequests,
hideFriendsModal,
} = useUserDetails();
const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
@ -94,7 +103,10 @@ export function App() {
}
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
if (response) {
updateUserDetails(response);
fetchFriendRequests();
}
});
}, [fetchUserDetails, updateUserDetails, dispatch]);
@ -102,6 +114,7 @@ export function App() {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
fetchFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
@ -206,6 +219,15 @@ export function App() {
onClose={handleToastClose}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

@ -1,7 +1,18 @@
import { style } from "@vanilla-extract/css";
import { createVar, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainerBackground = createVar();
export const profileContainer = style({
background: profileContainerBackground,
position: "relative",
cursor: "pointer",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
@ -10,9 +21,8 @@ export const profileButton = style({
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
width: "100%",
zIndex: "10",
});
export const profileButtonContent = style({
@ -64,3 +74,25 @@ export const profileButtonTitle = style({
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const friendRequestContainer = style({
position: "absolute",
padding: "8px",
right: `${SPACING_UNIT}px`,
display: "flex",
top: 0,
bottom: 0,
alignItems: "center",
});
export const friendRequestButton = style({
color: vars.color.success,
cursor: "pointer",
borderRadius: "50%",
overflow: "hidden",
width: "40px",
height: "40px",
":hover": {
color: vars.color.muted,
},
});

View File

@ -1,17 +1,29 @@
import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react";
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
export function SidebarProfile() {
const navigate = useNavigate();
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails();
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
useEffect(() => {
setReceivedRequests(
friendRequests.filter((request) => request.type === "RECEIVED")
);
}, [friendRequests]);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -30,10 +42,15 @@ export function SidebarProfile() {
}, [profileBackground]);
return (
<div
className={styles.profileContainer}
style={assignInlineVars({
[profileContainerBackground]: profileButtonBackground,
})}
>
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleButtonClick}
>
<div className={styles.profileButtonContent}>
@ -45,7 +62,7 @@ export function SidebarProfile() {
alt={userDetails.displayName}
/>
) : (
<PersonIcon />
<PersonIcon size={24} />
)}
</div>
@ -71,5 +88,20 @@ export function SidebarProfile() {
)}
</div>
</button>
{userDetails && receivedRequests.length > 0 && !gameRunning && (
<div className={styles.friendRequestContainer}>
<button
type="button"
className={styles.friendRequestButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
>
<PersonAddIcon size={24} />
{receivedRequests.length}
</button>
</div>
)}
</div>
);
}

View File

@ -14,6 +14,9 @@ import type {
RealDebridUser,
DownloadSource,
UserProfile,
FriendRequest,
FriendRequestAction,
UserFriends,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -125,13 +128,27 @@ declare global {
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (
userId: string,
take: number,
skip: number
) => Promise<UserFriends>;
/* Profile */
getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>;
updateProfile: (
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
}
interface Window {

View File

@ -1,14 +1,23 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import type { UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState {
userDetails: UserDetails | null;
profileBackground: null | string;
friendRequests: FriendRequest[];
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
}
const initialState: UserDetailsState = {
userDetails: null,
profileBackground: null,
friendRequests: [],
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
};
export const userDetailsSlice = createSlice({
@ -21,8 +30,28 @@ export const userDetailsSlice = createSlice({
setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload;
},
setFriendRequests: (state, action: PayloadAction<FriendRequest[]>) => {
state.friendRequests = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
},
});
export const { setUserDetails, setProfileBackground } =
userDetailsSlice.actions;
export const {
setUserDetails,
setProfileBackground,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@ -1,6 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = {
library: (objectID: string) =>
@ -45,3 +46,14 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const profileBackgroundFromProfileImage = async (
profileImageUrl: string
) => {
const output = await average(profileImageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
};

View File

@ -1,17 +1,27 @@
import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features";
import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types";
import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() {
const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector(
(state) => state.userDetails
);
const {
userDetails,
profileBackground,
friendRequests,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
@ -31,12 +41,9 @@ export function useUserDetails() {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, {
amount: 1,
format: "hex",
});
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
const profileBackground = await profileBackgroundFromProfileImage(
userDetails.profileImageUrl
);
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
@ -78,13 +85,76 @@ export function useUserDetails() {
[updateUserDetails]
);
const fetchFriendRequests = useCallback(() => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron
.sendFriendRequest(userId)
.then(() => fetchFriendRequests());
},
[fetchFriendRequests]
);
const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => {
return window.electron
.updateFriendRequest(userId, action)
.then(() => fetchFriendRequests());
},
[fetchFriendRequests]
);
const undoFriendship = (userId: string) => {
return window.electron.undoFriendship(userId);
};
const blockUser = (userId: string) => {
return window.electron.blockUser(userId);
};
const unblockUser = (userId: string) => {
return window.electron.unblockUser(userId);
};
return {
userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
showFriendsModal,
hideFriendsModal,
fetchUserDetails,
signOut,
clearUserDetails,
updateUserDetails,
patchUser,
profileBackground,
sendFriendRequest,
fetchFriendRequests,
updateFriendRequestState,
blockUser,
unblockUser,
undoFriendship,
};
}

View File

@ -0,0 +1 @@
export * from "./user-friend-modal";

View File

@ -0,0 +1,136 @@
import {
CheckCircleIcon,
PersonIcon,
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export type UserFriendItemProps = {
userId: string;
profileImageUrl: string | null;
displayName: string;
onClickItem: (userId: string) => void;
} & (
| { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void }
| {
type: "SENT" | "RECEIVED";
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
}
| { type: null }
);
export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type, onClickItem } = props;
const getRequestDescription = () => {
if (type === "ACCEPTED" || type === null) return null;
return (
<small>
{type == "SENT" ? t("request_sent") : t("request_received")}
</small>
);
};
const getRequestActions = () => {
if (type === null) return null;
if (type === "SENT") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickCancelRequest(userId)}
title={t("cancel_request")}
>
<XCircleIcon size={28} />
</button>
);
}
if (type === "RECEIVED") {
return (
<>
<button
className={styles.acceptRequestButton}
onClick={() => props.onClickAcceptRequest(userId)}
title={t("accept_request")}
>
<CheckCircleIcon size={28} />
</button>
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickRefuseRequest(userId)}
title={t("ignore_request")}
>
<XCircleIcon size={28} />
</button>
</>
);
}
if (type === "ACCEPTED") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickUndoFriendship(userId)}
title={t("undo_friendship")}
>
<XCircleIcon size={28} />
</button>
);
}
return null;
};
return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickItem(userId)}
>
<div className={styles.friendAvatarContainer}>
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
}}
>
<p className={styles.friendListDisplayName}>{displayName}</p>
{getRequestDescription()}
</div>
</button>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{getRequestActions()}
</div>
</div>
);
};

View File

@ -0,0 +1,144 @@
import { Button, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UserFriendItem } from "./user-friend-item";
export interface UserFriendModalAddFriendProps {
closeModal: () => void;
}
export const UserFriendModalAddFriend = ({
closeModal,
}: UserFriendModalAddFriendProps) => {
const { t } = useTranslation("user_profile");
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const navigate = useNavigate();
const { sendFriendRequest, updateFriendRequestState, friendRequests } =
useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
// TODO: add validation for this input?
setFriendCode("");
})
.catch(() => {
showErrorToast("Não foi possível enviar o pedido de amizade");
})
.finally(() => {
setIsAddingFriend(false);
});
};
const resetAndClose = () => {
setFriendCode("");
closeModal();
};
const handleClickRequest = (userId: string) => {
resetAndClose();
navigate(`/user/${userId}`);
};
const handleClickSeeProfile = () => {
resetAndClose();
// TODO: add validation for this input?
navigate(`/user/${friendCode}`);
};
const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast(t("try_again"));
});
};
const handleAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED")
.then(() => {
showSuccessToast(t("request_accepted"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
const handleRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<TextField
label={t("friend_code")}
value={friendCode}
minLength={8}
maxLength={8}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setFriendCode(e.target.value)}
/>
<Button
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
onClick={handleClickAddFriend}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
onClick={handleClickSeeProfile}
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
>
{t("see_profile")}
</Button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>Pendentes</h3>
{friendRequests.map((request) => {
return (
<UserFriendItem
key={request.id}
displayName={request.displayName}
type={request.type}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleAcceptFriendRequest}
onClickCancelRequest={handleCancelFriendRequest}
onClickRefuseRequest={handleRefuseFriendRequest}
onClickItem={handleClickRequest}
/>
);
})}
</div>
</>
);
};

View File

@ -0,0 +1,95 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import { UserFriend } from "@types";
import { useEffect, useState } from "react";
import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserFriendModalListProps {
userId: string;
closeModal: () => void;
}
const pageSize = 12;
export const UserFriendModalList = ({
userId,
closeModal,
}: UserFriendModalListProps) => {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]);
const { userDetails, undoFriendship } = useUserDetails();
const isMe = userDetails?.id == userId;
const loadNextPage = () => {
if (page > maxPage) return;
window.electron
.getUserFriends(userId, pageSize, page * pageSize)
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalFriends / pageSize);
}
setFriends([...friends, ...newPage.friends]);
setPage(page + 1);
})
.catch(() => {});
};
const reloadList = () => {
setPage(0);
setMaxPage(0);
setFriends([]);
loadNextPage();
};
useEffect(() => {
reloadList();
}, [userId]);
const handleClickFriend = (userId: string) => {
closeModal();
navigate(`/user/${userId}`);
};
const handleUndoFriendship = (userId: string) => {
undoFriendship(userId)
.then(() => {
reloadList();
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{friends.map((friend) => {
return (
<UserFriendItem
userId={friend.id}
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null}
key={friend.id}
/>
);
})}
</div>
);
};

View File

@ -0,0 +1,92 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
});
export const friendListContainer = style({
width: "100%",
height: "54px",
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendListButton = style({
display: "flex",
alignItems: "center",
position: "absolute",
cursor: "pointer",
height: "100%",
width: "100%",
flexDirection: "row",
color: vars.color.body,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
padding: `0 ${SPACING_UNIT}px`,
});
export const friendRequestItem = style({
color: vars.color.body,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.success,
},
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.danger,
},
});

View File

@ -0,0 +1,85 @@
import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
import { useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list";
export enum UserFriendModalTab {
FriendsList,
AddFriend,
}
export interface UserFriendsModalProps {
visible: boolean;
onClose: () => void;
initialTab: UserFriendModalTab | null;
userId: string;
}
export const UserFriendModal = ({
visible,
onClose,
initialTab,
userId,
}: UserFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")];
const [currentTab, setCurrentTab] = useState(
initialTab || UserFriendModalTab.FriendsList
);
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
useEffect(() => {
if (initialTab != null) {
setCurrentTab(initialTab);
}
}, [initialTab]);
const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) {
return <UserFriendModalList userId={userId} closeModal={onClose} />;
}
if (currentTab == UserFriendModalTab.AddFriend) {
return <UserFriendModalAddFriend closeModal={onClose} />;
}
return <></>;
};
return (
<Modal visible={visible} title={t("friends")} onClose={onClose}>
<div
style={{
display: "flex",
width: "500px",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{isMe && (
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => {
return (
<Button
key={tab}
theme={index === currentTab ? "primary" : "outline"}
onClick={() => setCurrentTab(index)}
>
{tab}
</Button>
);
})}
</section>
)}
{renderTab()}
</div>
</Modal>
);
};

View File

@ -0,0 +1,44 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserBlockModalProps {
visible: boolean;
displayName: string;
onConfirm: () => void;
onClose: () => void;
}
export const UserBlockModal = ({
visible,
displayName,
onConfirm,
onClose,
}: UserBlockModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p style={{ fontFamily: "Fira Sans" }}>
{t("user_block_modal_text", { displayName })}
</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("block_user")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View File

@ -1,9 +1,8 @@
import { UserGame, UserProfile } from "@types";
import { FriendRequestAction, UserGame, UserProfile } from "@types";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import {
@ -13,11 +12,23 @@ import {
useUserDetails,
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react";
import {
buildGameDetailsPath,
profileBackgroundFromProfileImage,
steamUrlBuilder,
} from "@renderer/helpers";
import {
CheckCircleIcon,
PersonIcon,
PlusIcon,
TelescopeIcon,
XCircleIcon,
} from "@primer/octicons-react";
import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
import { UserSignOutModal } from "./user-sign-out-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -26,17 +37,32 @@ export interface ProfileContentProps {
updateUserProfile: () => Promise<void>;
}
type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND");
export function UserContent({
userProfile,
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails();
const { showSuccessToast } = useToast();
const {
userDetails,
profileBackground,
signOut,
sendFriendRequest,
fetchFriendRequests,
showFriendsModal,
updateFriendRequestState,
undoFriendship,
blockUser,
} = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -72,6 +98,10 @@ export function UserContent({
setShowEditProfileModal(true);
};
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => {
await signOut();
@ -82,11 +112,142 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id;
const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */
return undefined;
}, [profileBackground]);
useEffect(() => {
if (isMe) fetchFriendRequests();
}, [isMe]);
useEffect(() => {
if (isMe && profileBackground) {
setProfileContentBoxBackground(profileBackground);
}
if (userProfile.profileImageUrl) {
profileBackgroundFromProfileImage(userProfile.profileImageUrl).then(
(profileBackground) => {
setProfileContentBoxBackground(profileBackground);
}
);
}
}, [profileBackground, isMe]);
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
if (action === "UNDO") {
undoFriendship(userId).then(updateUserProfile);
return;
}
if (action === "BLOCK") {
blockUser(userId).then(() => {
setShowUserBlockModal(false);
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
sendFriendRequest(userProfile.id).then(updateUserProfile);
return;
}
updateFriendRequestState(userId, action).then(updateUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
}
};
const showFriends = isMe || userProfile.totalFriends > 0;
const getProfileActions = () => {
if (isMe) {
return (
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}>
{t("sign_out")}
</Button>
</>
);
}
if (userProfile.relation == null) {
return (
<>
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
>
{t("add_friend")}
</Button>
<Button theme="danger" onClick={() => setShowUserBlockModal(true)}>
{t("block_user")}
</Button>
</>
);
}
if (userProfile.relation.status === "ACCEPTED") {
const userId =
userProfile.relation.AId === userDetails?.id
? userProfile.relation.BId
: userProfile.relation.AId;
return (
<>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() => handleFriendAction(userId, "UNDO")}
>
<XCircleIcon size={28} /> {t("undo_friendship")}
</Button>
</>
);
}
if (userProfile.relation.BId === userProfile.id) {
return (
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
>
<XCircleIcon size={28} /> {t("cancel_request")}
</Button>
);
}
return (
<>
<Button
theme="outline"
className={styles.acceptRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
>
<CheckCircleIcon size={28} /> {t("accept_request")}
</Button>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
>
<XCircleIcon size={28} /> {t("ignore_request")}
</Button>
</>
);
};
return (
<>
@ -103,6 +264,13 @@ export function UserContent({
onConfirm={handleConfirmSignout}
/>
<UserBlockModal
visible={showUserBlockModal}
onClose={() => setShowUserBlockModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")}
displayName={userProfile.displayName}
/>
<section
className={styles.profileContentBox}
style={{
@ -173,7 +341,6 @@ export function UserContent({
)}
</div>
{isMe && (
<div
style={{
flex: 1,
@ -189,21 +356,9 @@ export function UserContent({
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
{getProfileActions()}
</div>
</div>
)}
</section>
<div className={styles.profileContent}>
@ -216,9 +371,11 @@ export function UserContent({
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && (
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
)}
</div>
) : (
<div
@ -259,7 +416,8 @@ export function UserContent({
)}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<div className={styles.contentSidebar}>
<div className={styles.profileGameSection}>
<div
style={{
display: "flex",
@ -309,6 +467,85 @@ export function UserContent({
))}
</div>
</div>
{showFriends && (
<div className={styles.friendsSection}>
<button
className={styles.friendsSectionHeader}
onClick={() =>
showFriendsModal(
UserFriendModalTab.FriendsList,
userProfile.id
)
}
>
<h2>{t("friends")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.totalFriends}
</h3>
</button>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.friends.map((friend) => {
return (
<button
key={friend.id}
className={cn(
styles.profileContentBox,
styles.friendListContainer
)}
onClick={() => handleOnClickFriend(friend.id)}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div>
</div>
</>
);

View File

@ -2,7 +2,7 @@ import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
export interface UserSignOutModalProps {
visible: boolean;
onConfirm: () => void;
onClose: () => void;
@ -12,7 +12,7 @@ export const UserSignOutModal = ({
visible,
onConfirm,
onClose,
}: UserEditProfileModalProps) => {
}: UserSignOutModalProps) => {
const { t } = useTranslation("user_profile");
return (

View File

@ -11,6 +11,7 @@ export const wrapper = style({
export const profileContentBox = style({
display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
zIndex: 1,
});
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatarEditContainer = style({
width: "128px",
height: "128px",
@ -53,9 +77,9 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
});
export const profileAvatarEditOverlay = style({
@ -86,14 +110,36 @@ export const profileContent = style({
export const profileGameSection = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSectionHeader = style({
fontSize: vars.size.body,
color: vars.color.body,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
":hover": {
color: vars.color.muted,
},
});
export const contentSidebar = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": {
"(min-width: 768px)": {
width: "100%",
@ -116,12 +162,17 @@ export const libraryGameIcon = style({
borderRadius: "4px",
});
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "hidden",
height: "72px",
transition: "all ease 0.2s",
cursor: "pointer",
@ -143,6 +194,19 @@ export const gameListItem = style({
},
});
export const friendListContainer = style({
color: vars.color.body,
width: "100%",
height: "54px",
padding: `0 ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({
display: "flex",
flexDirection: "column",
@ -215,3 +279,16 @@ export const profileBackground = style({
top: "0",
borderRadius: "4px",
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
":hover": {
color: vars.color.danger,
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.success,
});

View File

@ -2,18 +2,23 @@ import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { useAppDispatch, useToast } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export const User = () => {
const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate();
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => {
@ -22,10 +27,11 @@ export const User = () => {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
}, [dispatch, userId]);
}, [dispatch, userId, t]);
useEffect(() => {
getUserProfile();

View File

@ -10,6 +10,8 @@ export type GameStatus =
export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre {
id: string;
name: string;
@ -269,14 +271,43 @@ export interface UserDetails {
profileImageUrl: string | null;
}
export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
}
export interface UserFriends {
totalFriends: number;
friends: UserFriend[];
}
export interface FriendRequest {
id: string;
displayName: string;
profileImageUrl: string | null;
type: "SENT" | "RECEIVED";
}
export interface UserRelation {
AId: string;
BId: string;
status: "ACCEPTED" | "PENDING";
createdAt: string;
updatedAt: string;
}
export interface UserProfile {
id: string;
displayName: string;
username: string;
profileImageUrl: string | null;
profileVisibility: "PUBLIC" | "PRIVATE" | "FRIENDS";
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];
recentGames: UserGame[];
friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
}
export interface DownloadSource {

View File

@ -2433,6 +2433,13 @@
modern-ahocorasick "^1.0.0"
picocolors "^1.0.0"
"@vanilla-extract/dynamic@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.1.tgz#bc93a577b127a7dcb6f254973d13a863029a7faf"
integrity sha512-iqf736036ujEIKsIq28UsBEMaLC2vR2DhwKyrG3NDb/fRy9qL9FKl1TqTtBV4daU30Uh3saeik4vRzN8bzQMbw==
dependencies:
"@vanilla-extract/private" "^1.0.5"
"@vanilla-extract/integration@^7.1.3":
version "7.1.4"
resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz"
@ -2456,6 +2463,11 @@
resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz"
integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg==
"@vanilla-extract/private@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.5.tgz#8c08ac4851f4cc89a3dcdb858d8938e69b1481c4"
integrity sha512-6YXeOEKYTA3UV+RC8DeAjFk+/okoNz/h88R+McnzA2zpaVqTR/Ep+vszkWYlGBcMNO7vEkqbq5nT/JMMvhi+tw==
"@vanilla-extract/recipes@^0.5.2":
version "0.5.2"
resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"