diff --git a/README.md b/README.md index 5d30fae2..6dc97aea 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Hydra Launcher

- 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.

[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) @@ -50,17 +50,15 @@ ## About -**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client** and a **self-managed repack scraper**. +**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client**.
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent. ## Features -- Self-Managed repack scraper among all the most reliable websites on the [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/") - Own embedded bittorrent client - How Long To Beat (HLTB) integration on game page - Downloads path customization -- Repack list update notifications - Windows and Linux support - Constantly updated - And more ... @@ -134,9 +132,8 @@ pip install -r requirements.txt ## Environment variables You'll need an SteamGridDB API Key in order to fetch the game icons on installation. -If you want to have onlinefix as a repacker you'll need to add your credentials to the .env -Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`. +Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`. ## Running diff --git a/package.json b/package.json index c99a9bac..1b99734d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@reduxjs/toolkit": "^2.2.3", "@sentry/electron": "^5.1.0", "@vanilla-extract/css": "^1.14.2", + "@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/recipes": "^0.5.2", "aria2": "^4.1.2", "auto-launch": "^5.0.6", diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index c756745a..9124af79 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -1,4 +1,7 @@ { + "app": { + "successfully_signed_in": "Has entrat correctament" + }, "home": { "featured": "Destacats", "trending": "Populars", @@ -14,7 +17,10 @@ "paused": "{{title}} (Pausat)", "downloading": "{{title}} ({{percentage}} - S'està baixant…)", "filter": "Filtra la biblioteca", - "home": "Inici" + "home": "Inici", + "queued": "{{title}} (En espera)", + "game_has_no_executable": "El joc encara no té un executable seleccionat", + "sign_in": "Entra" }, "header": { "search": "Cerca jocs", @@ -29,7 +35,9 @@ "bottom_panel": { "no_downloads_in_progress": "Cap baixada en curs", "downloading_metadata": "S'estan baixant les metadades de: {{title}}…", - "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}" + "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}", + "calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…", + "checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)" }, "catalogue": { "next_page": "Pàgina següent", @@ -47,12 +55,14 @@ "cancel": "Cancel·la", "remove": "Elimina", "space_left_on_disk": "{{space}} lliures al disc", - "eta": "Finalització: {{eta}}", + "eta": "Finalitza en: {{eta}}", + "calculating_eta": "Calculant temps estimat…", "downloading_metadata": "S'estan baixant les metadades…", "filter": "Filtra els reempaquetats", "requirements": "Requisits del sistema", "minimum": "Mínims", "recommended": "Recomanats", + "paused": "Paused", "release_date": "Publicat el {{date}}", "publisher": "Publicat per {{publisher}}", "hours": "hores", @@ -81,7 +91,29 @@ "previous_screenshot": "Captura anterior", "next_screenshot": "Captura següent", "screenshot": "Captura {{number}}", - "open_screenshot": "Obre la captura {{number}}" + "open_screenshot": "Obre la captura {{number}}", + "download_settings": "Configuració de descàrrega", + "downloader": "Descarregador", + "select_executable": "Selecciona", + "no_executable_selected": "No hi ha executable selccionat", + "open_folder": "Obre carpeta", + "open_download_location": "Visualitzar fitxers descarregats", + "create_shortcut": "Crear accés directe a l'escriptori", + "remove_files": "Elimina fitxers", + "remove_from_library_title": "Segur?", + "remove_from_library_description": "Això eliminarà el videojoc {{game}} del teu catàleg", + "options": "Opcions", + "executable_section_title": "Executable", + "executable_section_description": "Directori del fitxer des d'on s'executarà quan es cliqui a \"Executar\"", + "downloads_secion_title": "Descàrregues", + "downloads_section_description": "Comprova actualitzacions o altres versions del videojoc", + "danger_zone_section_title": "Zona de perill", + "danger_zone_section_description": "Elimina aquest videojoc del teu catàleg o els fitxers descarregats per Hydra", + "download_in_progress": "Descàrrega en progrés", + "download_paused": "Descàrrega en pausa", + "last_downloaded_option": "Opció de l'última descàrrega", + "create_shortcut_success": "Accés directe creat satisfactòriament", + "create_shortcut_error": "Error al crear l'accés directe" }, "activation": { "title": "Activa l'Hydra", @@ -98,6 +130,7 @@ "paused": "Pausada", "verifying": "S'està verificant…", "completed": "Completada", + "removed": "No descarregat", "cancel": "Cancel·la", "filter": "Filtra els jocs baixats", "remove": "Elimina", @@ -106,7 +139,14 @@ "delete": "Elimina l'instal·lador", "delete_modal_title": "N'estàs segur?", "delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació", - "install": "Instal·la" + "install": "Instal·la", + "download_in_progress": "En progrés", + "queued_downloads": "Descàrregues en espera", + "downloads_completed": "Completat", + "queued": "En espera", + "no_downloads_title": "Buit", + "no_downloads_description": "No has descarregat res amb Hydra encara, però mai és tard per començar a fer-ho.", + "checking_files": "Comprovant fitxers…" }, "settings": { "downloads_path": "Ruta de baixades", @@ -119,16 +159,49 @@ "launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema", "general": "General", "behavior": "Comportament", + "download_sources": "Fonts de descàrrega", + "language": "Idioma", + "real_debrid_api_token": "Testimoni API", "enable_real_debrid": "Activa el Real Debrid", + "real_debrid_description": "Real-Debrid és un programa de descàrrega sense restriccions que us permet descarregar fitxers a l'instant i al màxim de la vostra velocitat d'Internet.", + "real_debrid_invalid_token": "Invalida el testimoni de l'API", "real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí.", - "save_changes": "Desa els canvis" + "real_debrid_free_account_error": "L'usuari \"{{username}}\" és un compte gratuït. Si us plau subscriu-te a Real-Debrid", + "real_debrid_linked_message": "Compte \"{{username}}\" vinculat", + "save_changes": "Desa els canvis", + "changes_saved": "Els canvis s'han desat correctament", + "download_sources_description": "Hydra buscarà els enllaços de descàrrega d'aquestes fonts. L'URL d'origen ha de ser un enllaç directe a un fitxer .json que contingui els enllaços de descàrrega.", + "validate_download_source": "Valida", + "remove_download_source": "Elimina", + "add_download_source": "Afegeix font", + "download_count_zero": "No hi ha baixades a la llista", + "download_count_one": "{{countFormatted}} a la llista de baixades", + "download_count_other": "{{countFormatted}} baixades a la llista", + "download_options_zero": "No hi ha cap descàrrega disponible", + "download_options_one": "{{countFormatted}} descàrrega disponible", + "download_options_other": "{{countFormatted}} baixades disponibles", + "download_source_url": "Descarrega l'URL de la font", + "add_download_source_description": "Inseriu la URL que conté el fitxer .json", + "download_source_up_to_date": "Actualitzat", + "download_source_errored": "S'ha produït un error", + "sync_download_sources": "Sincronitza fonts", + "removed_download_source": "S'ha eliminat la font de descàrrega", + "added_download_source": "Added download source", + "download_sources_synced": "Totes les fonts de descàrrega estan sincronitzades", + "insert_valid_json_url": "Insereix una URL JSON vàlida", + "found_download_option_zero": "No s'ha trobat cap opció de descàrrega", + "found_download_option_one": "S'ha trobat l'opció de baixada de {{countFormatted}}", + "found_download_option_other": "S'han trobat {{countFormatted}} opcions de baixada", + "import": "Import" }, "notifications": { "download_complete": "La baixada ha finalitzat", "game_ready_to_install": "{{title}} ja es pot instal·lar", "repack_list_updated": "S'ha actualitzat la llista de reempaquetats", "repack_count_one": "S'ha afegit {{count}} reempaquetat", - "repack_count_other": "S'han afegit {{count}} reempaquetats" + "repack_count_other": "S'han afegit {{count}} reempaquetats", + "new_update_available": "Versió {{version}} disponible", + "restart_to_install_update": "Reinicieu Hydra per instal·lar l'actualització" }, "system_tray": { "open": "Obre l'Hydra", @@ -144,5 +217,39 @@ }, "modal": { "close": "Botó de tancar" + }, + "forms": { + "toggle_password_visibility": "Commuta la visibilitat de la contrasenya" + }, + "user_profile": { + "amount_hours": "{{amount}} hores", + "amount_minutes": "{{amount}} minuts", + "last_time_played": "Última partida {{period}}", + "activity": "Activitat recent", + "library": "Biblioteca", + "total_play_time": "Temps total de joc:{{amount}}", + "no_recent_activity_title": "Hmmm… encara no res", + "no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!", + "display_name": "Nom de visualització", + "saving": "Desant", + "save": "Desa", + "edit_profile": "Edita el Perfil", + "saved_successfully": "S'ha desat correctament", + "try_again": "Siusplau torna-ho a provar", + "sign_out_modal_title": "Segur?", + "cancel": "Cancel·la", + "successfully_signed_out": "S'ha tancat la sessió correctament", + "sign_out": "Tanca sessió", + "playing_for": "Jugant per {{amount}}", + "sign_out_modal_text": "La vostra biblioteca està enllaçada amb el vostre compte actual. Quan tanqueu la sessió, la vostra biblioteca ja no serà visible i cap progrés no es desarà. Voleu continuar amb tancar la sessió?", + "add_friends": "Afegeix amics", + "add": "Afegeix", + "friend_code": "Codi de l'amic", + "see_profile": "Veure Perfil", + "sending": "Enviant", + "friend_request_sent": "Sol·licitud d'amistat enviada", + "friends": "Amistats", + "friends_list": "Llista d'amistats", + "user_not_found": "Usuari no trobat" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e44dc7ea..b24509d3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -241,6 +241,26 @@ "successfully_signed_out": "Successfully signed out", "sign_out": "Sign out", "playing_for": "Playing for {{amount}}", - "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?" + "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?", + "add_friends": "Add Friends", + "add": "Add", + "friend_code": "Friend code", + "see_profile": "See profile", + "sending": "Sending", + "friend_request_sent": "Friend request sent", + "friends": "Friends", + "friends_list": "Friends list", + "user_not_found": "User not found", + "block_user": "Block user", + "add_friend": "Add friend", + "request_sent": "Request sent", + "request_received": "Request received", + "accept_request": "Accept request", + "ignore_request": "Ignore request", + "cancel_request": "Cancel request", + "undo_friendship": "Undo friendship", + "request_accepted": "Request accepted", + "user_blocked_successfully": "User blocked successfully", + "user_block_modal_text": "This will block {{displayName}}" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5e016d34..fcb2b099 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -241,6 +241,15 @@ "successfully_signed_out": "Sesión cerrada exitosamente", "sign_out": "Cerrar sesión", "playing_for": "Jugando por {{amount}}", - "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?" + "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?", + "add_friends": "Añadir amigos", + "add": "Añadir", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "sending": "Enviando", + "friend_request_sent": "Solicitud de amistad enviada", + "friends": "Amigos", + "friends_list": "Lista de amigos", + "user_not_found": "Usuario no encontrado" } } diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index bb7f452b..198aa568 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -1,134 +1,255 @@ { + "app": { + "successfully_signed_in": "Berhasil masuk" + }, "home": { "featured": "Unggulan", - "trending": "Trending", - "surprise_me": "Kejutkan Saya", - "no_results": "Tidak ada hasil" + "trending": "Sedang Tren", + "surprise_me": "Kejutkan saya", + "no_results": "Tidak ada hasil ditemukan" }, "sidebar": { "catalogue": "Katalog", "downloads": "Unduhan", "settings": "Pengaturan", - "my_library": "Koleksi saya", + "my_library": "Perpustakaan saya", "downloading_metadata": "{{title}} (Mengunduh metadata…)", - "paused": "{{title}} (Terhenti)", + "paused": "{{title}} (Dijeda)", "downloading": "{{title}} ({{percentage}} - Mengunduh…)", - "filter": "Filter koleksi", - "home": "Beranda" + "filter": "Filter perpustakaan", + "home": "Beranda", + "queued": "{{title}} (Antrian)", + "game_has_no_executable": "Game tidak punya file eksekusi yang dipilih", + "sign_in": "Masuk" }, "header": { - "search": "Pencarian", + "search": "Cari game", "home": "Beranda", "catalogue": "Katalog", "downloads": "Unduhan", "search_results": "Hasil pencarian", - "settings": "Pengaturan" + "settings": "Pengaturan", + "version_available_install": "Versi {{version}} tersedia. Klik di sini untuk restart dan instal.", + "version_available_download": "Versi {{version}} tersedia. Klik di sini untuk unduh." }, "bottom_panel": { - "no_downloads_in_progress": "Tidak ada unduhan berjalan", - "downloading_metadata": "Mengunduh metadata {{title}}...", - "downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Perkiraan {{eta}} - {{speed}}" + "no_downloads_in_progress": "Tidak ada unduhan yang sedang berjalan", + "downloading_metadata": "Mengunduh metadata {{title}}…", + "downloading": "Mengunduh {{title}}… ({{percentage}} selesai) - Estimasi selesai {{eta}} - {{speed}}", + "calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…", + "checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)" }, "catalogue": { - "next_page": "Halaman berikutnya", - "previous_page": "Halaman sebelumnya" + "next_page": "Halaman Berikutnya", + "previous_page": "Halaman Sebelumnya" }, "game_details": { "open_download_options": "Buka opsi unduhan", "download_options_zero": "Tidak ada opsi unduhan", "download_options_one": "{{count}} opsi unduhan", "download_options_other": "{{count}} opsi unduhan", - "updated_at": "Diperbarui {{updated_at}}", - "install": "Install", + "updated_at": "Diperbarui pada {{updated_at}}", + "install": "Instal", "resume": "Lanjutkan", - "pause": "Hentikan sementara", - "cancel": "Batalkan", + "pause": "Jeda", + "cancel": "Batal", "remove": "Hapus", - "space_left_on_disk": "{{space}} tersisa pada disk", - "eta": "Perkiraan {{eta}}", + "space_left_on_disk": "{{space}} tersisa di disk", + "eta": "Estimasi {{eta}}", + "calculating_eta": "Menghitung waktu yang tersisa…", "downloading_metadata": "Mengunduh metadata…", - "filter": "Saring repacks", - "requirements": "Keperluan sistem", + "filter": "Filter repack", + "requirements": "Persyaratan sistem", "minimum": "Minimum", - "recommended": "Rekomendasi", + "recommended": "Dianjurkan", + "paused": "Dijeda", "release_date": "Dirilis pada {{date}}", - "publisher": "Dipublikasikan oleh {{publisher}}", + "publisher": "Diterbitkan oleh {{publisher}}", "hours": "jam", "minutes": "menit", "amount_hours": "{{amount}} jam", "amount_minutes": "{{amount}} menit", "accuracy": "{{accuracy}}% akurasi", - "add_to_library": "Tambahkan ke koleksi", - "remove_from_library": "Hapus dari koleksi", - "no_downloads": "Tidak ada unduhan tersedia", + "add_to_library": "Tambah ke perpustakaan", + "remove_from_library": "Hapus dari perpustakaan", + "no_downloads": "Tidak ada yang bisa diunduh", "play_time": "Dimainkan selama {{amount}}", "last_time_played": "Terakhir dimainkan {{period}}", "not_played_yet": "Kamu belum memainkan {{title}}", - "next_suggestion": "Rekomendasi berikutnya", - "play": "Mainkan", + "next_suggestion": "Saran berikutnya", + "play": "Main", "deleting": "Menghapus installer…", "close": "Tutup", - "playing_now": "Memainkan sekarang", + "playing_now": "Sedang dimainkan", "change": "Ubah", - "repacks_modal_description": "Pilih repack yang kamu ingin unduh", - "select_folder_hint": "Untuk merubah folder bawaan, akses melalui", - "download_now": "Unduh sekarang" + "repacks_modal_description": "Pilih repack yang ingin kamu unduh", + "select_folder_hint": "Untuk ganti folder default, buka <0>Pengaturan", + "download_now": "Unduh sekarang", + "no_shop_details": "Gagal mendapatkan detail toko.", + "download_options": "Opsi unduhan", + "download_path": "Path unduhan", + "previous_screenshot": "Screenshot sebelumnya", + "next_screenshot": "Screenshot berikutnya", + "screenshot": "Screenshot {{number}}", + "open_screenshot": "Buka screenshot {{number}}", + "download_settings": "Pengaturan unduhan", + "downloader": "Pengunduh", + "select_executable": "Pilih", + "no_executable_selected": "Tidak ada file eksekusi yang dipilih", + "open_folder": "Buka folder", + "open_download_location": "Lihat file yang diunduh", + "create_shortcut": "Buat pintasan desktop", + "remove_files": "Hapus file", + "remove_from_library_title": "Apa kamu yakin?", + "remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu", + "options": "Opsi", + "executable_section_title": "Eksekusi", + "executable_section_description": "Path file eksekusi saat \"Main\" diklik", + "downloads_secion_title": "Unduhan", + "downloads_section_description": "Cek update atau versi lain dari game ini", + "danger_zone_section_title": "Zona Berbahaya", + "danger_zone_section_description": "Hapus game ini dari perpustakaan kamu atau file yang diunduh oleh Hydra", + "download_in_progress": "Sedang mengunduh", + "download_paused": "Unduhan dijeda", + "last_downloaded_option": "Opsi terakhir diunduh", + "create_shortcut_success": "Pintasan berhasil dibuat", + "create_shortcut_error": "Gagal membuat pintasan" }, "activation": { - "title": "Aktivasi Hydra", - "installation_id": "ID instalasi:", - "enter_activation_code": "Masukkan kode aktivasi", - "message": "Jika kamu tidak tau dimana bertanya untuk ini, maka kamu tidak seharusnya memiliki ini.", + "title": "Aktifkan Hydra", + "installation_id": "ID Instalasi:", + "enter_activation_code": "Masukkan kode aktivasi kamu", + "message": "Kalau tidak tahu harus tanya ke siapa, berarti kamu tidak perlu ini.", "activate": "Aktifkan", "loading": "Memuat…" }, "downloads": { "resume": "Lanjutkan", - "pause": "Hentikan sementara", - "eta": "Perkiraan {{eta}}", - "paused": "Terhenti sementara", - "verifying": "Memeriksa…", + "pause": "Jeda", + "eta": "Estimasi {{eta}}", + "paused": "Dijeda", + "verifying": "Verifikasi…", "completed": "Selesai", - "cancel": "Batalkan", - "filter": "Saring game yang diunduh", + "removed": "Tidak diunduh", + "cancel": "Batal", + "filter": "Filter game yang diunduh", "remove": "Hapus", "downloading_metadata": "Mengunduh metadata…", - "deleting": "Menghapus file instalasi…", - "delete": "Hapus file instalasi", - "delete_modal_title": "Kamu yakin?", - "delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu", - "install": "Install" + "deleting": "Menghapus installer…", + "delete": "Hapus installer", + "delete_modal_title": "Apa kamu yakin?", + "delete_modal_description": "Ini akan menghapus semua file instalasi dari komputer kamu", + "install": "Instal", + "download_in_progress": "Sedang berlangsung", + "queued_downloads": "Unduhan dalam antrian", + "downloads_completed": "Selesai", + "queued": "Dalam antrian", + "no_downloads_title": "Kosong", + "no_downloads_description": "Kamu belum mengunduh apa pun dengan Hydra, tapi belum terlambat untuk mulai.", + "checking_files": "Memeriksa file…" }, "settings": { - "downloads_path": "Lokasi unduhan", - "change": "Perbarui", - "notifications": "Pengingat", + "downloads_path": "Path unduhan", + "change": "Ganti", + "notifications": "Notifikasi", "enable_download_notifications": "Saat unduhan selesai", - "enable_repack_list_notifications": "Saat repack terbaru ditambahkan", + "enable_repack_list_notifications": "Saat ada repack baru", + "real_debrid_api_token_label": "Token API Real-Debrid", + "quit_app_instead_hiding": "Jangan sembunyikan Hydra saat ditutup", + "launch_with_system": "Jalankan Hydra saat sistem dinyalakan", + "general": "Umum", "behavior": "Perilaku", - "quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi", - "launch_with_system": "Jalankan saat memulai sistem" + "download_sources": "Sumber unduhan", + "language": "Bahasa", + "real_debrid_api_token": "Token API", + "enable_real_debrid": "Aktifkan Real-Debrid", + "real_debrid_description": "Real-Debrid adalah downloader tanpa batas yang memungkinkan kamu untuk mengunduh file dengan cepat dan pada kecepatan terbaik dari Internet kamu.", + "real_debrid_invalid_token": "Token API tidak valid", + "real_debrid_api_token_hint": "Kamu bisa dapatkan token API di <0>sini", + "real_debrid_free_account_error": "Akun \"{{username}}\" adalah akun gratis. Silakan berlangganan Real-Debrid", + "real_debrid_linked_message": "Akun \"{{username}}\" terhubung", + "save_changes": "Simpan perubahan", + "changes_saved": "Perubahan disimpan berhasil", + "download_sources_description": "Hydra akan mencari link unduhan dari sini. URL harus menuju file .json dengan link unduhan.", + "validate_download_source": "Validasi", + "remove_download_source": "Hapus", + "add_download_source": "Tambahkan sumber", + "download_count_zero": "Tidak ada unduhan dalam daftar", + "download_count_one": "{{countFormatted}} unduhan dalam daftar", + "download_count_other": "{{countFormatted}} unduhan dalam daftar", + "download_options_zero": "Tidak ada unduhan tersedia", + "download_options_one": "{{countFormatted}} unduhan tersedia", + "download_options_other": "{{countFormatted}} unduhan tersedia", + "download_source_url": "URL sumber unduhan", + "add_download_source_description": "Masukkan URL yang berisi file .json", + "download_source_up_to_date": "Terkini", + "download_source_errored": "Terjadi kesalahan", + "sync_download_sources": "Sinkronkan sumber", + "removed_download_source": "Sumber unduhan dihapus", + "added_download_source": "Sumber unduhan ditambahkan", + "download_sources_synced": "Semua sumber unduhan disinkronkan", + "insert_valid_json_url": "Masukkan URL JSON yang valid", + "found_download_option_zero": "Tidak ada opsi unduhan ditemukan", + "found_download_option_one": "Ditemukan {{countFormatted}} opsi unduhan", + "found_download_option_other": "Ditemukan {{countFormatted}} opsi unduhan", + "import": "Impor" }, "notifications": { "download_complete": "Unduhan selesai", - "game_ready_to_install": "{{title}} sudah siap untuk instalasi", + "game_ready_to_install": "{{title}} siap untuk diinstal", "repack_list_updated": "Daftar repack diperbarui", "repack_count_one": "{{count}} repack ditambahkan", - "repack_count_other": "{{count}} repack ditambahkan" + "repack_count_other": "{{count}} repack ditambahkan", + "new_update_available": "Versi {{version}} tersedia", + "restart_to_install_update": "Restart Hydra untuk instal pembaruan" }, "system_tray": { "open": "Buka Hydra", - "quit": "Tutup" + "quit": "Keluar" }, "game_card": { - "no_downloads": "Tidak ada unduhan tersedia" + "no_downloads": "Tidak ada unduhan yang tersedia" }, "binary_not_found_modal": { - "title": "Program tidak terinstal", - "description": "Wine atau Lutris exe tidak ditemukan pada sistem kamu", - "instructions": "Periksa cara instalasi yang benar pada Linux distro-mu agar game dapat dimainkan dengan benar" + "title": "Program tidak terpasang", + "description": "Executable Wine atau Lutris tidak ditemukan di sistem kamu", + "instructions": "Cek cara instalasi yang benar di distro Linux kamu agar game bisa jalan normal" }, "modal": { - "close": "Tombol tutup" + "close": "Tutup" + }, + "forms": { + "toggle_password_visibility": "Tampilkan/Sembunyikan kata sandi" + }, + "user_profile": { + "amount_hours": "{{amount}} jam", + "amount_minutes": "{{amount}} menit", + "last_time_played": "Terakhir dimainkan {{period}}", + "activity": "Aktivitas terbaru", + "library": "Perpustakaan", + "total_play_time": "Total waktu bermain: {{amount}}", + "no_recent_activity_title": "Hmm… kosong di sini", + "no_recent_activity_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!", + "display_name": "Nama tampilan", + "saving": "Menyimpan", + "save": "Simpan", + "edit_profile": "Edit Profil", + "saved_successfully": "Berhasil disimpan", + "try_again": "Coba lagi yuk", + "sign_out_modal_title": "Apa kamu yakin?", + "cancel": "Batal", + "successfully_signed_out": "Berhasil keluar", + "sign_out": "Keluar", + "playing_for": "Bermain selama {{amount}}", + "sign_out_modal_text": "Perpustakaan kamu terhubung dengan akun saat ini. Saat keluar, perpustakaan kamu tidak akan terlihat lagi, dan progres tidak akan disimpan. Lanjutkan keluar?", + "add_friends": "Tambah Teman", + "add": "Tambah", + "friend_code": "Kode teman", + "see_profile": "Lihat profil", + "sending": "Mengirim", + "friend_request_sent": "Permintaan teman terkirim", + "friends": "Teman", + "friends_list": "Daftar teman", + "user_not_found": "Pengguna tidak ditemukan" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 02c0879f..ef94c31f 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -12,11 +12,11 @@ "catalogue": "Catálogo", "downloads": "Downloads", "settings": "Ajustes", - "my_library": "Minha biblioteca", + "my_library": "Biblioteca", "downloading_metadata": "{{title}} (Baixando metadados…)", "paused": "{{title}} (Pausado)", "downloading": "{{title}} ({{percentage}} - Baixando…)", - "filter": "Filtrar biblioteca", + "filter": "Buscar", "home": "Início", "queued": "{{title}} (Na fila)", "game_has_no_executable": "Jogo não possui executável selecionado", @@ -54,7 +54,7 @@ "calculating_eta": "Calculando tempo restante…", "downloading_metadata": "Baixando metadados…", "filter": "Filtrar repacks", - "requirements": "Requisitos do sistema", + "requirements": "Requisitos de sistema", "minimum": "Mínimos", "recommended": "Recomendados", "paused": "Pausado", @@ -68,16 +68,16 @@ "add_to_library": "Adicionar à biblioteca", "remove_from_library": "Remover da biblioteca", "no_downloads": "Nenhum download disponível", - "play_time": "Jogado por {{amount}}", + "play_time": "Jogou por {{amount}}", "next_suggestion": "Próxima sugestão", "install": "Instalar", - "last_time_played": "Jogou por último {{period}}", + "last_time_played": "Última sessão {{period}}", "play": "Jogar", "not_played_yet": "Você ainda não jogou {{title}}", "close": "Fechar", "deleting": "Excluindo instalador…", "playing_now": "Jogando agora", - "change": "Mudar", + "change": "Explorar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes", "download_now": "Iniciar download", @@ -90,13 +90,13 @@ "open_screenshot": "Ver captura de tela {{number}}", "download_settings": "Ajustes do download", "downloader": "Downloader", - "select_executable": "Selecionar", + "select_executable": "Explorar", "no_executable_selected": "Nenhum executável selecionado", "open_folder": "Abrir pasta", "open_download_location": "Ver arquivos baixados", "create_shortcut": "Criar atalho na área de trabalho", "remove_files": "Remover arquivos", - "options": "Opções", + "options": "Gerenciar", "remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca", "remove_from_library_title": "Tem certeza?", "executable_section_title": "Executável", @@ -120,7 +120,7 @@ "loading": "Carregando…" }, "downloads": { - "resume": "Resumir", + "resume": "Retomar", "pause": "Pausar", "eta": "Conclusão {{eta}}", "paused": "Pausado", @@ -146,12 +146,12 @@ }, "settings": { "downloads_path": "Diretório dos downloads", - "change": "Mudar", + "change": "Explorar...", "notifications": "Notificações", "enable_download_notifications": "Quando um download for concluído", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "real_debrid_api_token_label": "Token de API do Real-Debrid", - "quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar", + "quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.", "launch_with_system": "Iniciar o Hydra junto com o sistema", "general": "Geral", "behavior": "Comportamento", @@ -208,7 +208,7 @@ }, "binary_not_found_modal": { "title": "Programas não instalados", - "description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris", + "description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.", "instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo" }, "catalogue": { @@ -224,8 +224,8 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", - "last_time_played": "Jogou {{period}}", - "activity": "Atividade recente", + "last_time_played": "Última sessão {{period}}", + "activity": "Atividades recentes", "library": "Biblioteca", "total_play_time": "Tempo total de jogo: {{amount}}", "no_recent_activity_title": "Hmmm… nada por aqui", @@ -233,7 +233,7 @@ "display_name": "Nome de exibição", "saving": "Salvando…", "save": "Salvar", - "edit_profile": "Editar Perfil", + "edit_profile": "Editar perfil", "saved_successfully": "Salvo com sucesso", "try_again": "Por favor, tente novamente", "cancel": "Cancelar", @@ -241,6 +241,26 @@ "sign_out": "Sair da conta", "sign_out_modal_title": "Tem certeza?", "playing_for": "Jogando por {{amount}}", - "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?" + "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?", + "add_friends": "Adicionar Amigos", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "friend_request_sent": "Pedido de amizade enviado", + "friends": "Amigos", + "add": "Adicionar", + "sending": "Enviando", + "friends_list": "Lista de amigos", + "user_not_found": "Usuário não encontrado", + "block_user": "Bloquear", + "add_friend": "Adicionar amigo", + "request_sent": "Pedido enviado", + "request_received": "Pedido recebido", + "accept_request": "Aceitar pedido", + "ignore_request": "Ignorar pedido", + "cancel_request": "Cancelar pedido", + "undo_friendship": "Desfazer amizade", + "request_accepted": "Pedido de amizade aceito", + "user_blocked_successfully": "Usuário bloqueado com sucesso", + "user_block_modal_text": "Bloquear {{displayName}}" } } diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 1b500be9..57daf51c 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -43,8 +43,15 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; +import "./user/block-user"; +import "./user/unblock-user"; +import "./user/get-user-friends"; +import "./profile/get-friend-requests"; import "./profile/get-me"; +import "./profile/undo-friendship"; +import "./profile/update-friend-request"; import "./profile/update-profile"; +import "./profile/send-friend-request"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/events/profile/get-friend-requests.ts b/src/main/events/profile/get-friend-requests.ts new file mode 100644 index 00000000..11d8a884 --- /dev/null +++ b/src/main/events/profile/get-friend-requests.ts @@ -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 => { + return HydraApi.get(`/profile/friend-requests`).catch(() => []); +}; + +registerEvent("getFriendRequests", getFriendRequests); diff --git a/src/main/events/profile/get-me.ts b/src/main/events/profile/get-me.ts index 83463680..1626125b 100644 --- a/src/main/events/profile/get-me.ts +++ b/src/main/events/profile/get-me.ts @@ -9,9 +9,7 @@ const getMe = async ( _event: Electron.IpcMainInvokeEvent ): Promise => { return HydraApi.get(`/profile/me`) - .then((response) => { - const me = response.data; - + .then((me) => { userAuthRepository.upsert( { id: 1, @@ -26,12 +24,18 @@ const getMe = async ( return me; }) - .catch((err) => { + .catch(async (err) => { if (err instanceof UserNotLoggedInError) { return null; } - return userAuthRepository.findOne({ where: { id: 1 } }); + const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + + if (loggedUser) { + return { ...loggedUser, id: loggedUser.userId }; + } + + return null; }); }; diff --git a/src/main/events/profile/send-friend-request.ts b/src/main/events/profile/send-friend-request.ts new file mode 100644 index 00000000..d696606f --- /dev/null +++ b/src/main/events/profile/send-friend-request.ts @@ -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); diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts new file mode 100644 index 00000000..371bc5cc --- /dev/null +++ b/src/main/events/profile/undo-friendship.ts @@ -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); diff --git a/src/main/events/profile/update-friend-request.ts b/src/main/events/profile/update-friend-request.ts new file mode 100644 index 00000000..24929544 --- /dev/null +++ b/src/main/events/profile/update-friend-request.ts @@ -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); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index fe79d345..8620eaa1 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -26,11 +26,9 @@ const updateProfile = async ( _event: Electron.IpcMainInvokeEvent, displayName: string, newProfileImagePath: string | null -) => { +): Promise => { if (!newProfileImagePath) { - return patchUserProfile(displayName).then( - (response) => response.data as UserProfile - ); + return patchUserProfile(displayName); } const stats = fs.statSync(newProfileImagePath); @@ -42,7 +40,7 @@ const updateProfile = async ( imageLength: fileSizeInBytes, }) .then(async (preSignedResponse) => { - const { presignedUrl, profileImageUrl } = preSignedResponse.data; + const { presignedUrl, profileImageUrl } = preSignedResponse; const mimeType = await fileTypeFromFile(newProfileImagePath); @@ -51,13 +49,11 @@ const updateProfile = async ( "Content-Type": mimeType?.mime, }, }); - return profileImageUrl; + return profileImageUrl as string; }) .catch(() => undefined); - return patchUserProfile(displayName, profileImageUrl).then( - (response) => response.data as UserProfile - ); + return patchUserProfile(displayName, profileImageUrl); }; registerEvent("updateProfile", updateProfile); diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts new file mode 100644 index 00000000..8003f478 --- /dev/null +++ b/src/main/events/user/block-user.ts @@ -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); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts new file mode 100644 index 00000000..28783459 --- /dev/null +++ b/src/main/events/user/get-user-friends.ts @@ -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 => { + 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); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 596df084..eb4f0619 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -4,14 +4,19 @@ import { steamGamesWorker } from "@main/workers"; import { UserProfile } from "@types"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { getSteamAppAsset } from "@main/helpers"; +import { getUserFriends } from "./get-user-friends"; const getUser = async ( _event: Electron.IpcMainInvokeEvent, userId: string ): Promise => { try { - const response = await HydraApi.get(`/user/${userId}`); - const profile = response.data; + const [profile, friends] = await Promise.all([ + HydraApi.get(`/user/${userId}`), + getUserFriends(userId, 12, 0).catch(() => { + return { totalFriends: 0, friends: [] }; + }), + ]); const recentGames = await Promise.all( profile.recentGames.map(async (game) => { @@ -47,7 +52,13 @@ const getUser = async ( }) ); - return { ...profile, libraryGames, recentGames }; + return { + ...profile, + libraryGames, + recentGames, + friends: friends.friends, + totalFriends: friends.totalFriends, + }; } catch (err) { return null; } diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts new file mode 100644 index 00000000..ac678dbd --- /dev/null +++ b/src/main/events/user/unblock-user.ts @@ -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); diff --git a/src/main/index.ts b/src/main/index.ts index e288302b..9ff74bf6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,8 @@ autoUpdater.setFeedURL({ autoUpdater.logger = logger; +logger.log("Init Hydra"); + const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); @@ -121,6 +123,7 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { /* Disconnects libtorrent */ PythonInstance.kill(); + logger.log("Quit Hydra"); }); app.on("activate", () => { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 98b783f3..5365bd9e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -10,7 +10,7 @@ import { UserNotLoggedInError } from "@shared"; export class HydraApi { private static instance: AxiosInstance; - private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; + private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static secondsToMilliseconds = (seconds: number) => seconds * 1000; @@ -45,6 +45,8 @@ export class HydraApi { expirationTimestamp: tokenExpirationTimestamp, }; + logger.log("Sign in received", this.userAuth); + await userAuthRepository.upsert( { id: 1, @@ -70,11 +72,11 @@ export class HydraApi { this.instance.interceptors.request.use( (request) => { logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.data); + logger.log(request.method, request.url, request.params, request.data); return request; }, (error) => { - logger.log("request error", error); + logger.error("request error", error); return Promise.reject(error); } ); @@ -95,12 +97,18 @@ export class HydraApi { const { config } = error; - logger.error(config.method, config.baseURL, config.url, config.headers); + logger.error( + config.method, + config.baseURL, + config.url, + config.headers, + config.data + ); if (error.response) { - logger.error(error.response.status, error.response.data); + logger.error("Response", error.response.status, error.response.data); } else if (error.request) { - logger.error(error.request); + logger.error("Request", error.request); } else { logger.error("Error", error.message); } @@ -146,6 +154,8 @@ export class HydraApi { this.userAuth.authToken = accessToken; this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + logger.log("Token refreshed", this.userAuth); + userAuthRepository.upsert( { id: 1, @@ -170,6 +180,8 @@ export class HydraApi { private static handleUnauthorizedError = (err) => { if (err instanceof AxiosError && err.response?.status === 401) { + logger.error("401 - Current credentials:", this.userAuth); + this.userAuth = { authToken: "", expirationTimestamp: 0, @@ -184,48 +196,53 @@ export class HydraApi { throw err; }; - static async get(url: string) { + static async get(url: string, params?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .get(url, this.getAxiosConfig()) + .get(url, { params, ...this.getAxiosConfig() }) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async post(url: string, data?: any) { + static async post(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .post(url, data, this.getAxiosConfig()) + .post(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async put(url: string, data?: any) { + static async put(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .put(url, data, this.getAxiosConfig()) + .put(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async patch(url: string, data?: any) { + static async patch(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .patch(url, data, this.getAxiosConfig()) + .patch(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async delete(url: string) { + static async delete(url: string) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .delete(url, this.getAxiosConfig()) + .delete(url, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } } diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index c0e8b1f8..b66a1897 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -10,11 +10,7 @@ export const createGame = async (game: Game) => { lastTimePlayed: game.lastTimePlayed, }) .then((response) => { - const { - id: remoteId, - playTimeInMilliseconds, - lastTimePlayed, - } = response.data; + const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; gameRepository.update( { objectID: game.objectID }, diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 2162ea58..2a6b5bb5 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -6,7 +6,7 @@ import { getSteamAppAsset } from "@main/helpers"; export const mergeWithRemoteGames = async () => { return HydraApi.get("/games") .then(async (response) => { - for (const game of response.data) { + for (const game of response) { const localGame = await gameRepository.findOne({ where: { objectID: game.objectId, diff --git a/src/preload/index.ts b/src/preload/index.ts index 0cadbc03..3350a340 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { AppUpdaterEvent, StartGameDownloadPayload, GameRunning, + FriendRequestAction, } from "@types"; contextBridge.exposeInMainWorld("electron", { @@ -134,11 +135,22 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), + undoFriendship: (userId: string) => + ipcRenderer.invoke("undoFriendship", userId), updateProfile: (displayName: string, newProfileImagePath: string | null) => ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), + getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), + updateFriendRequest: (userId: string, action: FriendRequestAction) => + ipcRenderer.invoke("updateFriendRequest", userId, action), + sendFriendRequest: (userId: string) => + ipcRenderer.invoke("sendFriendRequest", userId), /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), + unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), + getUserFriends: (userId: string, take: number, skip: number) => + ipcRenderer.invoke("getUserFriends", userId, take, skip), /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), diff --git a/src/renderer/index.html b/src/renderer/index.html index 543b85a9..d7abf3ad 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index afce9622..8c6f7604 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -25,6 +25,7 @@ import { setGameRunning, } from "@renderer/features"; import { useTranslation } from "react-i18next"; +import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; export interface AppProps { children: React.ReactNode; @@ -38,7 +39,15 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); - const { fetchUserDetails, updateUserDetails, clearUserDetails } = + const { + isFriendsModalVisible, + friendRequetsModalTab, + friendModalUserId, + fetchFriendRequests, + hideFriendsModal, + } = useUserDetails(); + + const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } = useUserDetails(); const dispatch = useAppDispatch(); @@ -94,7 +103,10 @@ export function App() { } fetchUserDetails().then((response) => { - if (response) updateUserDetails(response); + if (response) { + updateUserDetails(response); + fetchFriendRequests(); + } }); }, [fetchUserDetails, updateUserDetails, dispatch]); @@ -102,6 +114,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); + fetchFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -206,6 +219,15 @@ export function App() { onClose={handleToastClose} /> + {userDetails && ( + + )} +
diff --git a/src/renderer/src/components/sidebar/sidebar-profile.css.ts b/src/renderer/src/components/sidebar/sidebar-profile.css.ts index d01b07f1..ba29c850 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.css.ts +++ b/src/renderer/src/components/sidebar/sidebar-profile.css.ts @@ -1,7 +1,18 @@ -import { style } from "@vanilla-extract/css"; +import { createVar, style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../theme.css"; +export const profileContainerBackground = createVar(); + +export const profileContainer = style({ + background: profileContainerBackground, + position: "relative", + cursor: "pointer", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + export const profileButton = style({ display: "flex", cursor: "pointer", @@ -10,9 +21,8 @@ export const profileButton = style({ color: vars.color.muted, borderBottom: `solid 1px ${vars.color.border}`, boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, + width: "100%", + zIndex: "10", }); export const profileButtonContent = style({ @@ -64,3 +74,25 @@ export const profileButtonTitle = style({ textOverflow: "ellipsis", whiteSpace: "nowrap", }); + +export const friendRequestContainer = style({ + position: "absolute", + padding: "8px", + right: `${SPACING_UNIT}px`, + display: "flex", + top: 0, + bottom: 0, + alignItems: "center", +}); + +export const friendRequestButton = style({ + color: vars.color.success, + cursor: "pointer", + borderRadius: "50%", + overflow: "hidden", + width: "40px", + height: "40px", + ":hover": { + color: vars.color.muted, + }, +}); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 914481b0..81736e37 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -1,17 +1,29 @@ import { useNavigate } from "react-router-dom"; -import { PersonIcon } from "@primer/octicons-react"; +import { PersonAddIcon, PersonIcon } from "@primer/octicons-react"; import * as styles from "./sidebar-profile.css"; - +import { assignInlineVars } from "@vanilla-extract/dynamic"; import { useAppSelector, useUserDetails } from "@renderer/hooks"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { profileContainerBackground } from "./sidebar-profile.css"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import { FriendRequest } from "@types"; export function SidebarProfile() { const navigate = useNavigate(); const { t } = useTranslation("sidebar"); - const { userDetails, profileBackground } = useUserDetails(); + const { userDetails, profileBackground, friendRequests, showFriendsModal } = + useUserDetails(); + + const [receivedRequests, setReceivedRequests] = useState([]); + + useEffect(() => { + setReceivedRequests( + friendRequests.filter((request) => request.type === "RECEIVED") + ); + }, [friendRequests]); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -30,46 +42,66 @@ export function SidebarProfile() { }, [profileBackground]); return ( - + + {userDetails && receivedRequests.length > 0 && !gameRunning && ( +
+ +
+ )} + ); } diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 48fa7aae..e022cffe 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -14,6 +14,9 @@ import type { RealDebridUser, DownloadSource, UserProfile, + FriendRequest, + FriendRequestAction, + UserFriends, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -125,13 +128,27 @@ declare global { /* User */ getUser: (userId: string) => Promise; + blockUser: (userId: string) => Promise; + unblockUser: (userId: string) => Promise; + getUserFriends: ( + userId: string, + take: number, + skip: number + ) => Promise; /* Profile */ getMe: () => Promise; + undoFriendship: (userId: string) => Promise; updateProfile: ( displayName: string, newProfileImagePath: string | null ) => Promise; + getFriendRequests: () => Promise; + updateFriendRequest: ( + userId: string, + action: FriendRequestAction + ) => Promise; + sendFriendRequest: (userId: string) => Promise; } interface Window { diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 0cc395b0..d559de09 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -1,14 +1,23 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import type { UserDetails } from "@types"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import type { FriendRequest, UserDetails } from "@types"; export interface UserDetailsState { userDetails: UserDetails | null; profileBackground: null | string; + friendRequests: FriendRequest[]; + isFriendsModalVisible: boolean; + friendRequetsModalTab: UserFriendModalTab | null; + friendModalUserId: string; } const initialState: UserDetailsState = { userDetails: null, profileBackground: null, + friendRequests: [], + isFriendsModalVisible: false, + friendRequetsModalTab: null, + friendModalUserId: "", }; export const userDetailsSlice = createSlice({ @@ -21,8 +30,28 @@ export const userDetailsSlice = createSlice({ setProfileBackground: (state, action: PayloadAction) => { state.profileBackground = action.payload; }, + setFriendRequests: (state, action: PayloadAction) => { + state.friendRequests = action.payload; + }, + setFriendsModalVisible: ( + state, + action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }> + ) => { + state.isFriendsModalVisible = true; + state.friendRequetsModalTab = action.payload.initialTab; + state.friendModalUserId = action.payload.userId; + }, + setFriendsModalHidden: (state) => { + state.isFriendsModalVisible = false; + state.friendRequetsModalTab = null; + }, }, }); -export const { setUserDetails, setProfileBackground } = - userDetailsSlice.actions; +export const { + setUserDetails, + setProfileBackground, + setFriendRequests, + setFriendsModalVisible, + setFriendsModalHidden, +} = userDetailsSlice.actions; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index d37612d4..182aef25 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,6 +1,7 @@ import type { GameShop } from "@types"; import Color from "color"; +import { average } from "color.js"; export const steamUrlBuilder = { library: (objectID: string) => @@ -45,3 +46,14 @@ export const buildGameDetailsPath = ( export const darkenColor = (color: string, amount: number, alpha: number = 1) => new Color(color).darken(amount).alpha(alpha).toString(); + +export const profileBackgroundFromProfileImage = async ( + profileImageUrl: string +) => { + const output = await average(profileImageUrl, { + amount: 1, + format: "hex", + }); + + return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; +}; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index e87f8ff6..21690e7e 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -1,17 +1,27 @@ import { useCallback } from "react"; -import { average } from "color.js"; - import { useAppDispatch, useAppSelector } from "./redux"; -import { setProfileBackground, setUserDetails } from "@renderer/features"; -import { darkenColor } from "@renderer/helpers"; -import { UserDetails } from "@types"; +import { + setProfileBackground, + setUserDetails, + setFriendRequests, + setFriendsModalVisible, + setFriendsModalHidden, +} from "@renderer/features"; +import { profileBackgroundFromProfileImage } from "@renderer/helpers"; +import { FriendRequestAction, UserDetails } from "@types"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; export function useUserDetails() { const dispatch = useAppDispatch(); - const { userDetails, profileBackground } = useAppSelector( - (state) => state.userDetails - ); + const { + userDetails, + profileBackground, + friendRequests, + isFriendsModalVisible, + friendModalUserId, + friendRequetsModalTab, + } = useAppSelector((state) => state.userDetails); const clearUserDetails = useCallback(async () => { dispatch(setUserDetails(null)); @@ -31,12 +41,9 @@ export function useUserDetails() { dispatch(setUserDetails(userDetails)); if (userDetails.profileImageUrl) { - const output = await average(userDetails.profileImageUrl, { - amount: 1, - format: "hex", - }); - - const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; + const profileBackground = await profileBackgroundFromProfileImage( + userDetails.profileImageUrl + ); dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( @@ -78,13 +85,76 @@ export function useUserDetails() { [updateUserDetails] ); + const fetchFriendRequests = useCallback(() => { + return window.electron + .getFriendRequests() + .then((friendRequests) => { + dispatch(setFriendRequests(friendRequests)); + }) + .catch(() => {}); + }, [dispatch]); + + const showFriendsModal = useCallback( + (initialTab: UserFriendModalTab, userId: string) => { + dispatch(setFriendsModalVisible({ initialTab, userId })); + fetchFriendRequests(); + }, + [dispatch] + ); + + const hideFriendsModal = useCallback(() => { + dispatch(setFriendsModalHidden()); + }, [dispatch]); + + const sendFriendRequest = useCallback( + async (userId: string) => { + return window.electron + .sendFriendRequest(userId) + .then(() => fetchFriendRequests()); + }, + [fetchFriendRequests] + ); + + const updateFriendRequestState = useCallback( + async (userId: string, action: FriendRequestAction) => { + return window.electron + .updateFriendRequest(userId, action) + .then(() => fetchFriendRequests()); + }, + [fetchFriendRequests] + ); + + const undoFriendship = (userId: string) => { + return window.electron.undoFriendship(userId); + }; + + const blockUser = (userId: string) => { + return window.electron.blockUser(userId); + }; + + const unblockUser = (userId: string) => { + return window.electron.unblockUser(userId); + }; + return { userDetails, + profileBackground, + friendRequests, + friendRequetsModalTab, + isFriendsModalVisible, + friendModalUserId, + showFriendsModal, + hideFriendsModal, fetchUserDetails, signOut, clearUserDetails, updateUserDetails, patchUser, - profileBackground, + sendFriendRequest, + fetchFriendRequests, + updateFriendRequestState, + blockUser, + unblockUser, + undoFriendship, }; } diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts new file mode 100644 index 00000000..c7484512 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts @@ -0,0 +1 @@ +export * from "./user-friend-modal"; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx new file mode 100644 index 00000000..7c34d040 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx @@ -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 ( + + {type == "SENT" ? t("request_sent") : t("request_received")} + + ); + }; + + const getRequestActions = () => { + if (type === null) return null; + + if (type === "SENT") { + return ( + + ); + } + + if (type === "RECEIVED") { + return ( + <> + + + + ); + } + + if (type === "ACCEPTED") { + return ( + + ); + } + + return null; + }; + + return ( +
+ + +
+ {getRequestActions()} +
+
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx new file mode 100644 index 00000000..0725674e --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -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 ( + <> +
+ setFriendCode(e.target.value)} + /> + + +
+ +
+

Pendentes

+ {friendRequests.map((request) => { + return ( + + ); + })} +
+ + ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx new file mode 100644 index 00000000..52e646e0 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -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([]); + + 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 ( +
+ {friends.map((friend) => { + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts new file mode 100644 index 00000000..0d6e8643 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts @@ -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, + }, +}); diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx new file mode 100644 index 00000000..abc26270 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -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 ; + } + + if (currentTab == UserFriendModalTab.AddFriend) { + return ; + } + + return <>; + }; + + return ( + +
+ {isMe && ( +
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ )} + {renderTab()} +
+
+ ); +}; diff --git a/src/renderer/src/pages/user/user-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx new file mode 100644 index 00000000..e179e4da --- /dev/null +++ b/src/renderer/src/pages/user/user-block-modal.tsx @@ -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 ( + <> + +
+

+ {t("user_block_modal_text", { displayName })} +

+
+ + + +
+
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index 6f897238..81db119d 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -1,9 +1,8 @@ -import { UserGame, UserProfile } from "@types"; +import { FriendRequestAction, UserGame, UserProfile } from "@types"; import cn from "classnames"; - import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { @@ -13,11 +12,23 @@ import { useUserDetails, } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; -import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; +import { + buildGameDetailsPath, + profileBackgroundFromProfileImage, + steamUrlBuilder, +} from "@renderer/helpers"; +import { + CheckCircleIcon, + PersonIcon, + PlusIcon, + TelescopeIcon, + XCircleIcon, +} from "@primer/octicons-react"; import { Button, Link } from "@renderer/components"; import { UserEditProfileModal } from "./user-edit-modal"; -import { UserSignOutModal } from "./user-signout-modal"; +import { UserSignOutModal } from "./user-sign-out-modal"; +import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; +import { UserBlockModal } from "./user-block-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; @@ -26,17 +37,32 @@ export interface ProfileContentProps { updateUserProfile: () => Promise; } +type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND"); + export function UserContent({ userProfile, updateUserProfile, }: ProfileContentProps) { const { t, i18n } = useTranslation("user_profile"); - const { userDetails, profileBackground, signOut } = useUserDetails(); - const { showSuccessToast } = useToast(); + const { + userDetails, + profileBackground, + signOut, + sendFriendRequest, + fetchFriendRequests, + showFriendsModal, + updateFriendRequestState, + undoFriendship, + blockUser, + } = useUserDetails(); + const { showSuccessToast, showErrorToast } = useToast(); + const [profileContentBoxBackground, setProfileContentBoxBackground] = + useState(); const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); + const [showUserBlockModal, setShowUserBlockModal] = useState(false); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -72,6 +98,10 @@ export function UserContent({ setShowEditProfileModal(true); }; + const handleOnClickFriend = (userId: string) => { + navigate(`/user/${userId}`); + }; + const handleConfirmSignout = async () => { await signOut(); @@ -82,11 +112,142 @@ export function UserContent({ const isMe = userDetails?.id == userProfile.id; - const profileContentBoxBackground = useMemo(() => { - if (profileBackground) return profileBackground; - /* TODO: Render background colors for other users */ - return undefined; - }, [profileBackground]); + useEffect(() => { + if (isMe) fetchFriendRequests(); + }, [isMe]); + + useEffect(() => { + if (isMe && profileBackground) { + setProfileContentBoxBackground(profileBackground); + } + + if (userProfile.profileImageUrl) { + profileBackgroundFromProfileImage(userProfile.profileImageUrl).then( + (profileBackground) => { + setProfileContentBoxBackground(profileBackground); + } + ); + } + }, [profileBackground, isMe]); + + const handleFriendAction = (userId: string, action: FriendAction) => { + try { + if (action === "UNDO") { + undoFriendship(userId).then(updateUserProfile); + return; + } + + if (action === "BLOCK") { + blockUser(userId).then(() => { + setShowUserBlockModal(false); + showSuccessToast(t("user_blocked_successfully")); + navigate(-1); + }); + + return; + } + + if (action === "SEND") { + sendFriendRequest(userProfile.id).then(updateUserProfile); + return; + } + + updateFriendRequestState(userId, action).then(updateUserProfile); + } catch (err) { + showErrorToast(t("try_again")); + } + }; + + const showFriends = isMe || userProfile.totalFriends > 0; + + const getProfileActions = () => { + if (isMe) { + return ( + <> + + + + + ); + } + + if (userProfile.relation == null) { + return ( + <> + + + + + ); + } + + if (userProfile.relation.status === "ACCEPTED") { + const userId = + userProfile.relation.AId === userDetails?.id + ? userProfile.relation.BId + : userProfile.relation.AId; + + return ( + <> + + + ); + } + + if (userProfile.relation.BId === userProfile.id) { + return ( + + ); + } + + return ( + <> + + + + ); + }; return ( <> @@ -103,6 +264,13 @@ export function UserContent({ onConfirm={handleConfirmSignout} /> + setShowUserBlockModal(false)} + onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")} + displayName={userProfile.displayName} + /> +
- {isMe && ( +
-
- <> - - - - -
+ {getProfileActions()}
- )} +
@@ -216,9 +371,11 @@ export function UserContent({

{t("no_recent_activity_title")}

-

- {t("no_recent_activity_description")} -

+ {isMe && ( +

+ {t("no_recent_activity_description")} +

+ )} ) : (
-
-
-

{t("library")}

- +
+
-

- {userProfile.libraryGames.length} -

+ > +

{t("library")}

+ +
+

+ {userProfile.libraryGames.length} +

+
+ {t("total_play_time", { amount: formatPlayTime() })} +
+ {userProfile.libraryGames.map((game) => ( + + ))} +
- {t("total_play_time", { amount: formatPlayTime() })} -
- {userProfile.libraryGames.map((game) => ( + + {showFriends && ( +
- ))} -
+ +
+ {userProfile.friends.map((friend) => { + return ( + + ); + })} + + {isMe && ( + + )} +
+
+ )}
diff --git a/src/renderer/src/pages/user/user-signout-modal.tsx b/src/renderer/src/pages/user/user-sign-out-modal.tsx similarity index 92% rename from src/renderer/src/pages/user/user-signout-modal.tsx rename to src/renderer/src/pages/user/user-sign-out-modal.tsx index a91e8c9d..b9565a1d 100644 --- a/src/renderer/src/pages/user/user-signout-modal.tsx +++ b/src/renderer/src/pages/user/user-sign-out-modal.tsx @@ -2,7 +2,7 @@ import { Button, Modal } from "@renderer/components"; import * as styles from "./user.css"; import { useTranslation } from "react-i18next"; -export interface UserEditProfileModalProps { +export interface UserSignOutModalProps { visible: boolean; onConfirm: () => void; onClose: () => void; @@ -12,7 +12,7 @@ export const UserSignOutModal = ({ visible, onConfirm, onClose, -}: UserEditProfileModalProps) => { +}: UserSignOutModalProps) => { const { t } = useTranslation("user_profile"); return ( diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts index 299aa393..f9b1b09a 100644 --- a/src/renderer/src/pages/user/user.css.ts +++ b/src/renderer/src/pages/user/user.css.ts @@ -11,6 +11,7 @@ export const wrapper = style({ export const profileContentBox = style({ display: "flex", + cursor: "pointer", gap: `${SPACING_UNIT * 3}px`, alignItems: "center", borderRadius: "4px", @@ -35,6 +36,29 @@ export const profileAvatarContainer = style({ zIndex: 1, }); +export const friendAvatarContainer = style({ + width: "35px", + minWidth: "35px", + height: "35px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + overflow: "hidden", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", +}); + +export const friendListDisplayName = style({ + fontWeight: "bold", + fontSize: vars.size.body, + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + export const profileAvatarEditContainer = style({ width: "128px", height: "128px", @@ -53,9 +77,9 @@ export const profileAvatarEditContainer = style({ export const profileAvatar = style({ height: "100%", width: "100%", + objectFit: "cover", borderRadius: "50%", overflow: "hidden", - objectFit: "cover", }); export const profileAvatarEditOverlay = style({ @@ -86,14 +110,36 @@ export const profileContent = style({ export const profileGameSection = style({ width: "100%", - height: "100%", display: "flex", flexDirection: "column", gap: `${SPACING_UNIT * 2}px`, }); +export const friendsSection = style({ + width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 2}px`, +}); + +export const friendsSectionHeader = style({ + fontSize: vars.size.body, + color: vars.color.body, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: `${SPACING_UNIT * 2}px`, + ":hover": { + color: vars.color.muted, + }, +}); + export const contentSidebar = style({ width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 3}px`, "@media": { "(min-width: 768px)": { width: "100%", @@ -116,12 +162,17 @@ export const libraryGameIcon = style({ borderRadius: "4px", }); +export const friendProfileIcon = style({ + height: "100%", +}); + export const feedItem = style({ color: vars.color.body, display: "flex", flexDirection: "row", gap: `${SPACING_UNIT * 2}px`, width: "100%", + overflow: "hidden", height: "72px", transition: "all ease 0.2s", cursor: "pointer", @@ -143,6 +194,19 @@ export const gameListItem = style({ }, }); +export const friendListContainer = style({ + color: vars.color.body, + width: "100%", + height: "54px", + padding: `0 ${SPACING_UNIT}px`, + gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, + transition: "all ease 0.2s", + position: "relative", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + export const gameInformation = style({ display: "flex", flexDirection: "column", @@ -215,3 +279,16 @@ export const profileBackground = style({ top: "0", borderRadius: "4px", }); + +export const cancelRequestButton = style({ + cursor: "pointer", + color: vars.color.body, + ":hover": { + color: vars.color.danger, + }, +}); + +export const acceptRequestButton = style({ + cursor: "pointer", + color: vars.color.success, +}); diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx index 501a61d8..4c45f789 100644 --- a/src/renderer/src/pages/user/user.tsx +++ b/src/renderer/src/pages/user/user.tsx @@ -2,18 +2,23 @@ import { UserProfile } from "@types"; import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { setHeaderTitle } from "@renderer/features"; -import { useAppDispatch } from "@renderer/hooks"; +import { useAppDispatch, useToast } from "@renderer/hooks"; import { UserSkeleton } from "./user-skeleton"; import { UserContent } from "./user-content"; import { SkeletonTheme } from "react-loading-skeleton"; import { vars } from "@renderer/theme.css"; import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; export const User = () => { const { userId } = useParams(); const [userProfile, setUserProfile] = useState(); const navigate = useNavigate(); + const { t } = useTranslation("user_profile"); + + const { showErrorToast } = useToast(); + const dispatch = useAppDispatch(); const getUserProfile = useCallback(() => { @@ -22,10 +27,11 @@ export const User = () => { dispatch(setHeaderTitle(userProfile.displayName)); setUserProfile(userProfile); } else { + showErrorToast(t("user_not_found")); navigate(-1); } }); - }, [dispatch, userId]); + }, [dispatch, userId, t]); useEffect(() => { getUserProfile(); diff --git a/src/types/index.ts b/src/types/index.ts index 71071620..ac352a91 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,8 @@ export type GameStatus = export type GameShop = "steam" | "epic"; +export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; + export interface SteamGenre { id: string; name: string; @@ -269,14 +271,43 @@ export interface UserDetails { profileImageUrl: string | null; } +export interface UserFriend { + id: string; + displayName: string; + profileImageUrl: string | null; +} + +export interface UserFriends { + totalFriends: number; + friends: UserFriend[]; +} + +export interface FriendRequest { + id: string; + displayName: string; + profileImageUrl: string | null; + type: "SENT" | "RECEIVED"; +} + +export interface UserRelation { + AId: string; + BId: string; + status: "ACCEPTED" | "PENDING"; + createdAt: string; + updatedAt: string; +} + export interface UserProfile { id: string; displayName: string; - username: string; profileImageUrl: string | null; + profileVisibility: "PUBLIC" | "PRIVATE" | "FRIENDS"; totalPlayTimeInSeconds: number; libraryGames: UserGame[]; recentGames: UserGame[]; + friends: UserFriend[]; + totalFriends: number; + relation: UserRelation | null; } export interface DownloadSource { diff --git a/yarn.lock b/yarn.lock index 00172038..e6b91b9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,13 @@ modern-ahocorasick "^1.0.0" picocolors "^1.0.0" +"@vanilla-extract/dynamic@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.1.tgz#bc93a577b127a7dcb6f254973d13a863029a7faf" + integrity sha512-iqf736036ujEIKsIq28UsBEMaLC2vR2DhwKyrG3NDb/fRy9qL9FKl1TqTtBV4daU30Uh3saeik4vRzN8bzQMbw== + dependencies: + "@vanilla-extract/private" "^1.0.5" + "@vanilla-extract/integration@^7.1.3": version "7.1.4" resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz" @@ -2456,6 +2463,11 @@ resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz" integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg== +"@vanilla-extract/private@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.5.tgz#8c08ac4851f4cc89a3dcdb858d8938e69b1481c4" + integrity sha512-6YXeOEKYTA3UV+RC8DeAjFk+/okoNz/h88R+McnzA2zpaVqTR/Ep+vszkWYlGBcMNO7vEkqbq5nT/JMMvhi+tw== + "@vanilla-extract/recipes@^0.5.2": version "0.5.2" resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"