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> <h1 align="center">Hydra Launcher</h1>
<p align="center"> <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> </p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@ -50,17 +50,15 @@
## About ## 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> <br>
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent. The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent.
## Features ## 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 - Own embedded bittorrent client
- How Long To Beat (HLTB) integration on game page - How Long To Beat (HLTB) integration on game page
- Downloads path customization - Downloads path customization
- Repack list update notifications
- Windows and Linux support - Windows and Linux support
- Constantly updated - Constantly updated
- And more ... - And more ...
@ -134,9 +132,8 @@ pip install -r requirements.txt
## Environment variables ## Environment variables
You'll need an SteamGridDB API Key in order to fetch the game icons on installation. 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 ## Running

View File

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

View File

@ -1,4 +1,7 @@
{ {
"app": {
"successfully_signed_in": "Has entrat correctament"
},
"home": { "home": {
"featured": "Destacats", "featured": "Destacats",
"trending": "Populars", "trending": "Populars",
@ -14,7 +17,10 @@
"paused": "{{title}} (Pausat)", "paused": "{{title}} (Pausat)",
"downloading": "{{title}} ({{percentage}} - S'està baixant…)", "downloading": "{{title}} ({{percentage}} - S'està baixant…)",
"filter": "Filtra la biblioteca", "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": { "header": {
"search": "Cerca jocs", "search": "Cerca jocs",
@ -29,7 +35,9 @@
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "Cap baixada en curs", "no_downloads_in_progress": "Cap baixada en curs",
"downloading_metadata": "S'estan baixant les metadades de: {{title}}…", "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": { "catalogue": {
"next_page": "Pàgina següent", "next_page": "Pàgina següent",
@ -47,12 +55,14 @@
"cancel": "Cancel·la", "cancel": "Cancel·la",
"remove": "Elimina", "remove": "Elimina",
"space_left_on_disk": "{{space}} lliures al disc", "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…", "downloading_metadata": "S'estan baixant les metadades…",
"filter": "Filtra els reempaquetats", "filter": "Filtra els reempaquetats",
"requirements": "Requisits del sistema", "requirements": "Requisits del sistema",
"minimum": "Mínims", "minimum": "Mínims",
"recommended": "Recomanats", "recommended": "Recomanats",
"paused": "Paused",
"release_date": "Publicat el {{date}}", "release_date": "Publicat el {{date}}",
"publisher": "Publicat per {{publisher}}", "publisher": "Publicat per {{publisher}}",
"hours": "hores", "hours": "hores",
@ -81,7 +91,29 @@
"previous_screenshot": "Captura anterior", "previous_screenshot": "Captura anterior",
"next_screenshot": "Captura següent", "next_screenshot": "Captura següent",
"screenshot": "Captura {{number}}", "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": { "activation": {
"title": "Activa l'Hydra", "title": "Activa l'Hydra",
@ -98,6 +130,7 @@
"paused": "Pausada", "paused": "Pausada",
"verifying": "S'està verificant…", "verifying": "S'està verificant…",
"completed": "Completada", "completed": "Completada",
"removed": "No descarregat",
"cancel": "Cancel·la", "cancel": "Cancel·la",
"filter": "Filtra els jocs baixats", "filter": "Filtra els jocs baixats",
"remove": "Elimina", "remove": "Elimina",
@ -106,7 +139,14 @@
"delete": "Elimina l'instal·lador", "delete": "Elimina l'instal·lador",
"delete_modal_title": "N'estàs segur?", "delete_modal_title": "N'estàs segur?",
"delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació", "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": { "settings": {
"downloads_path": "Ruta de baixades", "downloads_path": "Ruta de baixades",
@ -119,16 +159,49 @@
"launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema", "launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema",
"general": "General", "general": "General",
"behavior": "Comportament", "behavior": "Comportament",
"download_sources": "Fonts de descàrrega",
"language": "Idioma",
"real_debrid_api_token": "Testimoni API",
"enable_real_debrid": "Activa el Real Debrid", "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>.", "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": { "notifications": {
"download_complete": "La baixada ha finalitzat", "download_complete": "La baixada ha finalitzat",
"game_ready_to_install": "{{title}} ja es pot instal·lar", "game_ready_to_install": "{{title}} ja es pot instal·lar",
"repack_list_updated": "S'ha actualitzat la llista de reempaquetats", "repack_list_updated": "S'ha actualitzat la llista de reempaquetats",
"repack_count_one": "S'ha afegit {{count}} reempaquetat", "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": { "system_tray": {
"open": "Obre l'Hydra", "open": "Obre l'Hydra",
@ -144,5 +217,39 @@
}, },
"modal": { "modal": {
"close": "Botó de tancar" "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", "successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out", "sign_out": "Sign out",
"playing_for": "Playing for {{amount}}", "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", "successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión", "sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}", "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": { "home": {
"featured": "Unggulan", "featured": "Unggulan",
"trending": "Trending", "trending": "Sedang Tren",
"surprise_me": "Kejutkan Saya", "surprise_me": "Kejutkan saya",
"no_results": "Tidak ada hasil" "no_results": "Tidak ada hasil ditemukan"
}, },
"sidebar": { "sidebar": {
"catalogue": "Katalog", "catalogue": "Katalog",
"downloads": "Unduhan", "downloads": "Unduhan",
"settings": "Pengaturan", "settings": "Pengaturan",
"my_library": "Koleksi saya", "my_library": "Perpustakaan saya",
"downloading_metadata": "{{title}} (Mengunduh metadata…)", "downloading_metadata": "{{title}} (Mengunduh metadata…)",
"paused": "{{title}} (Terhenti)", "paused": "{{title}} (Dijeda)",
"downloading": "{{title}} ({{percentage}} - Mengunduh…)", "downloading": "{{title}} ({{percentage}} - Mengunduh…)",
"filter": "Filter koleksi", "filter": "Filter perpustakaan",
"home": "Beranda" "home": "Beranda",
"queued": "{{title}} (Antrian)",
"game_has_no_executable": "Game tidak punya file eksekusi yang dipilih",
"sign_in": "Masuk"
}, },
"header": { "header": {
"search": "Pencarian", "search": "Cari game",
"home": "Beranda", "home": "Beranda",
"catalogue": "Katalog", "catalogue": "Katalog",
"downloads": "Unduhan", "downloads": "Unduhan",
"search_results": "Hasil pencarian", "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": { "bottom_panel": {
"no_downloads_in_progress": "Tidak ada unduhan berjalan", "no_downloads_in_progress": "Tidak ada unduhan yang sedang berjalan",
"downloading_metadata": "Mengunduh metadata {{title}}...", "downloading_metadata": "Mengunduh metadata {{title}}…",
"downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Perkiraan {{eta}} - {{speed}}" "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": { "catalogue": {
"next_page": "Halaman berikutnya", "next_page": "Halaman Berikutnya",
"previous_page": "Halaman sebelumnya" "previous_page": "Halaman Sebelumnya"
}, },
"game_details": { "game_details": {
"open_download_options": "Buka opsi unduhan", "open_download_options": "Buka opsi unduhan",
"download_options_zero": "Tidak ada opsi unduhan", "download_options_zero": "Tidak ada opsi unduhan",
"download_options_one": "{{count}} opsi unduhan", "download_options_one": "{{count}} opsi unduhan",
"download_options_other": "{{count}} opsi unduhan", "download_options_other": "{{count}} opsi unduhan",
"updated_at": "Diperbarui {{updated_at}}", "updated_at": "Diperbarui pada {{updated_at}}",
"install": "Install", "install": "Instal",
"resume": "Lanjutkan", "resume": "Lanjutkan",
"pause": "Hentikan sementara", "pause": "Jeda",
"cancel": "Batalkan", "cancel": "Batal",
"remove": "Hapus", "remove": "Hapus",
"space_left_on_disk": "{{space}} tersisa pada disk", "space_left_on_disk": "{{space}} tersisa di disk",
"eta": "Perkiraan {{eta}}", "eta": "Estimasi {{eta}}",
"calculating_eta": "Menghitung waktu yang tersisa…",
"downloading_metadata": "Mengunduh metadata…", "downloading_metadata": "Mengunduh metadata…",
"filter": "Saring repacks", "filter": "Filter repack",
"requirements": "Keperluan sistem", "requirements": "Persyaratan sistem",
"minimum": "Minimum", "minimum": "Minimum",
"recommended": "Rekomendasi", "recommended": "Dianjurkan",
"paused": "Dijeda",
"release_date": "Dirilis pada {{date}}", "release_date": "Dirilis pada {{date}}",
"publisher": "Dipublikasikan oleh {{publisher}}", "publisher": "Diterbitkan oleh {{publisher}}",
"hours": "jam", "hours": "jam",
"minutes": "menit", "minutes": "menit",
"amount_hours": "{{amount}} jam", "amount_hours": "{{amount}} jam",
"amount_minutes": "{{amount}} menit", "amount_minutes": "{{amount}} menit",
"accuracy": "{{accuracy}}% akurasi", "accuracy": "{{accuracy}}% akurasi",
"add_to_library": "Tambahkan ke koleksi", "add_to_library": "Tambah ke perpustakaan",
"remove_from_library": "Hapus dari koleksi", "remove_from_library": "Hapus dari perpustakaan",
"no_downloads": "Tidak ada unduhan tersedia", "no_downloads": "Tidak ada yang bisa diunduh",
"play_time": "Dimainkan selama {{amount}}", "play_time": "Dimainkan selama {{amount}}",
"last_time_played": "Terakhir dimainkan {{period}}", "last_time_played": "Terakhir dimainkan {{period}}",
"not_played_yet": "Kamu belum memainkan {{title}}", "not_played_yet": "Kamu belum memainkan {{title}}",
"next_suggestion": "Rekomendasi berikutnya", "next_suggestion": "Saran berikutnya",
"play": "Mainkan", "play": "Main",
"deleting": "Menghapus installer…", "deleting": "Menghapus installer…",
"close": "Tutup", "close": "Tutup",
"playing_now": "Memainkan sekarang", "playing_now": "Sedang dimainkan",
"change": "Ubah", "change": "Ubah",
"repacks_modal_description": "Pilih repack yang kamu ingin unduh", "repacks_modal_description": "Pilih repack yang ingin kamu unduh",
"select_folder_hint": "Untuk merubah folder bawaan, akses melalui", "select_folder_hint": "Untuk ganti folder default, buka <0>Pengaturan</0>",
"download_now": "Unduh sekarang" "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": { "activation": {
"title": "Aktivasi Hydra", "title": "Aktifkan Hydra",
"installation_id": "ID instalasi:", "installation_id": "ID Instalasi:",
"enter_activation_code": "Masukkan kode aktivasi", "enter_activation_code": "Masukkan kode aktivasi kamu",
"message": "Jika kamu tidak tau dimana bertanya untuk ini, maka kamu tidak seharusnya memiliki ini.", "message": "Kalau tidak tahu harus tanya ke siapa, berarti kamu tidak perlu ini.",
"activate": "Aktifkan", "activate": "Aktifkan",
"loading": "Memuat…" "loading": "Memuat…"
}, },
"downloads": { "downloads": {
"resume": "Lanjutkan", "resume": "Lanjutkan",
"pause": "Hentikan sementara", "pause": "Jeda",
"eta": "Perkiraan {{eta}}", "eta": "Estimasi {{eta}}",
"paused": "Terhenti sementara", "paused": "Dijeda",
"verifying": "Memeriksa…", "verifying": "Verifikasi…",
"completed": "Selesai", "completed": "Selesai",
"cancel": "Batalkan", "removed": "Tidak diunduh",
"filter": "Saring game yang diunduh", "cancel": "Batal",
"filter": "Filter game yang diunduh",
"remove": "Hapus", "remove": "Hapus",
"downloading_metadata": "Mengunduh metadata…", "downloading_metadata": "Mengunduh metadata…",
"deleting": "Menghapus file instalasi…", "deleting": "Menghapus installer…",
"delete": "Hapus file instalasi", "delete": "Hapus installer",
"delete_modal_title": "Kamu yakin?", "delete_modal_title": "Apa kamu yakin?",
"delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu", "delete_modal_description": "Ini akan menghapus semua file instalasi dari komputer kamu",
"install": "Install" "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": { "settings": {
"downloads_path": "Lokasi unduhan", "downloads_path": "Path unduhan",
"change": "Perbarui", "change": "Ganti",
"notifications": "Pengingat", "notifications": "Notifikasi",
"enable_download_notifications": "Saat unduhan selesai", "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", "behavior": "Perilaku",
"quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi", "download_sources": "Sumber unduhan",
"launch_with_system": "Jalankan saat memulai sistem" "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": { "notifications": {
"download_complete": "Unduhan selesai", "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_list_updated": "Daftar repack diperbarui",
"repack_count_one": "{{count}} repack ditambahkan", "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": { "system_tray": {
"open": "Buka Hydra", "open": "Buka Hydra",
"quit": "Tutup" "quit": "Keluar"
}, },
"game_card": { "game_card": {
"no_downloads": "Tidak ada unduhan tersedia" "no_downloads": "Tidak ada unduhan yang tersedia"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Program tidak terinstal", "title": "Program tidak terpasang",
"description": "Wine atau Lutris exe tidak ditemukan pada sistem kamu", "description": "Executable Wine atau Lutris tidak ditemukan di sistem kamu",
"instructions": "Periksa cara instalasi yang benar pada Linux distro-mu agar game dapat dimainkan dengan benar" "instructions": "Cek cara instalasi yang benar di distro Linux kamu agar game bisa jalan normal"
}, },
"modal": { "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", "catalogue": "Catálogo",
"downloads": "Downloads", "downloads": "Downloads",
"settings": "Ajustes", "settings": "Ajustes",
"my_library": "Minha biblioteca", "my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)", "downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)", "paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)", "downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca", "filter": "Buscar",
"home": "Início", "home": "Início",
"queued": "{{title}} (Na fila)", "queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado", "game_has_no_executable": "Jogo não possui executável selecionado",
@ -54,7 +54,7 @@
"calculating_eta": "Calculando tempo restante…", "calculating_eta": "Calculando tempo restante…",
"downloading_metadata": "Baixando metadados…", "downloading_metadata": "Baixando metadados…",
"filter": "Filtrar repacks", "filter": "Filtrar repacks",
"requirements": "Requisitos do sistema", "requirements": "Requisitos de sistema",
"minimum": "Mínimos", "minimum": "Mínimos",
"recommended": "Recomendados", "recommended": "Recomendados",
"paused": "Pausado", "paused": "Pausado",
@ -68,16 +68,16 @@
"add_to_library": "Adicionar à biblioteca", "add_to_library": "Adicionar à biblioteca",
"remove_from_library": "Remover da biblioteca", "remove_from_library": "Remover da biblioteca",
"no_downloads": "Nenhum download disponível", "no_downloads": "Nenhum download disponível",
"play_time": "Jogado por {{amount}}", "play_time": "Jogou por {{amount}}",
"next_suggestion": "Próxima sugestão", "next_suggestion": "Próxima sugestão",
"install": "Instalar", "install": "Instalar",
"last_time_played": "Jogou por último {{period}}", "last_time_played": "Última sessão {{period}}",
"play": "Jogar", "play": "Jogar",
"not_played_yet": "Você ainda não jogou {{title}}", "not_played_yet": "Você ainda não jogou {{title}}",
"close": "Fechar", "close": "Fechar",
"deleting": "Excluindo instalador…", "deleting": "Excluindo instalador…",
"playing_now": "Jogando agora", "playing_now": "Jogando agora",
"change": "Mudar", "change": "Explorar",
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "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>", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download", "download_now": "Iniciar download",
@ -90,13 +90,13 @@
"open_screenshot": "Ver captura de tela {{number}}", "open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download", "download_settings": "Ajustes do download",
"downloader": "Downloader", "downloader": "Downloader",
"select_executable": "Selecionar", "select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado", "no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta", "open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados", "open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho", "create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos", "remove_files": "Remover arquivos",
"options": "Opções", "options": "Gerenciar",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca", "remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?", "remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável", "executable_section_title": "Executável",
@ -120,7 +120,7 @@
"loading": "Carregando…" "loading": "Carregando…"
}, },
"downloads": { "downloads": {
"resume": "Resumir", "resume": "Retomar",
"pause": "Pausar", "pause": "Pausar",
"eta": "Conclusão {{eta}}", "eta": "Conclusão {{eta}}",
"paused": "Pausado", "paused": "Pausado",
@ -146,12 +146,12 @@
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",
"change": "Mudar", "change": "Explorar...",
"notifications": "Notificações", "notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído", "enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid", "real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra 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", "launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",
@ -208,7 +208,7 @@
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Programas não instalados", "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" "instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
}, },
"catalogue": { "catalogue": {
@ -224,8 +224,8 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"last_time_played": "Jogou {{period}}", "last_time_played": "Última sessão {{period}}",
"activity": "Atividade recente", "activity": "Atividades recentes",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}", "total_play_time": "Tempo total de jogo: {{amount}}",
"no_recent_activity_title": "Hmmm… nada por aqui", "no_recent_activity_title": "Hmmm… nada por aqui",
@ -233,7 +233,7 @@
"display_name": "Nome de exibição", "display_name": "Nome de exibição",
"saving": "Salvando…", "saving": "Salvando…",
"save": "Salvar", "save": "Salvar",
"edit_profile": "Editar Perfil", "edit_profile": "Editar perfil",
"saved_successfully": "Salvo com sucesso", "saved_successfully": "Salvo com sucesso",
"try_again": "Por favor, tente novamente", "try_again": "Por favor, tente novamente",
"cancel": "Cancelar", "cancel": "Cancelar",
@ -241,6 +241,26 @@
"sign_out": "Sair da conta", "sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?", "sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}", "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/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; 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/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); 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 _event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`) return HydraApi.get(`/profile/me`)
.then((response) => { .then((me) => {
const me = response.data;
userAuthRepository.upsert( userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -26,12 +24,18 @@ const getMe = async (
return me; return me;
}) })
.catch((err) => { .catch(async (err) => {
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
return null; 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, _event: Electron.IpcMainInvokeEvent,
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
) => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!newProfileImagePath) {
return patchUserProfile(displayName).then( return patchUserProfile(displayName);
(response) => response.data as UserProfile
);
} }
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
@ -42,7 +40,7 @@ const updateProfile = async (
imageLength: fileSizeInBytes, imageLength: fileSizeInBytes,
}) })
.then(async (preSignedResponse) => { .then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data; const { presignedUrl, profileImageUrl } = preSignedResponse;
const mimeType = await fileTypeFromFile(newProfileImagePath); const mimeType = await fileTypeFromFile(newProfileImagePath);
@ -51,13 +49,11 @@ const updateProfile = async (
"Content-Type": mimeType?.mime, "Content-Type": mimeType?.mime,
}, },
}); });
return profileImageUrl; return profileImageUrl as string;
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl).then( return patchUserProfile(displayName, profileImageUrl);
(response) => response.data as UserProfile
);
}; };
registerEvent("updateProfile", updateProfile); 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 { UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
const getUser = async ( const getUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const response = await HydraApi.get(`/user/${userId}`); const [profile, friends] = await Promise.all([
const profile = response.data; HydraApi.get(`/user/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { 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) { } catch (err) {
return null; 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; autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
@ -121,6 +123,7 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.kill(); PythonInstance.kill();
logger.log("Quit Hydra");
}); });
app.on("activate", () => { app.on("activate", () => {

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -134,11 +135,22 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */ /* Profile */
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (displayName: string, newProfileImagePath: string | null) => updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), 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 */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), 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 */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" 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> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -25,6 +25,7 @@ import {
setGameRunning, setGameRunning,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -38,7 +39,15 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const {
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
fetchFriendRequests,
hideFriendsModal,
} = useUserDetails();
const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -94,7 +103,10 @@ export function App() {
} }
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) updateUserDetails(response); if (response) {
updateUserDetails(response);
fetchFriendRequests();
}
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
@ -102,6 +114,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
fetchFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@ -206,6 +219,15 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main> <main>
<Sidebar /> <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"; 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({ export const profileButton = style({
display: "flex", display: "flex",
cursor: "pointer", cursor: "pointer",
@ -10,9 +21,8 @@ export const profileButton = style({
color: vars.color.muted, color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`, borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)", boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": { width: "100%",
backgroundColor: "rgba(255, 255, 255, 0.15)", zIndex: "10",
},
}); });
export const profileButtonContent = style({ export const profileButtonContent = style({
@ -64,3 +74,25 @@ export const profileButtonTitle = style({
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", 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 { 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 * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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() { export function SidebarProfile() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("sidebar"); 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); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -30,46 +42,66 @@ export function SidebarProfile() {
}, [profileBackground]); }, [profileBackground]);
return ( return (
<button <div
type="button" className={styles.profileContainer}
className={styles.profileButton} style={assignInlineVars({
style={{ background: profileButtonBackground }} [profileContainerBackground]: profileButtonBackground,
onClick={handleButtonClick} })}
> >
<div className={styles.profileButtonContent}> <button
<div className={styles.profileAvatar}> type="button"
{userDetails?.profileImageUrl ? ( className={styles.profileButton}
<img onClick={handleButtonClick}
className={styles.profileAvatar} >
src={userDetails.profileImageUrl} <div className={styles.profileButtonContent}>
alt={userDetails.displayName} <div className={styles.profileAvatar}>
/> {userDetails?.profileImageUrl ? (
) : ( <img
<PersonIcon /> className={styles.profileAvatar}
)} src={userDetails.profileImageUrl}
</div> alt={userDetails.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div className={styles.profileButtonInformation}> <div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}> <p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")} {userDetails ? userDetails.displayName : t("sign_in")}
</p> </p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && ( {userDetails && gameRunning && (
<div> <img
<small>{gameRunning.title}</small> alt={gameRunning.title}
</div> width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)} )}
</div> </div>
</button>
{userDetails && gameRunning && ( {userDetails && receivedRequests.length > 0 && !gameRunning && (
<img <div className={styles.friendRequestContainer}>
alt={gameRunning.title} <button
width={24} type="button"
style={{ borderRadius: 4 }} className={styles.friendRequestButton}
src={gameRunning.iconUrl} onClick={() =>
/> showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
)} }
</div> >
</button> <PersonAddIcon size={24} />
{receivedRequests.length}
</button>
</div>
)}
</div>
); );
} }

View File

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

View File

@ -1,14 +1,23 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; 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 { export interface UserDetailsState {
userDetails: UserDetails | null; userDetails: UserDetails | null;
profileBackground: null | string; profileBackground: null | string;
friendRequests: FriendRequest[];
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
userDetails: null, userDetails: null,
profileBackground: null, profileBackground: null,
friendRequests: [],
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@ -21,8 +30,28 @@ export const userDetailsSlice = createSlice({
setProfileBackground: (state, action: PayloadAction<string | null>) => { setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload; 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 } = export const {
userDetailsSlice.actions; setUserDetails,
setProfileBackground,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@ -1,6 +1,7 @@
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = { export const steamUrlBuilder = {
library: (objectID: string) => library: (objectID: string) =>
@ -45,3 +46,14 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) => export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString(); 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 { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features"; import {
import { darkenColor } from "@renderer/helpers"; setProfileBackground,
import { UserDetails } from "@types"; 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() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector( const {
(state) => state.userDetails userDetails,
); profileBackground,
friendRequests,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@ -31,12 +41,9 @@ export function useUserDetails() {
dispatch(setUserDetails(userDetails)); dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) { if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, { const profileBackground = await profileBackgroundFromProfileImage(
amount: 1, userDetails.profileImageUrl
format: "hex", );
});
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem( window.localStorage.setItem(
@ -78,13 +85,76 @@ export function useUserDetails() {
[updateUserDetails] [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 { return {
userDetails, userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,
updateUserDetails, updateUserDetails,
patchUser, 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 cn from "classnames";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.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 { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { import {
@ -13,11 +12,23 @@ import {
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; import {
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; buildGameDetailsPath,
profileBackgroundFromProfileImage,
steamUrlBuilder,
} from "@renderer/helpers";
import {
CheckCircleIcon,
PersonIcon,
PlusIcon,
TelescopeIcon,
XCircleIcon,
} from "@primer/octicons-react";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; 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; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -26,17 +37,32 @@ export interface ProfileContentProps {
updateUserProfile: () => Promise<void>; updateUserProfile: () => Promise<void>;
} }
type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND");
export function UserContent({ export function UserContent({
userProfile, userProfile,
updateUserProfile, updateUserProfile,
}: ProfileContentProps) { }: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile"); const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails(); const {
const { showSuccessToast } = useToast(); 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 [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -72,6 +98,10 @@ export function UserContent({
setShowEditProfileModal(true); setShowEditProfileModal(true);
}; };
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => { const handleConfirmSignout = async () => {
await signOut(); await signOut();
@ -82,11 +112,142 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
const profileContentBoxBackground = useMemo(() => { useEffect(() => {
if (profileBackground) return profileBackground; if (isMe) fetchFriendRequests();
/* TODO: Render background colors for other users */ }, [isMe]);
return undefined;
}, [profileBackground]); 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 ( return (
<> <>
@ -103,6 +264,13 @@ export function UserContent({
onConfirm={handleConfirmSignout} onConfirm={handleConfirmSignout}
/> />
<UserBlockModal
visible={showUserBlockModal}
onClose={() => setShowUserBlockModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")}
displayName={userProfile.displayName}
/>
<section <section
className={styles.profileContentBox} className={styles.profileContentBox}
style={{ style={{
@ -173,37 +341,24 @@ export function UserContent({
)} )}
</div> </div>
{isMe && ( <div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div <div
style={{ style={{
flex: 1,
display: "flex", display: "flex",
justifyContent: "end", flexDirection: "column",
zIndex: 1, gap: `${SPACING_UNIT}px`,
}} }}
> >
<div {getProfileActions()}
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
</div>
</div> </div>
)} </div>
</section> </section>
<div className={styles.profileContent}> <div className={styles.profileContent}>
@ -216,9 +371,11 @@ export function UserContent({
<TelescopeIcon size={24} /> <TelescopeIcon size={24} />
</div> </div>
<h2>{t("no_recent_activity_title")}</h2> <h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}> {isMe && (
{t("no_recent_activity_description")} <p style={{ fontFamily: "Fira Sans" }}>
</p> {t("no_recent_activity_description")}
</p>
)}
</div> </div>
) : ( ) : (
<div <div
@ -259,55 +416,135 @@ export function UserContent({
)} )}
</div> </div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}> <div className={styles.contentSidebar}>
<div <div className={styles.profileGameSection}>
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div <div
style={{ style={{
flex: 1, display: "flex",
backgroundColor: vars.color.border, alignItems: "center",
height: "1px", justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}} }}
/> >
<h3 style={{ fontWeight: "400" }}> <h2>{t("library")}</h2>
{userProfile.libraryGames.length}
</h3> <div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div> </div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div {showFriends && (
style={{ <div className={styles.friendsSection}>
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button <button
key={game.objectID} className={styles.friendsSectionHeader}
className={cn(styles.gameListItem, styles.profileContentBox)} onClick={() =>
onClick={() => handleGameClick(game)} showFriendsModal(
title={game.title} UserFriendModalTab.FriendsList,
userProfile.id
)
}
> >
{game.iconUrl ? ( <h2>{t("friends")}</h2>
<img
className={styles.libraryGameIcon} <div
src={game.iconUrl} style={{
alt={game.title} flex: 1,
/> backgroundColor: vars.color.border,
) : ( height: "1px",
<SteamLogo className={styles.libraryGameIcon} /> }}
)} />
<h3 style={{ fontWeight: "400" }}>
{userProfile.totalFriends}
</h3>
</button> </button>
))}
</div> <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>
</div> </div>
</> </>

View File

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

View File

@ -11,6 +11,7 @@ export const wrapper = style({
export const profileContentBox = style({ export const profileContentBox = style({
display: "flex", display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`, gap: `${SPACING_UNIT * 3}px`,
alignItems: "center", alignItems: "center",
borderRadius: "4px", borderRadius: "4px",
@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
zIndex: 1, 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({ export const profileAvatarEditContainer = style({
width: "128px", width: "128px",
height: "128px", height: "128px",
@ -53,9 +77,9 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({ export const profileAvatar = style({
height: "100%", height: "100%",
width: "100%", width: "100%",
objectFit: "cover",
borderRadius: "50%", borderRadius: "50%",
overflow: "hidden", overflow: "hidden",
objectFit: "cover",
}); });
export const profileAvatarEditOverlay = style({ export const profileAvatarEditOverlay = style({
@ -86,14 +110,36 @@ export const profileContent = style({
export const profileGameSection = style({ export const profileGameSection = style({
width: "100%", width: "100%",
height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`, 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({ export const contentSidebar = style({
width: "100%", width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": { "@media": {
"(min-width: 768px)": { "(min-width: 768px)": {
width: "100%", width: "100%",
@ -116,12 +162,17 @@ export const libraryGameIcon = style({
borderRadius: "4px", borderRadius: "4px",
}); });
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({ export const feedItem = style({
color: vars.color.body, color: vars.color.body,
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
width: "100%", width: "100%",
overflow: "hidden",
height: "72px", height: "72px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
cursor: "pointer", 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({ export const gameInformation = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -215,3 +279,16 @@ export const profileBackground = style({
top: "0", top: "0",
borderRadius: "4px", 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 { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch, useToast } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton"; import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content"; import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton"; import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export const User = () => { export const User = () => {
const { userId } = useParams(); const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>(); const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => { const getUserProfile = useCallback(() => {
@ -22,10 +27,11 @@ export const User = () => {
dispatch(setHeaderTitle(userProfile.displayName)); dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile); setUserProfile(userProfile);
} else { } else {
showErrorToast(t("user_not_found"));
navigate(-1); navigate(-1);
} }
}); });
}, [dispatch, userId]); }, [dispatch, userId, t]);
useEffect(() => { useEffect(() => {
getUserProfile(); getUserProfile();

View File

@ -10,6 +10,8 @@ export type GameStatus =
export type GameShop = "steam" | "epic"; export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre { export interface SteamGenre {
id: string; id: string;
name: string; name: string;
@ -269,14 +271,43 @@ export interface UserDetails {
profileImageUrl: string | null; 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 { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
username: string;
profileImageUrl: string | null; profileImageUrl: string | null;
profileVisibility: "PUBLIC" | "PRIVATE" | "FRIENDS";
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
} }
export interface DownloadSource { export interface DownloadSource {

View File

@ -2433,6 +2433,13 @@
modern-ahocorasick "^1.0.0" modern-ahocorasick "^1.0.0"
picocolors "^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": "@vanilla-extract/integration@^7.1.3":
version "7.1.4" version "7.1.4"
resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz" 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" resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz"
integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg== 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": "@vanilla-extract/recipes@^0.5.2":
version "0.5.2" version "0.5.2"
resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz" resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"