Compare commits

..

50 Commits

Author SHA1 Message Date
Tasheron
f0dd89e471 Merge branch 'main' into feature/add-collections-section 2024-08-05 15:28:53 +03:00
Tasheron
4e8f5a0881 Merge branch 'main' into feature/add-collections-section 2024-08-05 15:28:13 +03:00
Zamitto
ae45547c17 Merge pull request #863 from Lianela/main
Friend strings translated (ES)
2024-08-02 22:44:46 -03:00
Tasheron
4540dcc033 Merge branch 'main' into feature/add-collections-section 2024-07-28 17:06:36 +03:00
Lianela
11c29355e3 Friend strings translated (ES) 2024-07-22 19:38:34 -06:00
Zamitto
91862cd2fe Update README.md 2024-07-21 16:45:44 -03:00
Zamitto
6c5d3793ae Merge pull request #838 from Ezequiel9898/patch-2
Update translation.json
2024-07-21 15:42:56 -03:00
Zamitto
43c5fdbab9 Merge branch 'main' into patch-2 2024-07-21 15:37:02 -03:00
Zamitto
3952f106fc Merge pull request #814 from hydralauncher/hyd-270-create-a-section-under-library-games-in-profile-page-for
feat: add friends
2024-07-20 19:11:39 -03:00
Ezequiel Neri Ferreira
2e386528a4 update 2024-07-18 00:27:10 -03:00
Ezequiel Neri Ferreira
b6727be3cf update 2024-07-18 00:01:31 -03:00
Ezequiel Neri Ferreira
05f9703c25 Update translation.json 2024-07-17 23:06:29 -03:00
Zamitto
929be48495 feat: remove nullables 2024-07-17 19:03:46 -03:00
Zamitto
8c67dda84e feat: refactor 2024-07-17 19:03:46 -03:00
Zamitto
6d277cd1d8 feat: adjust ui 2024-07-17 19:03:45 -03:00
Zamitto
d4902a5ab1 feat: use empty list 2024-07-17 19:03:45 -03:00
Zamitto
004ccd0db5 feat: add comment 2024-07-17 19:03:45 -03:00
Zamitto
e55dc20c7d feat: open modal in correct tab 2024-07-17 19:03:45 -03:00
Zamitto
7f3d7a56c3 feat: create tabs on user friend modal 2024-07-17 19:03:45 -03:00
Zamitto
d0406282ce feat: organize code 2024-07-17 19:03:44 -03:00
Zamitto
c6e99f8599 feat: update i18n and texts 2024-07-17 19:03:44 -03:00
Zamitto
5aec973882 feat: show friend request modal when click on sidebar 2024-07-17 19:03:44 -03:00
Zamitto
49fd34c3c0 feat: moving friends request button to sidebar 2024-07-17 19:03:44 -03:00
Zamitto
198a283752 feat: add logs 2024-07-17 19:03:43 -03:00
Zamitto
46b12f2bc2 simplify code 2024-07-17 19:03:43 -03:00
Zamitto
cb93fbcb72 feat: add buttons gap 2024-07-17 19:03:43 -03:00
Zamitto
22b66149b3 fix: buttons on friend request item 2024-07-17 19:03:43 -03:00
Zamitto
6f70b529a2 feat: refactor hydra api 2024-07-17 19:03:43 -03:00
Zamitto
a81b016500 feat: sending friend request 2024-07-17 19:03:42 -03:00
Zamitto
ef0699dbea feat: refactor friends requests 2024-07-17 19:03:42 -03:00
Zamitto
b3f87d5662 feat: get friends requests from api 2024-07-17 19:03:42 -03:00
Zamitto
6ff48605da feat: show friends from response 2024-07-17 19:03:42 -03:00
Zamitto
0f0a1e98a3 feat: ui adjustments 2024-07-17 19:03:41 -03:00
Zamitto
007da03837 feat: finish ui for modal showing pending requests 2024-07-17 19:03:41 -03:00
Zamitto
6cc8e8f5fe feat: pending requests on modal 2024-07-17 19:03:41 -03:00
Zamitto
6ccbff0160 feat: creating friends section 2024-07-17 19:03:41 -03:00
Zamitto
202f5b60de Merge pull request #800 from Ezequiel9898/patch-1
Update portuguese translation
2024-07-17 18:52:56 -03:00
Zamitto
b9558907ec Merge pull request #806 from Lianela/main
Improvements in ES translation
2024-07-17 18:50:01 -03:00
Lianela
8a01352eab updated es-translation.json
Reverted changes on accuracy
2024-07-17 12:56:24 -06:00
Tasheron
1aa438b0fa Merge branch 'main' into feature/add-collections-section 2024-07-16 12:09:11 +03:00
Zamitto
e008478e53 Merge branch 'main' into main 2024-07-15 22:46:58 -03:00
Zamitto
6a195eb566 Merge pull request #832 from xxDRV/patch-11
Updated RU to fit new features
2024-07-15 22:44:57 -03:00
Antecess
e2b089e0f8 Updated RU to fit new features 2024-07-15 13:47:09 +05:00
Lianela
a9b92f3fc1 accuracy fixed 2024-07-09 10:35:19 -06:00
Ezequiel Neri Ferreira
6822ed8447 Update translation.json 2024-07-07 23:42:12 -03:00
Tasheron
2a6e0f31df feature: add collections section - query optimization 2024-07-07 11:38:36 +03:00
Lianela
5683a0ba49 variation of one line
changed in a different variation a translation
2024-07-06 16:53:02 -06:00
Lianela
18488490c1 fixed some strings
changed some words and translations to a new one making some things easier to understand
2024-07-06 16:44:36 -06:00
Tasheron
b7cabfdbde feature: add collections section 2024-07-06 14:56:35 +03:00
Lianela
2ee3fdc223 updated es-translation.json
added new string
fixed one string
2024-07-05 12:40:16 -06:00
53 changed files with 1508 additions and 181 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

@@ -194,6 +194,19 @@
"found_download_option_other": "Found {{countFormatted}} download options", "found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import" "import": "Import"
}, },
"collections": {
"collections": "Collections",
"add_the_game_to_the_collection": "Add the game to the collection",
"select_a_collection": "Select a collection",
"enter_the_name_of_the_collection": "Enter the name of the collection",
"add": "Add",
"remove": "Remove",
"you_cant_give_collections_existing_or_empty_names": "You can`t give collections existing or empty names",
"the_collection_has_been_added_successfully": "The collection has been added successfully",
"the_collection_has_been_removed_successfully": "The collection has been removed successfully",
"the_game_has_been_added_to_the_collection": "The game has been added to the collection",
"the_game_has_been_removed_from_the_collection": "The game has been removed from the collection"
},
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
"game_ready_to_install": "{{title}} is ready to install", "game_ready_to_install": "{{title}} is ready to install",
@@ -241,6 +254,15 @@
"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"
} }
} }

View File

@@ -48,7 +48,7 @@
"download_options_zero": "No hay opciones de descargas disponibles", "download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga", "download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas", "download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el {{updated_at}}", "updated_at": "Actualizado el: {{updated_at}}",
"install": "Instalar", "install": "Instalar",
"resume": "Continuar", "resume": "Continuar",
"pause": "Pausa", "pause": "Pausa",
@@ -74,7 +74,7 @@
"remove_from_library": "Eliminar de la biblioteca", "remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles", "no_downloads": "No hay descargas disponibles",
"play_time": "Jugado por {{amount}}", "play_time": "Jugado por {{amount}}",
"last_time_played": "Jugado por última vez {{period}}", "last_time_played": "Jugado por última vez: {{period}}",
"not_played_yet": "Aún no has jugado a {{title}}", "not_played_yet": "Aún no has jugado a {{title}}",
"next_suggestion": "Siguiente sugerencia", "next_suggestion": "Siguiente sugerencia",
"play": "Jugar", "play": "Jugar",
@@ -107,8 +107,8 @@
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"", "executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
"downloads_secion_title": "Descargas", "downloads_secion_title": "Descargas",
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego", "downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
"danger_zone_section_title": "Zona de Peligro", "danger_zone_section_title": "Opciones Avanzadas",
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra", "danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción descargada", "last_downloaded_option": "Última opción descargada",
@@ -138,7 +138,7 @@
"deleting": "Eliminando instalador…", "deleting": "Eliminando instalador…",
"delete": "Eliminar instalador", "delete": "Eliminar instalador",
"delete_modal_title": "¿Estás seguro?", "delete_modal_title": "¿Estás seguro?",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.", "delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)",
"install": "Instalar", "install": "Instalar",
"download_in_progress": "En progreso", "download_in_progress": "En progreso",
"queued_downloads": "Descargas en cola", "queued_downloads": "Descargas en cola",
@@ -200,7 +200,8 @@
"repack_list_updated": "Lista de repacks actualizadas", "repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido", "repack_count_one": "{{count}} repack ha sido añadido",
"repack_count_other": "{{count}} repacks añadidos", "repack_count_other": "{{count}} repacks añadidos",
"new_update_available": "Version {{version}} disponible" "new_update_available": "Version {{version}} disponible",
"restart_to_install_update": "Reinicia Hydra para instalar la actualización"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",
@@ -223,13 +224,13 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado {{period}}", "last_time_played": "Última vez jugado: {{period}}",
"activity": "Actividad reciente", "activity": "Actividad reciente",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Total de tiempo jugado: {{amount}}", "total_play_time": "Total de tiempo jugado: {{amount}}",
"no_recent_activity_title": "Que raro, no hay nada por acá, ¿que tal si jugamos algo para empezar?", "no_recent_activity_title": "Que raro, no hay nada por acá...",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", "no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
"display_name": "Nombre a mostrar", "display_name": "Nombre en pantalla",
"saving": "Guardando", "saving": "Guardando",
"save": "Guardar", "save": "Guardar",
"edit_profile": "Editar perfil", "edit_profile": "Editar perfil",
@@ -240,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

@@ -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",
@@ -45,7 +45,7 @@
"download_options_one": "{{count}} opção de download", "download_options_one": "{{count}} opção de download",
"download_options_other": "{{count}} opções de download", "download_options_other": "{{count}} opções de download",
"updated_at": "Atualizado {{updated_at}}", "updated_at": "Atualizado {{updated_at}}",
"resume": "Resumir", "resume": "Retomar",
"pause": "Pausar", "pause": "Pausar",
"cancel": "Cancelar", "cancel": "Cancelar",
"remove": "Remover", "remove": "Remover",
@@ -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,15 @@
"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"
} }
} }

View File

@@ -36,7 +36,8 @@
"no_downloads_in_progress": "Нет активных загрузок", "no_downloads_in_progress": "Нет активных загрузок",
"downloading_metadata": "Загрузка метаданных {{title}}…", "downloading_metadata": "Загрузка метаданных {{title}}…",
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}", "downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…" "calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
}, },
"catalogue": { "catalogue": {
"next_page": "Следующая страница", "next_page": "Следующая страница",
@@ -144,7 +145,8 @@
"downloads_completed": "Завершено", "downloads_completed": "Завершено",
"queued": "В очереди", "queued": "В очереди",
"no_downloads_title": "Здесь так пусто...", "no_downloads_title": "Здесь так пусто...",
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать." "no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать.",
"checking_files": "Проверка файлов…"
}, },
"settings": { "settings": {
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",
@@ -192,13 +194,27 @@
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать" "import": "Импортировать"
}, },
"collections": {
"collections": "Коллекции",
"add_the_game_to_the_collection": "Добавьте игру в коллекцию",
"select_a_collection": "Выберите коллекцию",
"enter_the_name_of_the_collection": "Введите название коллекции",
"add": "Добавить",
"remove": "Удалить",
"you_cant_give_collections_existing_or_empty_names": "Нельзя давать коллекциям существующие или пустые названия",
"the_collection_has_been_added_successfully": "Коллекция успешно добавлена",
"the_collection_has_been_removed_successfully": "Коллекция успешно удалена",
"the_game_has_been_added_to_the_collection": "Игра добавлена в коллекцию",
"the_game_has_been_removed_from_the_collection": "Игра удалена из коллекции"
},
"notifications": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",
"game_ready_to_install": "{{title}} готова к установке", "game_ready_to_install": "{{title}} готова к установке",
"repack_list_updated": "Список репаков обновлен", "repack_list_updated": "Список репаков обновлен",
"repack_count_one": "{{count}} репак добавлен", "repack_count_one": "{{count}} репак добавлен",
"repack_count_other": "{{count}} репаков добавлено", "repack_count_other": "{{count}} репаков добавлено",
"new_update_available": "Доступна версия {{version}}" "new_update_available": "Доступна версия {{version}}",
"restart_to_install_update": "Перезапустите Hydra для установки обновления"
}, },
"system_tray": { "system_tray": {
"open": "Открыть Hydra", "open": "Открыть Hydra",

View File

@@ -1,5 +1,6 @@
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { import {
Collection,
DownloadQueue, DownloadQueue,
DownloadSource, DownloadSource,
Game, Game,
@@ -19,6 +20,7 @@ export const createDataSource = (
new DataSource({ new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
entities: [ entities: [
Collection,
Game, Game,
Repack, Repack,
UserPreferences, UserPreferences,

View File

@@ -0,0 +1,21 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable,
} from "typeorm";
import { Game } from "./game.entity";
@Entity("collection")
export class Collection {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
title: string;
@ManyToMany("Game", "collections")
@JoinTable()
games: Game[];
}

View File

@@ -4,4 +4,5 @@ export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity"; export * from "./game-shop-cache.entity";
export * from "./download-source.entity"; export * from "./download-source.entity";
export * from "./download-queue.entity"; export * from "./download-queue.entity";
export * from "./collection.entity";
export * from "./user-auth"; export * from "./user-auth";

View File

@@ -0,0 +1,18 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { Collection, Game } from "@main/entity";
const addCollectionGame = async (
_event: Electron.IpcMainInvokeEvent,
collectionId: number,
game: Game
) => {
return await collectionRepository
.createQueryBuilder()
.relation(Collection, "games")
.of(collectionId)
.add(game);
};
registerEvent("addCollectionGame", addCollectionGame);

View File

@@ -0,0 +1,14 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const addCollection = async (
_event: Electron.IpcMainInvokeEvent,
title: string
) => {
return await collectionRepository.insert({
title: title,
});
};
registerEvent("addCollection", addCollection);

View File

@@ -0,0 +1,19 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getCollections = async () =>
collectionRepository.find({
relations: {
games: true,
},
select: {
games: {
id: true,
},
},
order: {
title: "asc",
},
});
registerEvent("getCollections", getCollections);

View File

@@ -0,0 +1,18 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { Collection, Game } from "@main/entity";
const removeCollectionGame = async (
_event: Electron.IpcMainInvokeEvent,
collectionId: number,
game: Game
) => {
return await collectionRepository
.createQueryBuilder()
.relation(Collection, "games")
.of(collectionId)
.remove(game);
};
registerEvent("removeCollectionGame", removeCollectionGame);

View File

@@ -0,0 +1,13 @@
import { collectionRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { Collection } from "@main/entity";
const removeCollection = async (
_event: Electron.IpcMainInvokeEvent,
collection: Collection
) => {
return await collectionRepository.remove(collection);
};
registerEvent("removeCollection", removeCollection);

View File

@@ -8,6 +8,11 @@ import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game"; import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";
import "./catalogue/search-game-repacks"; import "./catalogue/search-game-repacks";
import "./collections/add-collection";
import "./collections/add-collection-game";
import "./collections/get-collections";
import "./collections/remove-collection";
import "./collections/remove-collection-game";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./library/add-game-to-library"; import "./library/add-game-to-library";
import "./library/create-game-shortcut"; import "./library/create-game-shortcut";
@@ -43,8 +48,11 @@ 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 "./profile/get-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
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,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

@@ -10,8 +10,7 @@ const getUser = async (
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const response = await HydraApi.get(`/user/${userId}`); const profile = await HydraApi.get(`/user/${userId}`);
const profile = response.data;
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {

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

@@ -1,5 +1,6 @@
import { dataSource } from "./data-source"; import { dataSource } from "./data-source";
import { import {
Collection,
DownloadQueue, DownloadQueue,
DownloadSource, DownloadSource,
Game, Game,
@@ -24,3 +25,5 @@ export const downloadSourceRepository =
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth); export const userAuthRepository = dataSource.getRepository(UserAuth);
export const collectionRepository = dataSource.getRepository(Collection);

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,
@@ -74,7 +76,7 @@ export class HydraApi {
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,
@@ -190,6 +202,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@@ -199,6 +212,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@@ -208,6 +222,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@@ -217,6 +232,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@@ -226,6 +242,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete(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,9 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
Collection,
Game,
FriendRequestAction,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@@ -102,6 +105,16 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.removeListener("on-library-batch-complete", listener); ipcRenderer.removeListener("on-library-batch-complete", listener);
}, },
/* Collections */
addCollection: (title: string) => ipcRenderer.invoke("addCollection", title),
addCollectionGame: (id: number, game: Game) =>
ipcRenderer.invoke("addCollectionGame", id, game),
getCollections: () => ipcRenderer.invoke("getCollections"),
removeCollection: (collection: Collection) =>
ipcRenderer.invoke("removeCollection", collection),
removeCollectionGame: (id: number, game: Game) =>
ipcRenderer.invoke("removeCollectionGame", id, game),
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => getDiskFreeSpace: (path: string) =>
ipcRenderer.invoke("getDiskFreeSpace", path), ipcRenderer.invoke("getDiskFreeSpace", path),
@@ -136,6 +149,11 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
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),

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://cdn.discordapp.com 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;"
/> />
</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,6 +39,13 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const {
isFriendsModalVisible,
friendRequetsModalTab,
updateFriendRequests,
hideFriendsModal,
} = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
@@ -94,7 +102,10 @@ export function App() {
} }
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) updateUserDetails(response); if (response) {
updateUserDetails(response);
updateFriendRequests();
}
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
@@ -102,6 +113,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@@ -206,6 +218,12 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
/>
<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,20 @@
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 { useMemo } 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";
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 { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@@ -30,46 +33,64 @@ 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 && friendRequests.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)}
/> >
)} <PersonAddIcon size={24} />
</div> {friendRequests.length}
</button> </button>
</div>
)}
</div>
); );
} }

View File

@@ -15,6 +15,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile"; import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { useCollections } from "@renderer/hooks/use-collections";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_INITIAL_WIDTH = 250;
@@ -25,6 +26,7 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
export function Sidebar() { export function Sidebar() {
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary(); const { library, updateLibrary } = useLibrary();
const { collections, updateCollections } = useCollections();
const navigate = useNavigate(); const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]); const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
@@ -33,6 +35,7 @@ export function Sidebar() {
const [sidebarWidth, setSidebarWidth] = useState( const [sidebarWidth, setSidebarWidth] = useState(
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
); );
const [showCollections, setShowCollections] = useState(true);
const location = useLocation(); const location = useLocation();
@@ -46,7 +49,8 @@ export function Sidebar() {
useEffect(() => { useEffect(() => {
updateLibrary(); updateLibrary();
}, [lastPacket?.game.id, updateLibrary]); updateCollections();
}, [lastPacket?.game.id, updateLibrary, updateCollections]);
const isDownloading = sortedLibrary.some( const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1 (game) => game.status === "active" && game.progress !== 1
@@ -67,18 +71,27 @@ export function Sidebar() {
}; };
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => { const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
const val = event.target.value.toLocaleLowerCase();
setFilteredLibrary( setFilteredLibrary(
sortedLibrary.filter((game) => sortedLibrary.filter((game) => game.title.toLowerCase().includes(val))
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
); );
setShowCollections(val == "");
}; };
useEffect(() => { useEffect(() => {
setFilteredLibrary(sortedLibrary); setFilteredLibrary(
}, [sortedLibrary]); sortedLibrary.filter(
(game) =>
!collections.some((collection) =>
collection.games.some(
(collectionGame) => collectionGame.id == game.id
)
)
)
);
}, [sortedLibrary, collections]);
useEffect(() => { useEffect(() => {
window.onmousemove = (event: MouseEvent) => { window.onmousemove = (event: MouseEvent) => {
@@ -199,6 +212,58 @@ export function Sidebar() {
theme="dark" theme="dark"
/> />
{collections.map((collection) =>
collection.games?.length && showCollections ? (
<section className={styles.section} key={collection.id}>
<small className={styles.sectionTitle}>
{collection.title}
</small>
<ul className={styles.menu}>
{sortedLibrary
.filter((game) =>
collection.games.some(
(collectionGame) => game.id == collectionGame.id
)
)
.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) =>
handleSidebarGameClick(event, game)
}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
) : null
)}
<ul className={styles.menu}> <ul className={styles.menu}>
{filteredLibrary.map((game) => ( {filteredLibrary.map((game) => (
<li <li

View File

@@ -14,6 +14,9 @@ import type {
RealDebridUser, RealDebridUser,
DownloadSource, DownloadSource,
UserProfile, UserProfile,
Collection,
FriendRequest,
FriendRequestAction,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@@ -78,6 +81,13 @@ declare global {
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
/* Collections */
addCollection: (title: string) => Promise<void>;
addCollectionGame: (id: number, game: Game) => Promise<void>;
getCollections: () => Promise<Collection[]>;
removeCollection: (collection: Collection) => Promise<void>;
removeCollectionGame: (id: number, game: Game) => Promise<void>;
/* User preferences */ /* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>; getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: ( updateUserPreferences: (
@@ -132,6 +142,12 @@ declare global {
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

@@ -0,0 +1,26 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { Collection } from "../../../types/index";
export interface CollectionsState {
value: Collection[];
}
const initialState: CollectionsState = {
value: [],
};
export const collectionsSlice = createSlice({
name: "collections",
initialState,
reducers: {
setCollections: (
state,
action: PayloadAction<CollectionsState["value"]>
) => {
state.value = action.payload;
},
},
});
export const { setCollections } = collectionsSlice.actions;

View File

@@ -6,3 +6,4 @@ export * from "./window-slice";
export * from "./toast-slice"; export * from "./toast-slice";
export * from "./user-details-slice"; export * from "./user-details-slice";
export * from "./running-game-slice"; export * from "./running-game-slice";
export * from "./collections-slice";

View File

@@ -1,14 +1,21 @@
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;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
userDetails: null, userDetails: null,
profileBackground: null, profileBackground: null,
friendRequests: [],
isFriendsModalVisible: false,
friendRequetsModalTab: null,
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@@ -21,8 +28,27 @@ 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<UserFriendModalTab>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload;
},
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

@@ -4,3 +4,4 @@ export * from "./use-date";
export * from "./use-toast"; export * from "./use-toast";
export * from "./redux"; export * from "./redux";
export * from "./use-user-details"; export * from "./use-user-details";
export * from "./use-collections";

View File

@@ -0,0 +1,64 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import { setCollections } from "@renderer/features";
import { Collection, Game } from "@types";
import { useToast } from "./use-toast";
import { useTranslation } from "react-i18next";
export function useCollections() {
const { t } = useTranslation("collections");
const dispatch = useAppDispatch();
const collections = useAppSelector((state) => state.collections.value);
const { showSuccessToast, showErrorToast } = useToast();
const updateCollections = useCallback(async () => {
return window.electron
.getCollections()
.then((updatedCollection) => dispatch(setCollections(updatedCollection)));
}, [dispatch]);
const addCollection = async (title: string) => {
if (
!collections.some((collection) => collection.title === title) &&
title !== ""
) {
await window.electron.addCollection(title);
updateCollections();
showSuccessToast(t("the_collection_has_been_added_successfully"));
} else {
showErrorToast(t("you_cant_give_collections_existing_or_empty_names"));
}
};
const removeCollection = async (collection: Collection) => {
await window.electron.removeCollection(collection);
updateCollections();
showSuccessToast(t("the_collection_has_been_removed_successfully"));
};
const addCollectionGame = async (collectionId: number, game: Game) => {
await window.electron.addCollectionGame(collectionId, game);
updateCollections();
showSuccessToast(t("the_game_has_been_added_to_the_collection"));
};
const removeCollectionGame = async (collectionId: number, game: Game) => {
await window.electron.removeCollectionGame(collectionId, game);
updateCollections();
showSuccessToast(t("the_game_has_been_removed_from_the_collection"));
};
return {
collections,
updateCollections,
addCollection,
removeCollection,
addCollectionGame,
removeCollectionGame,
};
}

View File

@@ -2,16 +2,27 @@ import { useCallback } from "react";
import { average } from "color.js"; import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features"; import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import { darkenColor } from "@renderer/helpers"; import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types"; 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,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@@ -78,13 +89,56 @@ export function useUserDetails() {
[updateUserDetails] [updateUserDetails]
); );
const updateFriendRequests = useCallback(async () => {
const friendRequests = await window.electron.getFriendRequests();
dispatch(setFriendRequests(friendRequests));
}, [dispatch]);
const showFriendsModal = useCallback(
(tab: UserFriendModalTab) => {
dispatch(setFriendsModalVisible(tab));
updateFriendRequests();
},
[dispatch]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron
.sendFriendRequest(userId)
.then(() => updateFriendRequests());
},
[updateFriendRequests]
);
const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => {
return window.electron
.updateFriendRequest(userId, action)
.then(() => updateFriendRequests());
},
[updateFriendRequests]
);
return { return {
userDetails, userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,
updateUserDetails, updateUserDetails,
patchUser, patchUser,
profileBackground, sendFriendRequest,
updateFriendRequests,
updateFriendRequestState,
}; };
} }

View File

@@ -0,0 +1,25 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css";
export const collectionsContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "column",
width: "50%",
margin: "auto",
});
export const buttonsContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "row",
});
export const buttonSelect = style({
flex: 3,
});
export const buttonRemove = style({
flex: 1,
});

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Collection, Game } from "@types";
import * as styles from "./collections-modal.css";
import { useCollections } from "@renderer/hooks/use-collections";
import { useLibrary } from "@renderer/hooks";
export interface CollectionsModalProps {
visible: boolean;
game: Game;
onClose: () => void;
}
export function CollectionsModal({
visible,
game,
onClose,
}: CollectionsModalProps) {
const { t } = useTranslation("collections");
const {
collections,
addCollection,
removeCollection,
addCollectionGame,
removeCollectionGame,
} = useCollections();
const { updateLibrary } = useLibrary();
const [collectionTitle, setcollectionTitle] = useState<string>("");
const handleAddCollection = () => {
addCollection(collectionTitle);
setcollectionTitle("");
};
const handleRemoveCollection = (collection: Collection) => {
removeCollection(collection);
updateLibrary();
};
const handleSetCollection = (id: number, addOrRemove: boolean) => {
addOrRemove ? addCollectionGame(id, game) : removeCollectionGame(id, game);
updateLibrary();
};
return (
<>
<Modal
visible={visible}
title={t("collections")}
onClose={onClose}
large={true}
>
<div className={styles.collectionsContainer}>
<TextField
value={collectionTitle}
theme="dark"
placeholder={t("enter_the_name_of_the_collection")}
onChange={(e) => setcollectionTitle(e.target.value)}
rightContent={
<Button
type="button"
theme="outline"
onClick={handleAddCollection}
>
{t("add")}
</Button>
}
/>
{collections.map((collection) => (
<div className={styles.buttonsContainer} key={collection.id}>
<Button
className={styles.buttonSelect}
type="button"
theme={
collection.games?.some(
(collectionGame) => collectionGame.id == game.id
)
? "primary"
: "outline"
}
onClick={() =>
handleSetCollection(
collection.id,
!collection.games?.some(
(collectionGame) => collectionGame.id == game.id
)
)
}
>
{collection.title}
</Button>
<Button
className={styles.buttonRemove}
type="button"
theme="danger"
onClick={() => handleRemoveCollection(collection)}
>
{t("remove")}
</Button>
</div>
))}
</div>
</Modal>
</>
);
}

View File

@@ -7,6 +7,7 @@ import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast } from "@renderer/hooks"; import { useDownload, useToast } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { CollectionsModal } from "./collections-modal";
export interface GameOptionsModalProps { export interface GameOptionsModalProps {
visible: boolean; visible: boolean;
@@ -19,7 +20,7 @@ export function GameOptionsModal({
game, game,
onClose, onClose,
}: GameOptionsModalProps) { }: GameOptionsModalProps) {
const { t } = useTranslation("game_details"); const { t } = useTranslation(["game_details", "collections"]);
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
@@ -28,6 +29,7 @@ export function GameOptionsModal({
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false); const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const [showCollectionsModal, setShowCollectionsModal] = useState(false);
const { const {
removeGameInstaller, removeGameInstaller,
@@ -107,6 +109,29 @@ export function GameOptionsModal({
large={true} large={true}
> >
<div className={styles.optionsContainer}> <div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("collections:collections")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("collections:add_the_game_to_the_collection")}
</h4>
</div>
<Button
type="button"
theme="outline"
onClick={() => setShowCollectionsModal(true)}
>
{t("collections:select_a_collection")}
</Button>
<CollectionsModal
visible={showCollectionsModal}
game={game}
onClose={() => {
setShowCollectionsModal(false);
}}
/>
<div className={styles.gameOptionHeader}> <div className={styles.gameOptionHeader}>
<h2>{t("executable_section_title")}</h2> <h2>{t("executable_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}> <h4 className={styles.gameOptionHeaderDescription}>

View File

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

View File

@@ -0,0 +1,140 @@
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 { UserFriendRequest } from "./user-friend-request";
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 { 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 handleClickCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast("Falha ao cancelar convite");
});
};
const handleClickAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED").catch(() => {
showErrorToast("Falha ao aceitar convite");
});
};
const handleClickRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast("Falha ao recusar convite");
});
};
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 (
<UserFriendRequest
key={request.id}
displayName={request.displayName}
isRequestSent={request.type === "SENT"}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleClickAcceptFriendRequest}
onClickCancelRequest={handleClickCancelFriendRequest}
onClickRefuseRequest={handleClickRefuseFriendRequest}
onClickRequest={handleClickRequest}
/>
);
})}
</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,77 @@
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";
export enum UserFriendModalTab {
FriendsList,
AddFriend,
}
export interface UserAddFriendsModalProps {
visible: boolean;
onClose: () => void;
initialTab: UserFriendModalTab | null;
}
export const UserFriendModal = ({
visible,
onClose,
initialTab,
}: UserAddFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")];
const [currentTab, setCurrentTab] = useState(
initialTab || UserFriendModalTab.FriendsList
);
useEffect(() => {
if (initialTab != null) {
setCurrentTab(initialTab);
}
}, [initialTab]);
const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) {
return <></>;
}
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`,
}}
>
<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>
<h2>{tabs[currentTab]}</h2>
{renderTab()}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,97 @@
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";
export interface UserFriendRequestProps {
userId: string;
profileImageUrl: string | null;
displayName: string;
isRequestSent: boolean;
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
onClickRequest: (userId: string) => void;
}
export const UserFriendRequest = ({
userId,
profileImageUrl,
displayName,
isRequestSent,
onClickCancelRequest,
onClickAcceptRequest,
onClickRefuseRequest,
onClickRequest,
}: UserFriendRequestProps) => {
return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickRequest(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>
<small>{isRequestSent ? "Pedido enviado" : "Pedido recebido"}</small>
</div>
</button>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{isRequestSent ? (
<button
className={styles.cancelRequestButton}
onClick={() => onClickCancelRequest(userId)}
>
<XCircleIcon size={28} />
</button>
) : (
<>
<button
className={styles.acceptRequestButton}
onClick={() => onClickAcceptRequest(userId)}
>
<CheckCircleIcon size={28} />
</button>
<button
className={styles.cancelRequestButton}
onClick={() => onClickRefuseRequest(userId)}
>
<XCircleIcon size={28} />
</button>
</>
)}
</div>
</div>
);
};

View File

@@ -1,9 +1,8 @@
import { UserGame, UserProfile } from "@types"; import { 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 {
@@ -14,10 +13,11 @@ import {
} 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 { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; import { PersonIcon, PlusIcon, TelescopeIcon } 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-signout-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@@ -32,7 +32,13 @@ export function UserContent({
}: ProfileContentProps) { }: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile"); const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails(); const {
userDetails,
profileBackground,
signOut,
updateFriendRequests,
showFriendsModal,
} = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
@@ -72,6 +78,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,6 +92,10 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe) updateFriendRequests();
}, [isMe]);
const profileContentBoxBackground = useMemo(() => { const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground; if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */ /* TODO: Render background colors for other users */
@@ -216,9 +230,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 +275,128 @@ 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 {(isMe ||
style={{ (userProfile.friends && userProfile.friends.length > 0)) && (
display: "grid", <div className={styles.friendsSection}>
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={() => showFriendsModal(UserFriendModalTab.FriendsList)}
onClick={() => handleGameClick(game)}
title={game.title}
> >
{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.friends.length}
</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)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
</> </>

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,8 +77,6 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({ export const profileAvatar = style({
height: "100%", height: "100%",
width: "100%", width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover", objectFit: "cover",
}); });
@@ -86,14 +108,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 +160,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 +192,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",

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

@@ -3,6 +3,7 @@ import {
downloadSlice, downloadSlice,
windowSlice, windowSlice,
librarySlice, librarySlice,
collectionsSlice,
searchSlice, searchSlice,
userPreferencesSlice, userPreferencesSlice,
toastSlice, toastSlice,
@@ -15,6 +16,7 @@ export const store = configureStore({
search: searchSlice.reducer, search: searchSlice.reducer,
window: windowSlice.reducer, window: windowSlice.reducer,
library: librarySlice.reducer, library: librarySlice.reducer,
collections: collectionsSlice.reducer,
userPreferences: userPreferencesSlice.reducer, userPreferences: userPreferencesSlice.reducer,
download: downloadSlice.reducer, download: downloadSlice.reducer,
toast: toastSlice.reducer, toast: toastSlice.reducer,

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;
@@ -134,6 +136,12 @@ export interface Game {
export type LibraryGame = Omit<Game, "repacks">; export type LibraryGame = Omit<Game, "repacks">;
export interface Collection {
id: number;
title: string;
games: Game[];
}
export interface GameRunning { export interface GameRunning {
id: number; id: number;
title: string; title: string;
@@ -269,14 +277,27 @@ export interface UserDetails {
profileImageUrl: string | null; profileImageUrl: string | null;
} }
export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
}
export interface FriendRequest {
id: string;
displayName: string;
profileImageUrl: string | null;
type: "SENT" | "RECEIVED";
}
export interface UserProfile { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
username: string;
profileImageUrl: string | null; profileImageUrl: string | null;
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
friends: UserFriend[];
} }
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"