Compare commits

...

53 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
Chubby Granny Chaser
6fce60f9f7 ci: increasing version 2024-07-05 17:10:04 +01:00
Chubby Granny Chaser
c8aa9fd681 Merge branch 'main' of github.com:hydralauncher/hydra 2024-07-05 17:09:43 +01:00
Chubby Granny Chaser
0f12dfae88 ci: increasing version 2024-07-05 17:08:27 +01:00
54 changed files with 1511 additions and 184 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.0.2",
"version": "2.0.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -40,6 +40,7 @@
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",

View File

@@ -194,6 +194,19 @@
"found_download_option_other": "Found {{countFormatted}} download options",
"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": {
"download_complete": "Download complete",
"game_ready_to_install": "{{title}} is ready to install",
@@ -241,6 +254,15 @@
"successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out",
"playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?"
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
"add_friends": "Add Friends",
"add": "Add",
"friend_code": "Friend code",
"see_profile": "See profile",
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"friends_list": "Friends list",
"user_not_found": "User not found"
}
}

View File

@@ -48,7 +48,7 @@
"download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el {{updated_at}}",
"updated_at": "Actualizado el: {{updated_at}}",
"install": "Instalar",
"resume": "Continuar",
"pause": "Pausa",
@@ -74,7 +74,7 @@
"remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles",
"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}}",
"next_suggestion": "Siguiente sugerencia",
"play": "Jugar",
@@ -107,8 +107,8 @@
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
"downloads_secion_title": "Descargas",
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
"danger_zone_section_title": "Zona de Peligro",
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra",
"danger_zone_section_title": "Opciones Avanzadas",
"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_paused": "Descarga pausada",
"last_downloaded_option": "Última opción descargada",
@@ -138,7 +138,7 @@
"deleting": "Eliminando instalador…",
"delete": "Eliminar instalador",
"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",
"download_in_progress": "En progreso",
"queued_downloads": "Descargas en cola",
@@ -200,7 +200,8 @@
"repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido",
"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": {
"open": "Abrir Hydra",
@@ -223,13 +224,13 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado {{period}}",
"last_time_played": "Última vez jugado: {{period}}",
"activity": "Actividad reciente",
"library": "Biblioteca",
"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!",
"display_name": "Nombre a mostrar",
"display_name": "Nombre en pantalla",
"saving": "Guardando",
"save": "Guardar",
"edit_profile": "Editar perfil",
@@ -240,6 +241,15 @@
"successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}",
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?"
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
"add_friends": "Añadir amigos",
"add": "Añadir",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amigos",
"friends_list": "Lista de amigos",
"user_not_found": "Usuario no encontrado"
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { DataSource } from "typeorm";
import {
Collection,
DownloadQueue,
DownloadSource,
Game,
@@ -19,6 +20,7 @@ export const createDataSource = (
new DataSource({
type: "better-sqlite3",
entities: [
Collection,
Game,
Repack,
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 "./download-source.entity";
export * from "./download-queue.entity";
export * from "./collection.entity";
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/search-games";
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 "./library/add-game-to-library";
import "./library/create-game-shortcut";
@@ -43,8 +48,11 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/update-friend-request";
import "./profile/update-profile";
import "./profile/send-friend-request";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { dataSource } from "./data-source";
import {
Collection,
DownloadQueue,
DownloadSource,
Game,
@@ -24,3 +25,5 @@ export const downloadSourceRepository =
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
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 {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@@ -45,6 +45,8 @@ export class HydraApi {
expirationTimestamp: tokenExpirationTimestamp,
};
logger.log("Sign in received", this.userAuth);
await userAuthRepository.upsert(
{
id: 1,
@@ -74,7 +76,7 @@ export class HydraApi {
return request;
},
(error) => {
logger.log("request error", error);
logger.error("request error", error);
return Promise.reject(error);
}
);
@@ -95,12 +97,18 @@ export class HydraApi {
const { config } = error;
logger.error(config.method, config.baseURL, config.url, config.headers);
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error(error.response.status, error.response.data);
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error(error.request);
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
@@ -146,6 +154,8 @@ export class HydraApi {
this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
logger.log("Token refreshed", this.userAuth);
userAuthRepository.upsert(
{
id: 1,
@@ -170,6 +180,8 @@ export class HydraApi {
private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) {
logger.error("401 - Current credentials:", this.userAuth);
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
@@ -190,6 +202,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired();
return this.instance
.get(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
@@ -199,6 +212,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired();
return this.instance
.post(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
@@ -208,6 +222,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired();
return this.instance
.put(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
@@ -217,6 +232,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired();
return this.instance
.patch(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
@@ -226,6 +242,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired();
return this.instance
.delete(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
}

View File

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

View File

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

View File

@@ -9,6 +9,9 @@ import type {
AppUpdaterEvent,
StartGameDownloadPayload,
GameRunning,
Collection,
Game,
FriendRequestAction,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@@ -102,6 +105,16 @@ contextBridge.exposeInMainWorld("electron", {
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 */
getDiskFreeSpace: (path: string) =>
ipcRenderer.invoke("getDiskFreeSpace", path),
@@ -136,6 +149,11 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>
ipcRenderer.invoke("sendFriendRequest", userId),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ export function AutoUpdateSubHeader() {
<header className={styles.subheader}>
<Link to={releasesPageUrl} className={styles.newVersionLink}>
<SyncIcon className={styles.newVersionIcon} size={12} />
{t("version_available_download", { version: newVersion })}
{t("version_available_download", { version: newVersion })}
</Link>
</header>
);
@@ -63,7 +63,7 @@ export function AutoUpdateSubHeader() {
onClick={handleClickInstallUpdate}
>
<SyncIcon className={styles.newVersionIcon} size={12} />
{t("version_available_install", { version: newVersion })}
{t("version_available_install", { version: newVersion })}
</button>
</header>
);

View File

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

View File

@@ -1,17 +1,20 @@
import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react";
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function SidebarProfile() {
const navigate = useNavigate();
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails();
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@@ -30,46 +33,64 @@ export function SidebarProfile() {
}, [profileBackground]);
return (
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleButtonClick}
<div
className={styles.profileContainer}
style={assignInlineVars({
[profileContainerBackground]: profileButtonBackground,
})}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
{userDetails?.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={userDetails.profileImageUrl}
alt={userDetails.displayName}
/>
) : (
<PersonIcon />
)}
</div>
<button
type="button"
className={styles.profileButton}
onClick={handleButtonClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
{userDetails?.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={userDetails.profileImageUrl}
alt={userDetails.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")}
</p>
<div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")}
</p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)}
</div>
{userDetails && gameRunning && (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)}
</div>
</button>
</button>
{userDetails && friendRequests.length > 0 && !gameRunning && (
<div className={styles.friendRequestContainer}>
<button
type="button"
className={styles.friendRequestButton}
onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)}
>
<PersonAddIcon size={24} />
{friendRequests.length}
</button>
</div>
)}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import { useCollections } from "@renderer/hooks/use-collections";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -25,6 +26,7 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
export function Sidebar() {
const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary();
const { collections, updateCollections } = useCollections();
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
@@ -33,6 +35,7 @@ export function Sidebar() {
const [sidebarWidth, setSidebarWidth] = useState(
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
);
const [showCollections, setShowCollections] = useState(true);
const location = useLocation();
@@ -46,7 +49,8 @@ export function Sidebar() {
useEffect(() => {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
updateCollections();
}, [lastPacket?.game.id, updateLibrary, updateCollections]);
const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1
@@ -67,18 +71,27 @@ export function Sidebar() {
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
const val = event.target.value.toLocaleLowerCase();
setFilteredLibrary(
sortedLibrary.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
sortedLibrary.filter((game) => game.title.toLowerCase().includes(val))
);
setShowCollections(val == "");
};
useEffect(() => {
setFilteredLibrary(sortedLibrary);
}, [sortedLibrary]);
setFilteredLibrary(
sortedLibrary.filter(
(game) =>
!collections.some((collection) =>
collection.games.some(
(collectionGame) => collectionGame.id == game.id
)
)
)
);
}, [sortedLibrary, collections]);
useEffect(() => {
window.onmousemove = (event: MouseEvent) => {
@@ -199,6 +212,58 @@ export function Sidebar() {
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}>
{filteredLibrary.map((game) => (
<li

View File

@@ -14,6 +14,9 @@ import type {
RealDebridUser,
DownloadSource,
UserProfile,
Collection,
FriendRequest,
FriendRequestAction,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@@ -78,6 +81,13 @@ declare global {
) => () => 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 */
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
@@ -132,6 +142,12 @@ declare global {
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
}
interface Window {

View File

@@ -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 "./user-details-slice";
export * from "./running-game-slice";
export * from "./collections-slice";

View File

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

View File

@@ -4,3 +4,4 @@ export * from "./use-date";
export * from "./use-toast";
export * from "./redux";
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 { 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 { UserDetails } from "@types";
import { FriendRequestAction, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() {
const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector(
(state) => state.userDetails
);
const {
userDetails,
profileBackground,
friendRequests,
isFriendsModalVisible,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
@@ -78,13 +89,56 @@ export function useUserDetails() {
[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 {
userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
showFriendsModal,
hideFriendsModal,
fetchUserDetails,
signOut,
clearUserDetails,
updateUserDetails,
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 { useDownload, useToast } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { CollectionsModal } from "./collections-modal";
export interface GameOptionsModalProps {
visible: boolean;
@@ -19,7 +20,7 @@ export function GameOptionsModal({
game,
onClose,
}: GameOptionsModalProps) {
const { t } = useTranslation("game_details");
const { t } = useTranslation(["game_details", "collections"]);
const { showSuccessToast, showErrorToast } = useToast();
@@ -28,6 +29,7 @@ export function GameOptionsModal({
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const [showCollectionsModal, setShowCollectionsModal] = useState(false);
const {
removeGameInstaller,
@@ -107,6 +109,29 @@ export function GameOptionsModal({
large={true}
>
<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}>
<h2>{t("executable_section_title")}</h2>
<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 cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import {
@@ -14,10 +13,11 @@ import {
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
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 { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@@ -32,7 +32,13 @@ export function UserContent({
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails();
const {
userDetails,
profileBackground,
signOut,
updateFriendRequests,
showFriendsModal,
} = useUserDetails();
const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
@@ -72,6 +78,10 @@ export function UserContent({
setShowEditProfileModal(true);
};
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => {
await signOut();
@@ -82,6 +92,10 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe) updateFriendRequests();
}, [isMe]);
const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */
@@ -216,9 +230,11 @@ export function UserContent({
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
{isMe && (
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
)}
</div>
) : (
<div
@@ -259,55 +275,128 @@ export function UserContent({
)}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div className={styles.contentSidebar}>
<div className={styles.profileGameSection}>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
>
<h2>{t("library")}</h2>
<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>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
{(isMe ||
(userProfile.friends && userProfile.friends.length > 0)) && (
<div className={styles.friendsSection}>
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
className={styles.friendsSectionHeader}
onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
<h2>{t("friends")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.friends.length}
</h3>
</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>
</>

View File

@@ -11,6 +11,7 @@ export const wrapper = style({
export const profileContentBox = style({
display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
@@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
zIndex: 1,
});
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatarEditContainer = style({
width: "128px",
height: "128px",
@@ -53,8 +77,6 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({
height: "100%",
width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
});
@@ -86,14 +108,36 @@ export const profileContent = style({
export const profileGameSection = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSectionHeader = style({
fontSize: vars.size.body,
color: vars.color.body,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
":hover": {
color: vars.color.muted,
},
});
export const contentSidebar = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": {
"(min-width: 768px)": {
width: "100%",
@@ -116,12 +160,17 @@ export const libraryGameIcon = style({
borderRadius: "4px",
});
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "hidden",
height: "72px",
transition: "all ease 0.2s",
cursor: "pointer",
@@ -143,6 +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({
display: "flex",
flexDirection: "column",

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ export type GameStatus =
export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre {
id: string;
name: string;
@@ -134,6 +136,12 @@ export interface Game {
export type LibraryGame = Omit<Game, "repacks">;
export interface Collection {
id: number;
title: string;
games: Game[];
}
export interface GameRunning {
id: number;
title: string;
@@ -269,14 +277,27 @@ export interface UserDetails {
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 {
id: string;
displayName: string;
username: string;
profileImageUrl: string | null;
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];
recentGames: UserGame[];
friends: UserFriend[];
}
export interface DownloadSource {

View File

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