Merge pull request #950 from hydralauncher/feature/profile-redesign

Feature/profile redesign
This commit is contained in:
Zamitto 2024-09-14 17:57:41 -03:00 committed by GitHub
commit 3d132de860
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 3452 additions and 2411 deletions

View File

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.0.3",
"version": "2.1.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@ -35,6 +35,7 @@
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.0.22",
"@hookform/resolvers": "^3.9.0",
"@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
@ -64,6 +65,7 @@
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
@ -72,6 +74,7 @@
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1",
"yup": "^1.4.0",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -260,11 +260,6 @@
"request_accepted": "Žádost přijata",
"user_blocked_successfully": "Uživatel úspěšně zablokován",
"user_block_modal_text": "Tohle zablokuje {{displayName}}",
"settings": "Nastavení",
"public": "Veřejné",
"private": "Soukromé",
"friends_only": "Pouze přátelé",
"privacy": "Soukromí",
"blocked_users": "Zablokovaní uživatelé",
"unblock": "Odblokovat",
"no_friends_added": "Nemáš přidané žádné přátele",

View File

@ -260,11 +260,6 @@
"request_accepted": "Anfrage akzeptiert",
"user_blocked_successfully": "Nutzer erfolgreich blockiert",
"user_block_modal_text": "{{displayName}} wird dadurch blockiert",
"settings": "Einstellungen",
"public": "Öffentlich",
"private": "Privat",
"friends_only": "Nur Freunde",
"privacy": "Privatsphäre",
"blocked_users": "Blockierte Nutzer",
"unblock": "Freigeben",
"no_friends_added": "Du hast noch keine Freunde hinzugefügt",

View File

@ -8,7 +8,9 @@
"trending": "Trending",
"surprise_me": "Surprise me",
"no_results": "No results found",
"start_typing": "Starting typing to search..."
"start_typing": "Starting typing to search...",
"hot": "🔥 Hot now",
"weekly": "📅 Top games of the week"
},
"sidebar": {
"catalogue": "Catalogue",
@ -22,7 +24,8 @@
"home": "Home",
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in"
"sign_in": "Sign in",
"friends": "Friends"
},
"header": {
"search": "Search games",
@ -115,7 +118,19 @@
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option",
"create_shortcut_success": "Shortcut created successfully",
"create_shortcut_error": "Error creating shortcut"
"create_shortcut_error": "Error creating shortcut",
"nsfw_content_title": "This game contains innapropriate content",
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
"allow_nsfw_content": "Continue",
"refuse_nsfw_content": "Go back",
"stats": "Stats",
"download_count": "Downloads",
"player_count": "Active players",
"download_error": "This download option is not available",
"download": "Download",
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
"warning": "Warning:",
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress."
},
"activation": {
"title": "Activate Hydra",
@ -191,7 +206,18 @@
"found_download_option_zero": "No download option found",
"found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import"
"import": "Import",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"profile_visibility": "Profile visibility",
"profile_visibility_description": "Choose who can see your profile and library",
"required_field": "This field is required",
"source_already_exists": "This source has been already added",
"must_be_valid_url": "The source must be a valid URL",
"blocked_users": "Blocked users",
"user_unblocked": "User has been unblocked"
},
"notifications": {
"download_complete": "Download complete",
@ -261,11 +287,6 @@
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}",
"settings": "Settings",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You still don't have added friends",
@ -274,6 +295,22 @@
"no_blocked_users": "You have no blocked users",
"friend_code_copied": "Friend code copied",
"undo_friendship_modal_text": "This will undo your friendship with {{displayName}}",
"image_process_failure": "Failure while processing the image"
"privacy_hint": "To adjust who can see this, go to the <0>Settings</0>",
"locked_profile": "This profile is private",
"image_process_failure": "Failure while processing the image",
"required_field": "This field is required",
"displayname_min_length": "Display name must be at least 3 characters long",
"displayname_max_length": "Display name must be at most 50 characters long",
"report_profile": "Report this profile",
"report_reason": "Why are you reporting this profile?",
"report_description": "Additional information",
"report_description_placeholder": "Additional information",
"report": "Report",
"report_reason_hate": "Hate speech",
"report_reason_sexual_content": "Sexual content",
"report_reason_violence": "Violence",
"report_reason_spam": "Spam",
"report_reason_other": "Other",
"profile_reported": "Profile reported"
}
}

View File

@ -8,6 +8,8 @@
"trending": "Tendencias",
"surprise_me": "¡Sorpréndeme!",
"no_results": "No se encontraron resultados",
"hot": "🔥 Caliente ahora",
"weekly": "📅 Los mejores juegos de la semana",
"start_typing": "Empieza a escribir para buscar..."
},
"sidebar": {
@ -22,7 +24,8 @@
"home": "Inicio",
"queued": "{{title}} (En Cola)",
"game_has_no_executable": "El juego no tiene un ejecutable",
"sign_in": "Iniciar sesión"
"sign_in": "Iniciar sesión",
"friends": "Amigos"
},
"header": {
"search": "Buscar juegos",
@ -115,7 +118,17 @@
"download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción descargada",
"create_shortcut_success": "Atajo creado con éxito",
"create_shortcut_error": "Error al crear un atajo"
"create_shortcut_error": "Error al crear un atajo",
"allow_nsfw_content": "Continuar",
"download": "Descargar",
"download_count": "Descargas",
"download_error": "Esta opción de descarga no está disponible.",
"executable_path_in_use": "Ejecutable ya en uso por \"{{game}}\"",
"nsfw_content_description": "{{title}} incluye contenido que puede no ser adecuado para todas las edades. \n¿Estás seguro de que quieres continuar?",
"nsfw_content_title": "Este juego contiene contenido inapropiado.",
"player_count": "Jugadores activos",
"refuse_nsfw_content": "Volver",
"stats": "Estadísticas"
},
"activation": {
"title": "Activar Hydra",
@ -191,7 +204,21 @@
"found_download_option_zero": "No se encontró una opción de descarga",
"found_download_option_one": "Se encontró {{countFormatted}} opción de descarga",
"found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga",
"import": "Importar"
"import": "Importar",
"blocked_users": "Usuarios bloqueados",
"download_options_one": "",
"download_options_other": "",
"download_options_zero": "",
"friends_only": "solo amigos",
"must_be_valid_url": "La fuente debe ser una URL válida.",
"privacy": "Privacidad",
"private": "Privado",
"profile_visibility": "Visibilidad del perfil",
"profile_visibility_description": "Elige quién puede ver tu perfil y biblioteca",
"public": "Público",
"required_field": "Este campo es obligatorio",
"source_already_exists": "Esta fuente ya ha sido agregada.",
"user_unblocked": "El usuario ha sido desbloqueado"
},
"notifications": {
"download_complete": "Descarga completada",
@ -261,11 +288,6 @@
"request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"settings": "Ajustes",
"public": "Público",
"private": "Privado",
"friends_only": "Solo Amigos",
"privacy": "Privacidad",
"blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Todavía no tienes amigos añadidos",
@ -274,6 +296,23 @@
"no_blocked_users": "No has bloqueado a ningún usuario",
"friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}",
"displayname_max_length": "El nombre para mostrar debe tener como máximo 50 caracteres",
"displayname_min_length": "El nombre para mostrar debe tener al menos 3 caracteres",
"locked_profile": "Este perfil es privado.",
"privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración</0>.",
"profile_locked": "",
"profile_reported": "Perfil reportado",
"report": "Informe",
"report_description": "Información adicional",
"report_description_placeholder": "Información adicional",
"report_profile": "Reportar este perfil",
"report_reason": "¿Por qué estás denunciando este perfil?",
"report_reason_hate": "Discurso de odio",
"report_reason_other": "Otro",
"report_reason_sexual_content": "Contenido sexual",
"report_reason_spam": "Correo basura",
"report_reason_violence": "Violencia",
"required_field": "Este campo es obligatorio",
"image_process_failure": "Error al procesar la imagen"
}
}

View File

@ -6,6 +6,8 @@
"home": {
"featured": "Destaques",
"trending": "Populares",
"hot": "🔥 Populares agora",
"weekly": "📅 Mais baixados da semana",
"surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado",
"start_typing": "Comece a digitar para pesquisar…"
@ -22,7 +24,8 @@
"home": "Início",
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login"
"sign_in": "Login",
"friends": "Amigos"
},
"header": {
"search": "Buscar jogos",
@ -111,7 +114,19 @@
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada",
"create_shortcut_success": "Atalho criado com sucesso",
"create_shortcut_error": "Erro ao criar atalho"
"create_shortcut_error": "Erro ao criar atalho",
"nsfw_content_title": "Este jogo contém conteúdo inapropriado",
"nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?",
"allow_nsfw_content": "Continuar",
"refuse_nsfw_content": "Voltar",
"stats": "Estatísticas",
"download_count": "Downloads",
"player_count": "Jogadores ativos",
"download_error": "Essa opção de download falhou",
"download": "Baixar",
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
},
"activation": {
"title": "Ativação",
@ -190,7 +205,18 @@
"found_download_option_zero": "Nenhuma opção de download encontrada",
"found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar"
"import": "Importar",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"profile_visibility": "Visibilidade do perfil",
"profile_visibility_description": "Escolha quem pode ver seu perfil e biblioteca",
"required_field": "Este campo é obrigatório",
"source_already_exists": "Essa fonte já foi adicionada",
"must_be_valid_url": "A fonte deve ser uma URL válida",
"blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado"
},
"notifications": {
"download_complete": "Download concluído",
@ -241,7 +267,7 @@
"cancel": "Cancelar",
"successfully_signed_out": "Deslogado com sucesso",
"sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?",
"sign_out_modal_title": "Deseja mesmo sair?",
"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?",
"add_friends": "Adicionar Amigos",
@ -264,11 +290,6 @@
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"settings": "Configurações",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
@ -277,6 +298,23 @@
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
"friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}",
"image_process_failure": "Falha ao processar a imagem"
"privacy_hint": "Pra controlar quem pode ver seu perfil, acesse a <0>Tela de Configurações</0>",
"profile_locked": "Este perfil é privado",
"image_process_failure": "Falha ao processar a imagem",
"required_field": "Este campo é obrigatório",
"displayname_min_length": "Nome de exibição deve ter pelo menos 3 caracteres",
"displayname_max_length": "Nome de exibição deve ter no máximo 50 caracteres",
"locked_profile": "Este perfil é privado",
"report_profile": "Reportar este perfil",
"report_reason": "Por que você deseja reportar este perfil?",
"report_description": "Informações adicionais",
"report_description_placeholder": "Insira aqui",
"report": "Reportar",
"report_reason_hate": "Discurso de ódio",
"report_reason_sexual_content": "Conteúdo sexual",
"report_reason_violence": "Violência",
"report_reason_spam": "Spam",
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado"
}
}

View File

@ -111,7 +111,11 @@
"download_paused": "Transferência pausada",
"last_downloaded_option": "Última opção transferida",
"create_shortcut_success": "Atalho criado com sucesso",
"create_shortcut_error": "Erro ao criar atalho"
"create_shortcut_error": "Erro ao criar atalho",
"download": "Transferir",
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
},
"activation": {
"title": "Ativação",
@ -169,8 +173,6 @@
"save_changes": "Guardar alterações",
"changes_saved": "Definições guardadas com sucesso",
"download_sources_description": "O Hydra vai procurar links de transferência em todas as fontes ativadas. A URL da página de detalhes da loja não é guardada no seu dispositivo. Utilizamos um sistema de metadados criado pela comunidade para fornecer suporte a mais fontes de transferência de jogos.",
"enable_source": "Ativar",
"disable_source": "Desativar",
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
@ -266,11 +268,6 @@
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Utilizador bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"settings": "Definições",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"blocked_users": "Utilizadores bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Ainda não adicionaste amigos",

View File

@ -7,7 +7,10 @@
"featured": "Рекомендованное",
"trending": "В тренде",
"surprise_me": "Удиви меня",
"no_results": "Ничего не найдено"
"no_results": "Ничего не найдено",
"hot": "🔥 Сейчас жарко",
"start_typing": "Начинаю вводить текст для поиска...",
"weekly": "📅 Лучшие игры недели"
},
"sidebar": {
"catalogue": "Каталог",
@ -21,7 +24,8 @@
"home": "Главная",
"queued": "{{title}} (В очереди)",
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти"
"sign_in": "Войти",
"friends": "Друзья"
},
"header": {
"search": "Поиск",
@ -114,7 +118,17 @@
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
"create_shortcut_success": "Ярлык создан",
"create_shortcut_error": "Не удалось создать ярлык"
"create_shortcut_error": "Не удалось создать ярлык",
"allow_nsfw_content": "Продолжать",
"download": "Скачать",
"download_count": "Загрузки",
"download_error": "Этот вариант загрузки недоступен",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
"nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?",
"nsfw_content_title": "Эта игра содержит неприемлемый контент",
"player_count": "Активные игроки",
"refuse_nsfw_content": "Возвращаться",
"stats": "Статистика"
},
"activation": {
"title": "Активировать Hydra",
@ -190,7 +204,21 @@
"found_download_option_zero": "Не найдено вариантов загрузки",
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать"
"import": "Импортировать",
"blocked_users": "Заблокированные пользователи",
"download_options_one": "",
"download_options_other": "",
"download_options_zero": "",
"friends_only": "Только друзья",
"must_be_valid_url": "Источник должен быть действительным URL-адресом.",
"privacy": "Конфиденциальность",
"private": "Частный",
"profile_visibility": "Видимость профиля",
"profile_visibility_description": "Выберите, кто может видеть ваш профиль и библиотеку",
"public": "Общественный",
"required_field": "Это поле обязательно к заполнению",
"source_already_exists": "Этот источник уже добавлен",
"user_unblocked": "Пользователь разблокирован"
},
"notifications": {
"download_complete": "Загрузка завершена",
@ -260,17 +288,31 @@
"request_accepted": "Запрос принят",
"user_blocked_successfully": "Пользователь успешно заблокирован",
"user_block_modal_text": "{{displayName}} будет заблокирован",
"settings": "Настройки",
"public": "Публичный",
"private": "Приватный",
"friends_only": "Только друзья",
"privacy": "Приватность",
"blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга",
"pending": "Ожидание",
"no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
"friend_code_copied": "Код друга скопирован"
"friend_code_copied": "Код друга скопирован",
"displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.",
"displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.",
"image_process_failure": "Сбой при обработке изображения",
"locked_profile": "Этот профиль является частным",
"privacy_hint": "Чтобы указать, кто может это видеть, перейдите в <0>Настройки</0>.",
"profile_locked": "",
"profile_reported": "Профиль сообщил",
"report": "Отчет",
"report_description": "Дополнительная информация",
"report_description_placeholder": "Дополнительная информация",
"report_profile": "Пожаловаться на этот профиль",
"report_reason": "Почему вы жалуетесь на этот профиль?",
"report_reason_hate": "Разжигание ненависти",
"report_reason_other": "Другой",
"report_reason_sexual_content": "Сексуальный контент",
"report_reason_spam": "Спам",
"report_reason_violence": "Насилие",
"required_field": "Это поле обязательно к заполнению",
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}."
}
}

View File

@ -1,6 +1,11 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services";
import {
DownloadManager,
HydraApi,
PythonInstance,
gamesPlaytime,
} from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity";
@ -23,6 +28,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
/* Removes user from Sentry */
Sentry.setUser(null);
/* Cancels any ongoing downloads */
DownloadManager.cancelDownload();
/* Disconnects libtorrent */
PythonInstance.killTorrent();

View File

@ -1,36 +1,44 @@
import { getSteamAppAsset } from "@main/helpers";
import type { CatalogueEntry, GameShop } from "@types";
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { RepacksManager, requestSteam250 } from "@main/services";
import { formatName } from "@shared";
import { HydraApi, RepacksManager } from "@main/services";
import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared";
import { steamGamesWorker } from "@main/workers";
const resultSize = 12;
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const params = new URLSearchParams({
take: "12",
skip: "0",
});
const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
const trendingGames = await requestSteam250("/90day");
const results: CatalogueEntry[] = [];
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
`/games/${category}?${params.toString()}`,
{},
{ needsAuth: false }
);
for (let i = 0; i < resultSize; i++) {
if (!trendingGames[i]) {
i++;
continue;
}
return Promise.all(
response.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const { title, objectID } = trendingGames[i]!;
const repacks = RepacksManager.search({ query: formatName(title) });
const repacks = RepacksManager.search({
query: formatName(steamGame.name),
});
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
results.push({ ...catalogueEntry, repacks });
}
return results;
return {
title: steamGame.name,
shop: game.shop,
repacks,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
};
})
);
};
registerEvent("getCatalogue", getCatalogue);

View File

@ -0,0 +1,23 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameStats } from "@types";
const getGameStats = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
const response = await HydraApi.get<GameStats>(
`/games/stats?${params.toString()}`
);
return response;
};
registerEvent("getGameStats", getGameStats);

View File

@ -1,8 +1,8 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { getSteamAppAsset } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
query?: string;
@ -16,7 +16,7 @@ export const convertSteamGameToCatalogueEntry = (
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
cover: steamUrlBuilder.library(String(game.id)),
repacks: [],
});

View File

@ -8,6 +8,7 @@ import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
@ -21,6 +22,7 @@ import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/verify-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/open-external";
@ -44,10 +46,12 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/get-user-blocks";
import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-user-stats";
import "./user/report-user";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";

View File

@ -3,10 +3,11 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -32,7 +33,7 @@ const addGameToLibrary = async (
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
@ -53,7 +54,7 @@ const addGameToLibrary = async (
const game = await gameRepository.findOne({ where: { objectID } });
createGame(game!);
createGame(game!).catch(() => {});
});
};

View File

@ -0,0 +1,13 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const verifyExecutablePathInUse = async (
_event: Electron.IpcMainInvokeEvent,
executablePath: string
) => {
return gameRepository.findOne({
where: { executablePath },
});
};
registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse);

View File

@ -1,15 +1,33 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
import { UserProfile } from "@types";
import { userAuthRepository } from "@main/repository";
import { UserNotLoggedInError } from "@shared";
import { steamUrlBuilder, UserNotLoggedInError } from "@shared";
import { steamGamesWorker } from "@main/workers";
const getSteamGame = async (objectId: string) => {
try {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
return {
title: steamGame.name,
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
};
} catch (err) {
logger.error("Failed to get Steam game", err);
return null;
}
};
const getMe = async (
_event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`)
.then((me) => {
.then(async (me) => {
userAuthRepository.upsert(
{
id: 1,
@ -20,6 +38,17 @@ const getMe = async (
["id"]
);
if (me.currentGame) {
const steamGame = await getSteamGame(me.currentGame.objectId);
if (steamGame) {
me.currentGame = {
...me.currentGame,
...steamGame,
};
}
}
Sentry.setUser({ id: me.id, username: me.username });
return me;

View File

@ -2,7 +2,7 @@ import { registerEvent } from "../register-event";
import { HydraApi, PythonInstance } from "@main/services";
import fs from "node:fs";
import path from "node:path";
import type { UpdateProfileProps, UserProfile } from "@types";
import type { UpdateProfileRequest, UserProfile } from "@types";
import { omit } from "lodash-es";
import axios from "axios";
@ -11,7 +11,7 @@ interface PresignedResponse {
profileImageUrl: string;
}
const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch<UserProfile>("/profile", updateProfile);
};
@ -40,7 +40,7 @@ const getNewProfileImageUrl = async (localImageUrl: string) => {
const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
updateProfile: UpdateProfileProps
updateProfile: UpdateProfileRequest
) => {
if (!updateProfile.profileImageUrl) {
return patchUserProfile(omit(updateProfile, "profileImageUrl"));

View File

@ -1,18 +1,15 @@
import {
downloadQueueRepository,
gameRepository,
repackRepository,
} from "@main/repository";
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { getFileBase64 } from "@main/helpers";
import { DownloadManager } from "@main/services";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, Repack } from "@main/entity";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -21,88 +18,95 @@ const startGameDownload = async (
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
payload;
const [game, repack] = await Promise.all([
gameRepository.findOne({
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
const repackRepository = transactionalEntityManager.getRepository(Repack);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
shop,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
await DownloadManager.pauseDownload();
await gameRepository.update(
{ status: "active", progress: Not(1) },
{ status: "paused" }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: "active",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
isDeleted: false,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
return result;
});
}
const updatedGame = await gameRepository.findOne({
where: {
objectID,
shop,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
await DownloadManager.pauseDownload();
await gameRepository.update(
{ status: "active", progress: Not(1) },
{ status: "paused" }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: "active",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
isDeleted: false,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
: null;
createGame(updatedGame!).catch(() => {});
await gameRepository
.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!);
return result;
});
}
const updatedGame = await gameRepository.findOne({
where: {
objectID,
},
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
});
createGame(updatedGame!);
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!);
};
registerEvent("startGameDownload", startGameDownload);

View File

@ -2,7 +2,7 @@ import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserBlocks } from "@types";
export const getUserBlocks = async (
export const getBlockedUsers = async (
_event: Electron.IpcMainInvokeEvent,
take: number,
skip: number
@ -10,4 +10,4 @@ export const getUserBlocks = async (
return HydraApi.get(`/profile/blocks`, { take, skip });
};
registerEvent("getUserBlocks", getUserBlocks);
registerEvent("getBlockedUsers", getBlockedUsers);

View File

@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserStats } from "@types";
export const getUserStats = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserStats> => {
return HydraApi.get(`/users/${userId}/stats`);
};
registerEvent("getUserStats", getUserStats);

View File

@ -1,76 +1,81 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { GameRunning, UserGame, UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
import type { UserProfile } from "@types";
import { steamUrlBuilder } from "@shared";
const getSteamGame = async (objectId: string) => {
try {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
return {
title: steamGame.name,
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
};
} catch (err) {
logger.error("Failed to get Steam game", err);
return null;
}
};
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserProfile | null> => {
try {
const [profile, friends] = await Promise.all([
HydraApi.get(`/users/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
if (!profile) return null;
const recentGames = await Promise.all(
profile.recentGames.map(async (game) => {
return getSteamUserGame(game);
})
profile.recentGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => {
return getSteamUserGame(game);
})
profile.libraryGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
const currentGame = await getGameRunning(profile.currentGame);
if (profile.currentGame) {
const steamGame = await getSteamGame(profile.currentGame.objectId);
if (steamGame) {
profile.currentGame = {
...profile.currentGame,
...steamGame,
};
}
}
return {
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
currentGame,
};
} catch (err) {
console.log(err);
return null;
}
};
const getGameRunning = async (currentGame): Promise<GameRunning | null> => {
if (!currentGame) {
return null;
}
const gameRunning = await getSteamUserGame(currentGame);
return {
...gameRunning,
sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000,
};
};
const getSteamUserGame = async (game): Promise<UserGame> => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
};
registerEvent("getUser", getUser);

View File

@ -0,0 +1,16 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
export const reportUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
reason: string,
description: string
): Promise<void> => {
return HydraApi.post(`/users/${userId}/report`, {
reason,
description,
});
};
registerEvent("reportUser", reportUser);

View File

@ -2,23 +2,6 @@ import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
objectID: string,
clientIcon?: string
) => {
if (category === "library")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
if (category === "hero")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
if (category === "logo")
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
};
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
@ -34,15 +17,6 @@ export const getFileBase64 = async (url: string) =>
})
);
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -2,12 +2,13 @@ import knex, { Knex } from "knex";
import { databasePath } from "./constants";
import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3";
import { RepackUris } from "./migrations/20240830143906_RepackUris";
import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language";
export type HydraMigration = Knex.Migration & { name: string };
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
getMigrations(): Promise<HydraMigration[]> {
return Promise.resolve([Hydra2_0_3, RepackUris]);
return Promise.resolve([Hydra2_0_3, RepackUris, UpdateUserLanguage]);
}
getMigrationName(migration: HydraMigration): string {
return migration.name;

View File

@ -0,0 +1,13 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const UpdateUserLanguage: HydraMigration = {
name: "UpdateUserLanguage",
up: async (knex: Knex) => {
await knex("user_preferences")
.update("language", "pt-BR")
.where("language", "pt");
},
down: async (_knex: Knex) => {},
};

View File

@ -11,6 +11,7 @@ import { GenericHttpDownloader } from "./generic-http-downloader";
export class DownloadManager {
private static currentDownloader: Downloader | null = null;
private static downloadingGameId: number | null = null;
public static async watchDownloads() {
let status: DownloadProgress | null = null;
@ -76,13 +77,14 @@ export class DownloadManager {
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
this.downloadingGameId = null;
}
static async resumeDownload(game: Game) {
return this.startDownload(game);
}
static async cancelDownload(gameId: number) {
static async cancelDownload(gameId = this.downloadingGameId!) {
if (this.currentDownloader === Downloader.Torrent) {
PythonInstance.cancelDownload(gameId);
} else if (this.currentDownloader === Downloader.RealDebrid) {
@ -93,6 +95,7 @@ export class DownloadManager {
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
this.downloadingGameId = null;
}
static async startDownload(game: Game) {
@ -131,5 +134,6 @@ export class DownloadManager {
}
this.currentDownloader = game.downloader;
this.downloadingGameId = game.id;
}
}

View File

@ -81,54 +81,54 @@ export class HydraApi {
baseURL: import.meta.env.MAIN_VITE_API_URL,
});
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.params, request.data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
// this.instance.interceptors.request.use(
// (request) => {
// logger.log(" ---- REQUEST -----");
// logger.log(request.method, request.url, request.params, request.data);
// return request;
// },
// (error) => {
// logger.error("request error", error);
// return Promise.reject(error);
// }
// );
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
logger.log(
response.status,
response.config.method,
response.config.url,
response.data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
// this.instance.interceptors.response.use(
// (response) => {
// logger.log(" ---- RESPONSE -----");
// logger.log(
// response.status,
// response.config.method,
// response.config.url,
// response.data
// );
// return response;
// },
// (error) => {
// logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
// const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
// logger.error(
// config.method,
// config.baseURL,
// config.url,
// config.headers,
// config.data
// );
if (error.response) {
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
// if (error.response) {
// logger.error("Response", error.response.status, error.response.data);
// } else if (error.request) {
// logger.error("Request", error.request);
// } else {
// logger.error("Error", error.message);
// }
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
// logger.error(" ----- END RESPONSE ERROR -------");
// return Promise.reject(error);
// }
// );
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },

View File

@ -1,21 +1,31 @@
import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository";
import { logger } from "../logger";
export const createGame = async (game: Game) => {
HydraApi.post(`/profile/games`, {
HydraApi.post(
"/games/download",
{
objectId: game.objectID,
shop: game.shop,
},
{ needsAuth: false }
).catch((err) => {
logger.error("Failed to create game download", err);
});
return HydraApi.post(`/profile/games`, {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
})
.then((response) => {
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
}).then((response) => {
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
gameRepository.update(
{ objectID: game.objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
})
.catch(() => {});
gameRepository.update(
{ objectID: game.objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
};

View File

@ -1,7 +1,7 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers";
import { steamUrlBuilder } from "@shared";
export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games")
@ -44,7 +44,7 @@ export const mergeWithRemoteGames = async () => {
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({

View File

@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
deltaInMillis: number,
lastTimePlayed: Date
) => {
HydraApi.put(`/profile/games/${game.remoteId}`, {
return HydraApi.put(`/profile/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
}).catch(() => {});
});
};

View File

@ -81,3 +81,5 @@ export const publishNotificationUpdateReadyToInstall = async (
icon: trayIcon,
}).show();
};
export const publishNewFriendRequestNotification = async () => {};

View File

@ -70,9 +70,9 @@ function onOpenGame(game: Game) {
});
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date());
updateGamePlaytime(game, 0, new Date()).catch(() => {});
} else {
createGame({ ...game, lastTimePlayed: new Date() });
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
}
@ -93,20 +93,22 @@ function onTickGame(game: Game) {
});
if (currentTick % TICKS_TO_UPDATE_API === 0) {
if (game.remoteId) {
updateGamePlaytime(
game,
now - gamePlaytime.lastSyncTick,
game.lastTimePlayed!
);
} else {
createGame(game);
}
const gamePromise = game.remoteId
? updateGamePlaytime(
game,
now - gamePlaytime.lastSyncTick,
game.lastTimePlayed!
)
: createGame(game);
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastSyncTick: now,
});
gamePromise
.then(() => {
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastSyncTick: now,
});
})
.catch(() => {});
}
}
@ -119,8 +121,8 @@ const onCloseGame = (game: Game) => {
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
).catch(() => {});
} else {
createGame(game);
createGame(game).catch(() => {});
}
};

View File

@ -10,8 +10,9 @@ import type {
StartGameDownloadPayload,
GameRunning,
FriendRequestAction,
UpdateProfileProps,
UpdateProfileRequest,
} from "@types";
import type { CatalogueCategory } from "@shared";
contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
@ -34,7 +35,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: () => ipcRenderer.invoke("getCatalogue"),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@ -44,6 +46,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGames", take, prevCursor),
searchGameRepacks: (query: string) =>
ipcRenderer.invoke("searchGameRepacks", query),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
/* User preferences */
@ -71,6 +75,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string) =>
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
@ -139,7 +145,7 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (updateProfile: UpdateProfileProps) =>
updateProfile: (updateProfile: UpdateProfileRequest) =>
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
@ -155,8 +161,11 @@ contextBridge.exposeInMainWorld("electron", {
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip),
getUserBlocks: (take: number, skip: number) =>
ipcRenderer.invoke("getUserBlocks", take, skip),
getBlockedUsers: (take: number, skip: number) =>
ipcRenderer.invoke("getBlockedUsers", take, skip),
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description),
/* Auth */
signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -1,6 +1,13 @@
import { ComplexStyleRule, globalStyle, style } from "@vanilla-extract/css";
import {
ComplexStyleRule,
createContainer,
globalStyle,
style,
} from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
export const appContainer = createContainer();
globalStyle("*", {
boxSizing: "border-box",
});
@ -90,6 +97,8 @@ export const container = style({
overflow: "hidden",
display: "flex",
flexDirection: "column",
containerName: appContainer,
containerType: "inline-size",
});
export const content = style({

View File

@ -58,11 +58,11 @@ export const button = styleVariants({
danger: [
base,
{
border: `solid 1px #a31533`,
backgroundColor: "transparent",
color: "white",
borderColor: "transparent",
backgroundColor: "#a31533",
color: "#c0c1c7",
":hover": {
backgroundColor: "#a31533",
backgroundColor: "#b3203f",
},
},
],

View File

@ -0,0 +1,13 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const actions = style({
display: "flex",
alignSelf: "flex-end",
gap: `${SPACING_UNIT * 2}px`,
});
export const descriptionText = style({
fontSize: "16px",
lineHeight: "24px",
});

View File

@ -0,0 +1,48 @@
import { Button } from "../button/button";
import { Modal, type ModalProps } from "../modal/modal";
import * as styles from "./confirmation-modal.css";
export interface ConfirmationModalProps extends Omit<ModalProps, "children"> {
confirmButtonLabel: string;
cancelButtonLabel: string;
descriptionText: string;
onConfirm: () => void;
onCancel?: () => void;
}
export function ConfirmationModal({
confirmButtonLabel,
cancelButtonLabel,
descriptionText,
onConfirm,
onCancel,
...props
}: ConfirmationModalProps) {
const handleCancelClick = () => {
if (onCancel) {
onCancel();
return;
}
props.onClose();
};
return (
<Modal {...props}>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p className={styles.descriptionText}>{descriptionText}</p>
<div className={styles.actions}>
<Button theme="outline" onClick={handleCancelClick}>
{cancelButtonLabel}
</Button>
<Button theme="danger" onClick={onConfirm}>
{confirmButtonLabel}
</Button>
</div>
</div>
</Modal>
);
}

View File

@ -1,11 +1,13 @@
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
import type { CatalogueEntry } from "@types";
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -22,12 +24,29 @@ const shopIcon = {
export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
);
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
setStats(stats);
});
}
}, [game, stats]);
const { numberFormatter } = useFormat();
return (
<button {...props} type="button" className={styles.card}>
<button
{...props}
type="button"
className={styles.card}
onMouseEnter={handleHover}
>
<div className={styles.backdrop}>
<img src={game.cover} alt={game.title} className={styles.cover} />
@ -48,19 +67,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
) : (
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
)}
<div className={styles.specifics}>
<div className={styles.specificsItem}>
<DownloadIcon />
<span>{game.repacks.length}</span>
<span>
{stats ? numberFormatter.format(stats.downloadCount) : "…"}
</span>
</div>
{game.repacks.length > 0 && (
<div className={styles.specificsItem}>
<FileDirectoryIcon />
<span>{game.repacks.at(0)?.fileSize}</span>
</div>
)}
<div className={styles.specificsItem}>
<PeopleIcon />
<span>
{stats ? numberFormatter.format(stats?.playerCount) : "…"}
</span>
</div>
</div>
</div>
</div>

View File

@ -39,7 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/user")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);

View File

@ -48,6 +48,9 @@ export function Hero() {
/>
<div className={styles.content}>
{game.logo && (
<img src={game.logo} width="250px" alt={game.description} />
)}
<p className={styles.description}>{game.description}</p>
</div>
</div>

View File

@ -11,3 +11,4 @@ export * from "./link/link";
export * from "./select-field/select-field";
export * from "./toast/toast";
export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";

View File

@ -1,21 +1,13 @@
import { createVar, style } from "@vanilla-extract/css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainerBackground = createVar();
export const profileContainer = style({
background: profileContainerBackground,
position: "relative",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
});
export const profileButton = style({
@ -25,20 +17,24 @@ export const profileButton = style({
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
height: "40px",
width: "100%",
});
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "50%",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -56,17 +52,6 @@ export const profileButtonInformation = style({
minWidth: 0,
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
@ -77,14 +62,31 @@ export const profileButtonTitle = style({
whiteSpace: "nowrap",
});
export const friendRequestButton = style({
color: vars.color.success,
export const friendsButton = style({
color: vars.color.muted,
cursor: "pointer",
borderRadius: "50%",
overflow: "hidden",
width: "40px",
minWidth: "40px",
minHeight: "40px",
height: "40px",
backgroundColor: vars.color.background,
position: "relative",
transition: "all ease 0.3s",
":hover": {
color: vars.color.muted,
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendsButtonBadge = style({
backgroundColor: vars.color.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
height: "20px",
borderRadius: "50%",
position: "absolute",
top: "-5px",
right: "-5px",
});

View File

@ -1,60 +1,79 @@
import { useNavigate } from "react-router-dom";
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import { PeopleIcon, 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 { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
const LONG_POLLING_INTERVAL = 10_000;
export function SidebarProfile() {
const navigate = useNavigate();
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
const { userDetails, friendRequests, showFriendsModal, fetchFriendRequests } =
useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
useEffect(() => {
setReceivedRequests(
friendRequests.filter((request) => request.type === "RECEIVED")
);
}, [friendRequests]);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => {
const receivedRequests = useMemo(() => {
return friendRequests.filter((request) => request.type === "RECEIVED");
}, [friendRequests]);
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow();
return;
}
navigate(`/user/${userDetails!.id}`);
navigate(`/profile/${userDetails!.id}`);
};
const profileButtonBackground = useMemo(() => {
if (profileBackground) return profileBackground;
return undefined;
}, [profileBackground]);
useEffect(() => {
pollingInterval.current = setInterval(() => {
fetchFriendRequests();
}, LONG_POLLING_INTERVAL);
const showPendingRequests =
userDetails && receivedRequests.length > 0 && !gameRunning;
return () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
};
}, [fetchFriendRequests]);
const friendsButton = useMemo(() => {
if (!userDetails) return null;
return (
<button
type="button"
className={styles.friendsButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{receivedRequests.length > 0 && (
<small className={styles.friendsButtonBadge}>
{receivedRequests.length > 99 ? "99+" : receivedRequests.length}
</small>
)}
<PeopleIcon size={16} />
</button>
);
}, [userDetails, t, receivedRequests, showFriendsModal]);
return (
<div
className={styles.profileContainer}
style={assignInlineVars({
[profileContainerBackground]: profileButtonBackground,
})}
>
<div className={styles.profileContainer}>
<button
type="button"
className={styles.profileButton}
onClick={handleButtonClick}
onClick={handleProfileClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
@ -75,34 +94,31 @@ export function SidebarProfile() {
</p>
{userDetails && gameRunning && (
<div>
<div
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
width: "100%",
}}
>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning?.iconUrl && (
{userDetails && gameRunning && (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
src={gameRunning.iconUrl!}
/>
)}
</div>
</button>
{showPendingRequests && (
<button
type="button"
className={styles.friendRequestButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
>
<PersonAddIcon size={24} />
{receivedRequests.length}
</button>
)}
{friendsButton}
</div>
);
}

View File

@ -21,27 +21,26 @@ export const sidebar = recipe({
pointerEvents: "none",
},
},
},
});
export const content = recipe({
base: {
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "auto",
},
variants: {
macos: {
darwin: {
true: {
paddingTop: `${SPACING_UNIT * 6}px`,
},
false: {
paddingTop: `${SPACING_UNIT * 2}px`,
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "auto",
});
export const handle = style({
width: "5px",
height: "100%",

View File

@ -23,6 +23,8 @@ const SIDEBAR_MAX_WIDTH = 450;
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
export function Sidebar() {
const filterRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary();
const navigate = useNavigate();
@ -78,6 +80,10 @@ export function Sidebar() {
useEffect(() => {
setFilteredLibrary(sortedLibrary);
if (filterRef.current) {
filterRef.current.value = "";
}
}, [sortedLibrary]);
useEffect(() => {
@ -139,7 +145,7 @@ export function Sidebar() {
navigate(path);
}
if (event.detail == 2) {
if (event.detail === 2) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
} else {
@ -149,98 +155,93 @@ export function Sidebar() {
};
return (
<>
<aside
ref={sidebarRef}
className={styles.sidebar({ resizing: isResizing })}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
}}
>
<SidebarProfile />
<aside
ref={sidebarRef}
className={styles.sidebar({
resizing: isResizing,
darwin: window.electron.platform === "darwin",
})}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
}}
>
<SidebarProfile />
<div
className={styles.content({
macos: window.electron.platform === "darwin",
})}
>
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<TextField
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
<ul className={styles.menu}>
{filteredLibrary.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)}
>
<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} />
)}
{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>
</div>
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
/>
</aside>
</>
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
/>
</aside>
);
}

View File

@ -22,16 +22,6 @@ export const textField = recipe({
minHeight: "40px",
},
variants: {
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
theme: {
primary: {
backgroundColor: vars.color.darkBackground,
@ -40,11 +30,21 @@ export const textField = recipe({
backgroundColor: vars.color.background,
},
},
state: {
error: {
hasError: {
true: {
borderColor: vars.color.danger,
},
},
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
},
});
@ -83,3 +83,7 @@ export const textFieldWrapper = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const errorLabel = style({
color: vars.color.danger,
});

View File

@ -1,9 +1,11 @@
import React, { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./text-field.css";
import type { FieldError, FieldErrorsImpl, Merge } from "react-hook-form";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import * as styles from "./text-field.css";
export interface TextFieldProps
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
@ -21,71 +23,105 @@ export interface TextFieldProps
HTMLDivElement
>;
rightContent?: React.ReactNode | null;
state?: NonNullable<RecipeVariants<typeof styles.textField>>["state"];
error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
}
export function TextField({
theme = "primary",
label,
hint,
textFieldProps,
containerProps,
rightContent = null,
state,
...props
}: TextFieldProps) {
const id = useId();
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
(
{
theme = "primary",
label,
hint,
textFieldProps,
containerProps,
rightContent = null,
error,
...props
},
ref
) => {
const id = useId();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const { t } = useTranslation("forms");
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
return (
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
const hintContent = useMemo(() => {
if (error && error.message)
return (
<small className={styles.errorLabel}>{error.message as string}</small>
);
<div className={styles.textFieldWrapper}>
<div
className={styles.textField({ focused: isFocused, theme, state })}
{...textFieldProps}
>
<input
id={id}
className={styles.textFieldInput({ readOnly: props.readOnly })}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
type={inputType}
/>
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>
{isPasswordVisible ? (
<EyeClosedIcon size={16} />
) : (
<EyeIcon size={16} />
)}
</button>
)}
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
if (props.onFocus) props.onFocus(event);
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
if (props.onBlur) props.onBlur(event);
};
const hasError = !!error;
return (
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
<div className={styles.textFieldWrapper}>
<div
className={styles.textField({
theme,
hasError,
focused: isFocused,
})}
{...textFieldProps}
>
<input
ref={ref}
id={id}
className={styles.textFieldInput({ readOnly: props.readOnly })}
{...props}
onFocus={handleFocus}
onBlur={handleBlur}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>
{isPasswordVisible ? (
<EyeClosedIcon size={16} />
) : (
<EyeIcon size={16} />
)}
</button>
)}
</div>
{rightContent}
</div>
{rightContent}
{hintContent}
</div>
);
}
);
{hint && <small>{hint}</small>}
</div>
);
}
TextField.displayName = "TextField";

View File

@ -9,3 +9,5 @@ export const DOWNLOADER_NAME = {
[Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@ -5,10 +5,17 @@ import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
import type {
Game,
GameRepack,
GameShop,
GameStats,
ShopDetails,
} from "@types";
import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@ -22,11 +29,14 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
gameColor: "",
showRepacksModal: false,
showGameOptionsModal: false,
stats: null,
hasNSFWContentBlocked: false,
setGameColor: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {},
setShowGameOptionsModal: () => {},
setShowRepacksModal: () => {},
setHasNSFWContentBlocked: () => {},
});
const { Provider } = gameDetailsContext;
@ -41,9 +51,12 @@ export function GameDetailsContextProvider({
}: GameDetailsContextProps) {
const { objectID, shop } = useParams();
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
const [stats, setStats] = useState<GameStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [gameColor, setGameColor] = useState("");
@ -85,13 +98,25 @@ export function GameDetailsContextProvider({
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
window.electron.getGameStats(objectID!, shop as GameShop),
])
.then(([appDetailsResult, repacksResult]) => {
if (appDetailsResult.status === "fulfilled")
setGameDetails(appDetailsResult.value);
.then(([appDetailsResult, repacksResult, statsResult]) => {
if (appDetailsResult.status === "fulfilled") {
setShopDetails(appDetailsResult.value);
if (
appDetailsResult.value!.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
}
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
})
.finally(() => {
setIsLoading(false);
@ -101,7 +126,7 @@ export function GameDetailsContextProvider({
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
useEffect(() => {
setGameDetails(null);
setShopDetails(null);
setGame(null);
setIsLoading(true);
setisGameRunning(false);
@ -167,6 +192,9 @@ export function GameDetailsContextProvider({
gameColor,
showGameOptionsModal,
showRepacksModal,
stats,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
setGameColor,
selectGameExecutable,
updateGame,

View File

@ -1,4 +1,10 @@
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
import type {
Game,
GameRepack,
GameShop,
GameStats,
ShopDetails,
} from "@types";
export interface GameDetailsContext {
game: Game | null;
@ -12,9 +18,12 @@ export interface GameDetailsContext {
gameColor: string;
showRepacksModal: boolean;
showGameOptionsModal: boolean;
stats: GameStats | null;
hasNSFWContentBlocked: boolean;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
}

View File

@ -1,2 +1,3 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";

View File

@ -1,8 +1,8 @@
import { createContext, useEffect, useState } from "react";
import { createContext, useCallback, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import type { UserPreferences } from "@types";
import type { UserBlocks, UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom";
export interface SettingsContext {
@ -11,6 +11,8 @@ export interface SettingsContext {
clearSourceUrl: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>;
}
export const settingsContext = createContext<SettingsContext>({
@ -19,6 +21,8 @@ export const settingsContext = createContext<SettingsContext>({
clearSourceUrl: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
blockedUsers: [],
fetchBlockedUsers: async () => {},
});
const { Provider } = settingsContext;
@ -35,6 +39,8 @@ export function SettingsContextProvider({
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
@ -48,6 +54,15 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks);
}, []);
useEffect(() => {
fetchBlockedUsers();
}, [fetchBlockedUsers]);
const clearSourceUrl = () => setSourceUrl(null);
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
@ -63,8 +78,10 @@ export function SettingsContextProvider({
updateUserPreferences,
setCurrentCategoryIndex,
clearSourceUrl,
fetchBlockedUsers,
currentCategoryIndex,
sourceUrl,
blockedUsers,
}}
>
{children}

View File

@ -0,0 +1,110 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { UserProfile, UserStats } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export interface UserProfileContext {
userProfile: UserProfile | null;
heroBackground: string;
/* Indicates if the current user is viewing their own profile */
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
export const userProfileContext = createContext<UserProfileContext>({
userProfile: null,
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
isMe: false,
userStats: null,
getUserProfile: async () => {},
});
const { Provider } = userProfileContext;
export const { Consumer: UserProfileContextConsumer } = userProfileContext;
export interface UserProfileContextProviderProps {
children: React.ReactNode;
userId: string;
}
export function UserProfileContextProvider({
children,
userId,
}: UserProfileContextProviderProps) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
);
const getHeroBackgroundFromImageUrl = async (imageUrl: string) => {
const output = await average(imageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`;
};
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const getUserStats = useCallback(async () => {
window.electron.getUserStats(userId).then((stats) => {
setUserStats(stats);
});
}, [userId]);
const getUserProfile = useCallback(async () => {
getUserStats();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
useEffect(() => {
setUserProfile(null);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
}, [getUserProfile]);
return (
<Provider
value={{
userProfile,
heroBackground,
isMe: userDetails?.id === userProfile?.id,
getUserProfile,
userStats,
}}
>
{children}
</Provider>
);
}

View File

@ -1,3 +1,4 @@
import type { CatalogueCategory } from "@shared";
import type {
AppUpdaterEvent,
CatalogueEntry,
@ -18,7 +19,10 @@ import type {
FriendRequestAction,
UserFriends,
UserBlocks,
UpdateProfileRequest,
GameStats,
TrendingGame,
UserStats,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -40,7 +44,7 @@ declare global {
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: () => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
@ -57,6 +61,7 @@ declare global {
prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
/* Library */
@ -67,6 +72,7 @@ declare global {
) => Promise<void>;
createGameShortcut: (id: number) => Promise<boolean>;
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
@ -138,11 +144,20 @@ declare global {
take: number,
skip: number
) => Promise<UserFriends>;
getUserBlocks: (take: number, skip: number) => Promise<UserBlocks>;
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
getUserStats: (userId: string) => Promise<UserStats>;
reportUser: (
userId: string,
reason: string,
description: string
) => Promise<void>;
/* Profile */
getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>;
updateProfile: (
updateProfile: UpdateProfileRequest
) => Promise<UserProfile>;
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
processProfileImage: (
path: string

View File

@ -1,9 +1,9 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
import type { FriendRequest, UserProfile } from "@types";
export interface UserDetailsState {
userDetails: UserDetails | null;
userDetails: UserProfile | null;
profileBackground: null | string;
friendRequests: FriendRequest[];
isFriendsModalVisible: boolean;
@ -24,7 +24,7 @@ export const userDetailsSlice = createSlice({
name: "user-details",
initialState,
reducers: {
setUserDetails: (state, action: PayloadAction<UserDetails | null>) => {
setUserDetails: (state, action: PayloadAction<UserProfile | null>) => {
state.userDetails = action.payload;
},
setProfileBackground: (state, action: PayloadAction<string | null>) => {

View File

@ -1,16 +1,6 @@
import type { GameShop } from "@types";
import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export const formatDownloadProgress = (progress?: number) => {
if (!progress) return "0%";
@ -46,14 +36,3 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const profileBackgroundFromProfileImage = async (
profileImageUrl: string
) => {
const output = await average(profileImageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
};

View File

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

View File

@ -25,11 +25,10 @@ export function useDownload() {
const startDownload = async (payload: StartGameDownloadPayload) => {
dispatch(clearDownload());
return window.electron.startGameDownload(payload).then((game) => {
updateLibrary();
const game = await window.electron.startGameDownload(payload);
return game;
});
await updateLibrary();
return game;
};
const pauseDownload = async (gameId: number) => {

View File

@ -0,0 +1,14 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export function useFormat() {
const { i18n } = useTranslation();
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
return { numberFormatter };
}

View File

View File

@ -7,10 +7,12 @@ import {
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
import type {
FriendRequestAction,
UpdateProfileRequest,
UserProfile,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { logger } from "@renderer/logger";
export function useUserDetails() {
const dispatch = useAppDispatch();
@ -38,17 +40,18 @@ export function useUserDetails() {
}, [clearUserDetails]);
const updateUserDetails = useCallback(
async (userDetails: UserDetails) => {
async (userDetails: UserProfile) => {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const profileBackground = await profileBackgroundFromProfileImage(
userDetails.profileImageUrl
).catch((err) => {
logger.error("profileBackgroundFromProfileImage", err);
return `#151515B3`;
});
dispatch(setProfileBackground(profileBackground));
// TODO: Decide if we want to use this
// const profileBackground = await profileBackgroundFromProfileImage(
// userDetails.profileImageUrl
// ).catch((err) => {
// logger.error("profileBackgroundFromProfileImage", err);
// return `#151515B3`;
// });
// dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
@ -64,7 +67,7 @@ export function useUserDetails() {
);
}
},
[dispatch]
[dispatch, profileBackground]
);
const fetchUserDetails = useCallback(async () => {
@ -78,14 +81,14 @@ export function useUserDetails() {
}, [clearUserDetails]);
const patchUser = useCallback(
async (props: UpdateProfileProps) => {
const response = await window.electron.updateProfile(props);
async (values: UpdateProfileRequest) => {
const response = await window.electron.updateProfile(values);
return updateUserDetails(response);
},
[updateUserDetails]
);
const fetchFriendRequests = useCallback(() => {
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
@ -124,13 +127,10 @@ export function useUserDetails() {
[fetchFriendRequests]
);
const undoFriendship = (userId: string) => {
return window.electron.undoFriendship(userId);
};
const undoFriendship = (userId: string) =>
window.electron.undoFriendship(userId);
const blockUser = (userId: string) => {
return window.electron.blockUser(userId);
};
const blockUser = (userId: string) => window.electron.blockUser(userId);
const unblockUser = (userId: string) => {
return window.electron.unblockUser(userId);

View File

@ -22,12 +22,12 @@ import {
SearchResults,
Settings,
Catalogue,
Profile,
} from "@renderer/pages";
import { store } from "./store";
import resources from "@locales";
import { User } from "./pages/user/user";
Sentry.init({});
@ -41,8 +41,14 @@ i18n
escapeValue: false,
},
})
.then(() => {
window.electron.updateUserPreferences({ language: i18n.language });
.then(async () => {
const userPreferences = await window.electron.getUserPreferences();
if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);
} else {
window.electron.updateUserPreferences({ language: i18n.language });
}
});
ReactDOM.createRoot(document.getElementById("root")!).render(
@ -57,7 +63,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/user/:userId" Component={User} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
</Routes>
</HashRouter>

View File

@ -6,10 +6,9 @@ import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";

View File

@ -2,8 +2,6 @@ import { useContext, useEffect, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { steamUrlBuilder } from "@renderer/helpers";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
@ -12,6 +10,7 @@ import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
const HERO_ANIMATION_THRESHOLD = 25;
@ -22,8 +21,14 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const { objectID, shopDetails, game, gameColor, setGameColor } =
useContext(gameDetailsContext);
const {
objectID,
shopDetails,
game,
gameColor,
setGameColor,
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
@ -65,7 +70,7 @@ export function GameDetailsContent() {
};
return (
<div className={styles.wrapper}>
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
@ -94,7 +99,7 @@ export function GameDetailsContent() {
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
className={styles.gameLogo}
alt={game?.title}
/>
</div>

View File

@ -43,7 +43,7 @@ export function GameDetailsSkeleton() {
</div>
</div>
<div className={sidebarStyles.contentSidebar}>
<div className={sidebarStyles.contentSidebarTitle}>
{/* <div className={sidebarStyles.contentSidebarTitle}>
<h3>HowLongToBeat</h3>
</div>
<ul className={sidebarStyles.howLongToBeatCategoriesList}>
@ -53,7 +53,7 @@ export function GameDetailsSkeleton() {
className={sidebarStyles.howLongToBeatCategorySkeleton}
/>
))}
</ul>
</ul> */}
<div
className={sidebarStyles.contentSidebarTitle}
style={{ border: "none" }}

View File

@ -1,6 +1,7 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 300;
@ -9,12 +10,22 @@ export const slideIn = keyframes({
"100%": { transform: "translateY(0)" },
});
export const wrapper = style({
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
height: "100%",
export const wrapper = recipe({
base: {
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
height: "100%",
transition: "all ease 0.3s",
},
variants: {
blurredContent: {
true: {
filter: "blur(20px)",
},
},
},
});
export const hero = style({
@ -68,6 +79,11 @@ export const heroImage = style({
},
});
export const gameLogo = style({
width: 300,
alignSelf: "flex-end",
});
export const heroImageSkeleton = style({
height: "300px",
"@media": {

View File

@ -3,7 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { GameRepack, GameShop, Steam250Game } from "@types";
import { Button } from "@renderer/components";
import { Button, ConfirmationModal } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -83,6 +83,8 @@ export function GameDetails() {
shop,
showRepacksModal,
showGameOptionsModal,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
updateGame,
setShowRepacksModal,
setShowGameOptionsModal,
@ -107,6 +109,11 @@ export function GameDetails() {
setShowGameOptionsModal(false);
};
const handleNSFWContentRefuse = () => {
setHasNSFWContentBlocked(false);
navigate(-1);
};
return (
<SkeletonTheme
baseColor={vars.color.background}
@ -120,6 +127,19 @@ export function GameDetails() {
onClose={() => setShowRepacksModal(false)}
/>
<ConfirmationModal
visible={hasNSFWContentBlocked}
onClose={handleNSFWContentRefuse}
title={t("nsfw_content_title")}
descriptionText={t("nsfw_content_description", {
title: gameTitle,
})}
confirmButtonLabel={t("allow_nsfw_content")}
cancelButtonLabel={t("refuse_nsfw_content")}
onConfirm={() => setHasNSFWContentBlocked(false)}
clickOutsideToClose={false}
/>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}

View File

@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "@renderer/context";
import { DownloadIcon } from "@renderer/components/sidebar/download-icon";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@ -25,6 +26,11 @@ export function HeroPanelActions() {
selectGameExecutable,
} = useContext(gameDetailsContext);
const { lastPacket } = useDownload();
const isGameDownloading =
game?.status === "active" && lastPacket?.game.id === game?.id;
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
@ -84,6 +90,47 @@ export function HeroPanelActions() {
</Button>
);
const gameActionButton = () => {
if (isGameRunning) {
return (
<Button
onClick={closeGame}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("close")}
</Button>
);
}
if (game?.executablePath) {
return (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
<PlayIcon />
{t("play")}
</Button>
);
}
return (
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading || !repacks.length}
className={styles.heroPanelAction}
>
<DownloadIcon isDownloading={false} />
{t("download")}
</Button>
);
};
if (repacks.length && !game) {
return (
<>
@ -96,26 +143,7 @@ export function HeroPanelActions() {
if (game) {
return (
<div className={styles.actions}>
{isGameRunning ? (
<Button
onClick={closeGame}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
<PlayIcon />
{t("play")}
</Button>
)}
{gameActionButton()}
<div className={styles.separator} />

View File

@ -2,19 +2,20 @@ import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css";
import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload } from "@renderer/hooks";
import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { i18n, t } = useTranslation("game_details");
const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload();
@ -30,13 +31,7 @@ export function HeroPanelPlaytime() {
}
}, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const formatPlayTime = () => {
const formattedPlayTime = useMemo(() => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
@ -49,7 +44,7 @@ export function HeroPanelPlaytime() {
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
}, [game?.playTimeInMilliseconds, numberFormatter, t]);
if (!game) return null;
@ -96,7 +91,7 @@ export function HeroPanelPlaytime() {
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
amount: formattedPlayTime,
})}
</p>

View File

@ -8,9 +8,9 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector } from "@renderer/hooks";
import { useAppSelector, useToast } from "@renderer/hooks";
export interface DownloadSettingsModalProps {
visible: boolean;
@ -31,6 +31,8 @@ export function DownloadSettingsModal({
}: DownloadSettingsModalProps) {
const { t } = useTranslation("game_details");
const { showErrorToast } = useToast();
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
const [selectedPath, setSelectedPath] = useState("");
const [downloadStarting, setDownloadStarting] = useState(false);
@ -104,10 +106,16 @@ export function DownloadSettingsModal({
if (repack) {
setDownloadStarting(true);
startDownload(repack, selectedDownloader!, selectedPath).finally(() => {
setDownloadStarting(false);
onClose();
});
startDownload(repack, selectedDownloader!, selectedPath)
.then(() => {
onClose();
})
.catch(() => {
showErrorToast(t("download_error"));
})
.finally(() => {
setDownloadStarting(false);
});
}
};
@ -121,15 +129,14 @@ export function DownloadSettingsModal({
onClose={onClose}
>
<div className={styles.container}>
<div>
<span
style={{
marginBottom: `${SPACING_UNIT}px`,
display: "block",
}}
>
{t("downloader")}
</span>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<span>{t("downloader")}</span>
<div className={styles.downloaders}>
{downloaders.map((downloader) => (
@ -152,6 +159,13 @@ export function DownloadSettingsModal({
</Button>
))}
</div>
{selectedDownloader && selectedDownloader !== Downloader.Torrent && (
<p style={{ marginTop: `${SPACING_UNIT}px` }}>
<span style={{ color: vars.color.warning }}>{t("warning")}</span>{" "}
{t("hydra_needs_to_remain_open")}
</p>
)}
</div>
<div

View File

@ -57,8 +57,17 @@ export function GameOptionsModal({
const path = await selectGameExecutable();
if (path) {
await window.electron.updateExecutablePath(game.id, path);
updateGame();
const gameUsingPath =
await window.electron.verifyExecutablePathInUse(path);
if (gameUsingPath) {
showErrorToast(
t("executable_path_in_use", { game: gameUsingPath.title })
);
return;
}
window.electron.updateExecutablePath(game.id, path).then(updateGame);
}
};

View File

@ -7,10 +7,6 @@ export const contentSidebar = style({
width: "100%",
height: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "200px",
},
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
@ -70,7 +66,7 @@ export const howLongToBeatCategory = style({
flexDirection: "column",
gap: "4px",
backgroundColor: vars.color.background,
borderRadius: "8px",
borderRadius: "4px",
padding: `8px 16px`,
border: `solid 1px ${vars.color.border}`,
});
@ -81,10 +77,39 @@ export const howLongToBeatCategoryLabel = style({
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "8px",
borderRadius: "4px",
height: "76px",
});
export const statsSection = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px`,
justifyContent: "space-between",
"@media": {
"(min-width: 1024px)": {
flexDirection: "column",
},
"(min-width: 1280px)": {
flexDirection: "row",
},
},
});
export const statsCategoryTitle = style({
fontSize: "14px",
fontWeight: "bold",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const statsCategory = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.body,

View File

@ -1,14 +1,15 @@
import { useContext, useEffect, useState } from "react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{
const [_howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
@ -16,10 +17,13 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
const { gameTitle, shopDetails, objectID, stats } =
useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
useEffect(() => {
if (objectID) {
setHowLongToBeat({ isLoading: true, data: null });
@ -37,14 +41,46 @@ export function Sidebar() {
return (
<aside className={styles.contentSidebar}>
<HowLongToBeatSection
{/* <HowLongToBeatSection
howLongToBeatData={howLongToBeat.data}
isLoading={howLongToBeat.isLoading}
/>
/> */}
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
<h3>{t("requirements")}</h3>
</div>
{stats && (
<>
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("stats")}</h3>
</div>
<div className={styles.statsSection}>
<div className={styles.statsCategory}>
<p className={styles.statsCategoryTitle}>
<DownloadIcon size={18} />
{t("download_count")}
</p>
<p>{numberFormatter.format(stats?.downloadCount)}</p>
</div>
<div className={styles.statsCategory}>
<p className={styles.statsCategoryTitle}>
<PeopleIcon size={18} />
{t("player_count")}
</p>
<p>{numberFormatter.format(stats?.playerCount)}</p>
</div>
</div>
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("requirements")}</h3>
</div>
</>
)}
<div className={styles.requirementButtonContainer}>
<Button

View File

@ -60,3 +60,11 @@ export const noResults = style({
gap: "16px",
gridColumn: "1 / -1",
});
export const buttonsList = style({
display: "flex",
listStyle: "none",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT}px`,
});

View File

@ -13,6 +13,7 @@ import * as styles from "./home.css";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
export function Home() {
const { t } = useTranslation("home");
@ -21,15 +22,25 @@ export function Home() {
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [catalogue, setCatalogue] = useState<CatalogueEntry[]>([]);
const [currentCatalogueCategory, setCurrentCatalogueCategory] = useState(
CatalogueCategory.Hot
);
const getCatalogue = useCallback(() => {
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
[CatalogueCategory.Hot]: [],
[CatalogueCategory.Weekly]: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setCurrentCatalogueCategory(category);
setIsLoading(true);
window.electron
.getCatalogue()
.getCatalogue(category)
.then((catalogue) => {
setCatalogue(catalogue);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
@ -56,13 +67,21 @@ export function Home() {
}
};
const handleCategoryClick = (category: CatalogueCategory) => {
if (category !== currentCatalogueCategory) {
getCatalogue(category);
}
};
useEffect(() => {
setIsLoading(true);
getCatalogue();
getCatalogue(CatalogueCategory.Hot);
getRandomGame();
}, [getCatalogue, getRandomGame]);
const categories = Object.values(CatalogueCategory);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
@ -71,7 +90,22 @@ export function Home() {
<Hero />
<section className={styles.homeHeader}>
<h2>{t("trending")}</h2>
<ul className={styles.buttonsList}>
{categories.map((category) => (
<li key={category}>
<Button
theme={
category === currentCatalogueCategory
? "primary"
: "outline"
}
onClick={() => handleCategoryClick(category)}
>
{t(category)}
</Button>
</li>
))}
</ul>
<Button
onClick={handleRandomizerClick}
@ -89,12 +123,14 @@ export function Home() {
</Button>
</section>
<h2>{t(currentCatalogueCategory)}</h2>
<section className={styles.cards}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue.map((result) => (
: catalogue[currentCatalogueCategory].map((result) => (
<GameCard
key={result.objectID}
game={result}

View File

@ -7,7 +7,7 @@ import type { DebouncedFunc } from "lodash";
import { debounce } from "lodash";
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
import { clearSearch } from "@renderer/features";
import { clearSearch, setSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@ -37,6 +37,10 @@ export function SearchResults() {
navigate(buildGameDetailsPath(game));
};
useEffect(() => {
dispatch(setSearch(searchParams.get("query") ?? ""));
}, [dispatch, searchParams]);
useEffect(() => {
setIsLoading(true);
if (debouncedFunc.current) debouncedFunc.current.cancel();

View File

@ -4,3 +4,4 @@ export * from "./downloads/downloads";
export * from "./home/search-results";
export * from "./settings/settings";
export * from "./catalogue/catalogue";
export * from "./profile/profile";

View File

@ -0,0 +1,45 @@
import { vars } from "../../../theme.css";
import { globalStyle, style } from "@vanilla-extract/css";
export const profileAvatarEditContainer = style({
alignSelf: "center",
width: "128px",
height: "128px",
display: "flex",
borderRadius: "4px",
color: vars.color.body,
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "4px",
overflow: "hidden",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.7)",
color: vars.color.muted,
zIndex: "1",
cursor: "pointer",
display: "flex",
justifyContent: "center",
transition: "all ease 0.2s",
alignItems: "center",
opacity: "0",
});
globalStyle(`${profileAvatarEditContainer}:hover ${profileAvatarEditOverlay}`, {
opacity: "1",
});

View File

@ -0,0 +1,185 @@
import { useContext, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import {
Button,
Link,
Modal,
ModalProps,
TextField,
} from "@renderer/components";
import { useAppSelector, useToast, useUserDetails } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import * as styles from "./edit-profile-modal.css";
import { userProfileContext } from "@renderer/context";
interface FormValues {
profileImageUrl?: string;
displayName: string;
}
export function EditProfileModal(
props: Omit<ModalProps, "children" | "title">
) {
const { t } = useTranslation("user_profile");
const schema = yup.object({
displayName: yup
.string()
.required(t("required_field"))
.min(3, t("displayname_min_length"))
.max(50, t("displayname_max_length")),
});
const {
register,
control,
setValue,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const { getUserProfile } = useContext(userProfileContext);
const { userDetails } = useAppSelector((state) => state.userDetails);
const { fetchUserDetails } = useUserDetails();
useEffect(() => {
if (userDetails) {
setValue("displayName", userDetails.displayName);
}
}, [setValue, userDetails]);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const onSubmit = async (values: FormValues) => {
return patchUser(values)
.then(async () => {
await Promise.allSettled([fetchUserDetails(), getUserProfile()]);
props.onClose();
showSuccessToast(t("saved_successfully"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<Modal {...props} title={t("edit_profile")} clickOutsideToClose={false}>
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
width: "350px",
}}
>
<div
style={{
gap: `${SPACING_UNIT * 3}px`,
display: "flex",
flexDirection: "column",
}}
>
<Controller
control={control}
name="profileImageUrl"
render={({ field: { value, onChange } }) => {
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
const { imagePath } = await window.electron
.processProfileImage(path)
.catch(() => {
showErrorToast(t("image_process_failure"));
return { imagePath: null };
});
onChange(imagePath);
}
};
const getImageUrl = () => {
if (value) return `local:${value}`;
if (userDetails?.profileImageUrl)
return userDetails.profileImageUrl;
return null;
};
const imageUrl = getImageUrl();
return (
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{imageUrl ? (
<img
className={styles.profileAvatar}
alt={userDetails?.displayName}
src={imageUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.profileAvatarEditOverlay}>
<DeviceCameraIcon size={38} />
</div>
</button>
);
}}
/>
<TextField
{...register("displayName")}
label={t("display_name")}
minLength={3}
maxLength={50}
containerProps={{ style: { width: "100%" } }}
error={errors.displayName}
/>
</div>
<small style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
<Trans i18nKey="privacy_hint" ns="user_profile">
<Link to="/settings" />
</Trans>
</small>
<Button
disabled={isSubmitting}
style={{ alignSelf: "end", marginTop: `${SPACING_UNIT * 3}px` }}
type="submit"
>
{isSubmitting ? t("saving") : t("save")}
</Button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,53 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./profile-content.css";
import { Link } from "@renderer/components";
import { PersonIcon } from "@primer/octicons-react";
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
if (!userProfile?.friends.length) return null;
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("friends")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.friendsCount)}</span>
)}
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li key={friend.id}>
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
{friend.profileImageUrl ? (
<img
src={friend.profileImageUrl!}
alt={friend.displayName}
className={styles.listItemImage}
/>
) : (
<div className={styles.defaultAvatarWrapper}>
<PersonIcon size={16} />
</div>
)}
<span className={styles.friendName}>{friend.displayName}</span>
</Link>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { SPACING_UNIT } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const container = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const lockIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});

View File

@ -0,0 +1,18 @@
import { LockIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import * as styles from "./locked-profile.css";
export function LockedProfile() {
const { t } = useTranslation("user_profile");
return (
<div className={styles.container}>
<div className={styles.lockIcon}>
<LockIcon size={24} />
</div>
<h2>{t("locked_profile")}</h2>
</div>
);
}

View File

@ -0,0 +1,189 @@
import { appContainer } from "../../../app.css";
import { vars, SPACING_UNIT } from "../../../theme.css";
import { globalStyle, style } from "@vanilla-extract/css";
export const gameCover = style({
transition: "all ease 0.2s",
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
width: "100%",
":before": {
content: "",
top: "0",
left: "0",
width: "100%",
height: "172%",
position: "absolute",
background:
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%);",
transition: "all ease 0.3s",
transform: "translateY(-36%)",
opacity: "0.5",
},
});
export const game = style({
transition: "all ease 0.2s",
":hover": {
transform: "scale(1.05)",
},
});
globalStyle(`${gameCover}:hover::before`, {
opacity: "1",
transform: "translateY(-20%)",
});
export const box = style({
backgroundColor: vars.color.background,
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
padding: `${SPACING_UNIT * 2}px`,
});
export const sectionHeader = style({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const list = style({
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friend = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
});
export const friendName = style({
color: vars.color.muted,
fontWeight: "bold",
fontSize: vars.size.body,
});
export const rightContent = style({
width: "100%",
height: "100%",
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "column",
transition: "all ease 0.2s",
"@media": {
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "400px",
},
},
});
export const listItem = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
textDecoration: "none",
},
});
export const gamesGrid = style({
listStyle: "none",
margin: "0",
padding: "0",
display: "grid",
gap: `${SPACING_UNIT * 2}px`,
gridTemplateColumns: "repeat(2, 1fr)",
"@container": {
[`${appContainer} (min-width: 900px)`]: {
gridTemplateColumns: "repeat(4, 1fr)",
},
[`${appContainer} (min-width: 1300px)`]: {
gridTemplateColumns: "repeat(5, 1fr)",
},
[`${appContainer} (min-width: 2000px)`]: {
gridTemplateColumns: "repeat(6, 1fr)",
},
[`${appContainer} (min-width: 2600px)`]: {
gridTemplateColumns: "repeat(8, 1fr)",
},
[`${appContainer} (min-width: 3000px)`]: {
gridTemplateColumns: "repeat(12, 1fr)",
},
},
});
export const telescopeIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noGames = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const listItemImage = style({
width: "32px",
height: "32px",
borderRadius: "4px",
});
export const listItemDetails = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
overflow: "hidden",
});
export const listItemTitle = style({
fontWeight: "bold",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
});
export const listItemDescription = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const defaultAvatarWrapper = style({
width: "32px",
height: "32px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
borderRadius: "4px",
});

View File

@ -0,0 +1,156 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserGame } from "@types";
import { buildGameDetailsPath } from "@renderer/helpers";
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
const dispatch = useAppDispatch();
const { t } = useTranslation("user_profile");
useEffect(() => {
dispatch(setHeaderTitle(""));
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
}
}, [userProfile, dispatch]);
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
});
const content = useMemo(() => {
if (!userProfile) return null;
const shouldLockProfile =
userProfile.profileVisibility === "PRIVATE" ||
(userProfile.profileVisibility === "FRIENDS" && !usersAreFriends);
if (!isMe && shouldLockProfile) {
return <LockedProfile />;
}
const hasGames = userProfile?.libraryGames.length > 0;
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
return (
<section
style={{
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 3}px`,
}}
>
<div style={{ flex: 1 }}>
{!hasGames && (
<div className={styles.noGames}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasGames && (
<>
<div className={styles.sectionHeader}>
<h2>{t("library")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<ul className={styles.gamesGrid}>
{userProfile?.libraryGames?.map((game) => (
<li
key={game.objectId}
style={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
}}
className={styles.game}
>
<button
type="button"
style={{
cursor: "pointer",
}}
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{
width: "100%",
objectFit: "cover",
borderRadius: 4,
}}
/>
</button>
</li>
))}
</ul>
</>
)}
</div>
{shouldShowRightContent && (
<div className={styles.rightContent}>
<RecentGamesBox />
<FriendsBox />
<ReportProfile />
</div>
)}
</section>
);
}, [
userProfile,
isMe,
usersAreFriends,
userStats,
numberFormatter,
t,
navigate,
]);
return (
<div>
<ProfileHero />
{content}
</div>
);
}

View File

@ -0,0 +1,80 @@
import { buildGameDetailsPath } from "@renderer/helpers";
import * as styles from "./profile-content.css";
import { Link } from "@renderer/components";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { ClockIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks";
import type { UserGame } from "@types";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function RecentGamesBox() {
const { userProfile } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = useCallback(
(game: UserGame) => {
const seconds = game?.playTimeInSeconds || 0;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
});
if (!userProfile?.recentGames.length) return null;
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("activity")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className={styles.listItem}
>
<img
src={game.iconUrl!}
alt={game.title}
className={styles.listItemImage}
/>
<div className={styles.listItemDetails}>
<span className={styles.listItemTitle}>{game.title}</span>
<div className={styles.listItemDescription}>
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
flexDirection: "column",
});
export const profileAvatarButton = style({
width: "96px",
minWidth: "96px",
height: "96px",
borderRadius: "4px",
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)",
cursor: "pointer",
transition: "all ease 0.3s",
color: vars.color.muted,
":hover": {
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.7)",
},
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
overflow: "hidden",
});
export const profileInformation = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
alignItems: "flex-start",
color: "#c0c1c7",
zIndex: 1,
overflow: "hidden",
});
export const profileDisplayName = style({
fontWeight: "bold",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
display: "flex",
alignItems: "center",
position: "relative",
});
export const heroPanel = style({
width: "100%",
height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
backdropFilter: `blur(10px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
backgroundColor: "rgba(0, 0, 0, 0.3)",
});
export const userInformation = style({
display: "flex",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
});
export const currentGameWrapper = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
});
export const currentGameDetails = style({
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
});

View File

@ -0,0 +1,347 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-hero.css";
import { useCallback, useContext, useMemo, useState } from "react";
import { userProfileContext } from "@renderer/context";
import {
BlockedIcon,
CheckCircleFillIcon,
PencilIcon,
PersonAddIcon,
PersonIcon,
SignOutIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDate,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { addSeconds } from "date-fns";
import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
type FriendAction =
| FriendRequestAction
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const { isMe, heroBackground, getUserProfile, userProfile } =
useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
sendFriendRequest,
undoFriendship,
blockUser,
} = useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const { t } = useTranslation("user_profile");
const { formatDistance } = useDate();
const { showSuccessToast, showErrorToast } = useToast();
const navigate = useNavigate();
const handleSignOut = useCallback(async () => {
setIsPerformingAction(true);
try {
await signOut();
showSuccessToast(t("successfully_signed_out"));
} finally {
setIsPerformingAction(false);
}
navigate("/");
}, [navigate, signOut, showSuccessToast, t]);
const handleFriendAction = useCallback(
async (userId: string, action: FriendAction) => {
if (!userProfile) return;
setIsPerformingAction(true);
try {
if (action === "UNDO_FRIENDSHIP") {
await undoFriendship(userId).then(getUserProfile);
return;
}
if (action === "BLOCK") {
await blockUser(userId).then(() => {
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
await sendFriendRequest(userProfile.id).then(getUserProfile);
return;
}
await updateFriendRequestState(userId, action).then(getUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
} finally {
setIsPerformingAction(false);
}
},
[
undoFriendship,
blockUser,
sendFriendRequest,
updateFriendRequestState,
t,
showErrorToast,
getUserProfile,
navigate,
showSuccessToast,
userProfile,
]
);
const profileActions = useMemo(() => {
if (!userProfile) return null;
if (isMe) {
return (
<>
<Button
theme="outline"
onClick={() => setShowEditProfileModal(true)}
disabled={isPerformingAction}
>
<PencilIcon />
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={handleSignOut}
disabled={isPerformingAction}
>
<SignOutIcon />
{t("sign_out")}
</Button>
</>
);
}
if (userProfile.relation == null) {
return (
<>
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
disabled={isPerformingAction}
>
<PersonAddIcon />
{t("add_friend")}
</Button>
<Button
theme="danger"
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
disabled={isPerformingAction}
>
<BlockedIcon />
{t("block_user")}
</Button>
</>
);
}
if (userProfile.relation.status === "ACCEPTED") {
return (
<>
<Button
theme="danger"
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
disabled={isPerformingAction}
>
<BlockedIcon />
{t("block_user")}
</Button>
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
}
disabled={isPerformingAction}
>
<XCircleFillIcon />
{t("undo_friendship")}
</Button>
</>
);
}
if (userProfile.relation.BId === userProfile.id) {
return (
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
disabled={isPerformingAction}
>
<XCircleFillIcon /> {t("cancel_request")}
</Button>
);
}
return (
<>
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
disabled={isPerformingAction}
>
<CheckCircleFillIcon /> {t("accept_request")}
</Button>
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
disabled={isPerformingAction}
>
<XCircleFillIcon /> {t("ignore_request")}
</Button>
</>
);
}, [
handleFriendAction,
handleSignOut,
isMe,
t,
isPerformingAction,
userProfile,
]);
const handleAvatarClick = useCallback(() => {
if (isMe) {
setShowEditProfileModal(true);
}
}, [isMe]);
const currentGame = useMemo(() => {
if (isMe) {
if (gameRunning)
return {
...gameRunning,
objectId: gameRunning.objectID,
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
};
return null;
}
return userProfile?.currentGame;
}, [isMe, userProfile, gameRunning]);
return (
<>
{/* <ConfirmationModal
visible
title={t("sign_out_modal_title")}
descriptionText={t("sign_out_modal_text")}
confirmButtonLabel={t("sign_out")}
cancelButtonLabel={t("cancel")}
/> */}
<EditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
/>
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
>
<div className={styles.userInformation}>
<button
type="button"
className={styles.profileAvatarButton}
onClick={handleAvatarClick}
>
{userProfile?.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</button>
<div className={styles.profileInformation}>
{userProfile ? (
<h2 className={styles.profileDisplayName}>
{userProfile?.displayName}
</h2>
) : (
<Skeleton width={150} height={28} />
)}
{currentGame && (
<div className={styles.currentGameWrapper}>
<div className={styles.currentGameDetails}>
<Link
to={buildGameDetailsPath({
...currentGame,
objectID: currentGame.objectId,
})}
>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDistance(
addSeconds(
new Date(),
-currentGame.sessionDurationInSeconds
),
new Date()
),
})}
</small>
</div>
)}
</div>
</div>
<div className={styles.heroPanel}>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "flex-end",
flex: 1,
}}
>
{profileActions}
</div>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,9 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const wrapper = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
});

View File

@ -0,0 +1,21 @@
import { ProfileContent } from "./profile-content/profile-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./profile.css";
import { UserProfileContextProvider } from "@renderer/context";
import { useParams } from "react-router-dom";
export function Profile() {
const { userId } = useParams();
return (
<UserProfileContextProvider userId={userId!}>
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
<ProfileContent />
</div>
</SkeletonTheme>
</UserProfileContextProvider>
);
}

View File

@ -0,0 +1,12 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const reportButton = style({
alignSelf: "flex-end",
color: vars.color.muted,
gap: `${SPACING_UNIT}px`,
display: "flex",
cursor: "pointer",
alignItems: "center",
fontSize: "12px",
});

View File

@ -0,0 +1,131 @@
import { ReportIcon } from "@primer/octicons-react";
import * as styles from "./report-profile.css";
import { Button, Modal, SelectField, TextField } from "@renderer/components";
import { useCallback, useContext, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as yup from "yup";
import { SPACING_UNIT } from "@renderer/theme.css";
import { userProfileContext } from "@renderer/context";
import { yupResolver } from "@hookform/resolvers/yup";
import { useToast } from "@renderer/hooks";
const reportReasons = ["hate", "sexual_content", "violence", "spam", "other"];
interface FormValues {
reason: string;
description: string;
}
export function ReportProfile() {
const [showReportProfileModal, setShowReportProfileModal] = useState(false);
const { userProfile, isMe } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const schema = yup.object().shape({
reason: yup.string().required(t("required_field")),
description: yup.string().required(t("required_field")),
});
const {
register,
control,
formState: { isSubmitting, errors },
reset,
handleSubmit,
} = useForm<FormValues>({
resolver: yupResolver(schema),
defaultValues: {
reason: "hate",
description: "",
},
});
const { showSuccessToast } = useToast();
useEffect(() => {
reset({
reason: "hate",
description: "",
});
}, [userProfile, reset]);
const onSubmit = useCallback(
async (values: FormValues) => {
return window.electron
.reportUser(userProfile!.id, values.reason, values.description)
.then(() => {
showSuccessToast(t("profile_reported"));
setShowReportProfileModal(false);
});
},
[userProfile, showSuccessToast, t]
);
if (isMe) return null;
return (
<>
<Modal
visible={showReportProfileModal}
onClose={() => setShowReportProfileModal(false)}
title={t("report_profile")}
clickOutsideToClose={false}
>
<form
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<Controller
control={control}
name="reason"
render={({ field }) => {
return (
<SelectField
label={t("report_reason")}
value={field.value}
onChange={field.onChange}
options={reportReasons.map((reason) => ({
key: reason,
value: reason,
label: t(`report_reason_${reason}`),
}))}
/>
);
}}
/>
<TextField
{...register("description")}
label={t("report_description")}
placeholder={t("report_description_placeholder")}
error={errors.description}
/>
<Button
style={{ marginTop: `${SPACING_UNIT}px`, alignSelf: "flex-end" }}
onClick={handleSubmit(onSubmit)}
>
{t("report")}
</Button>
</form>
</Modal>
<button
type="button"
className={styles.reportButton}
onClick={() => setShowReportProfileModal(true)}
disabled={isSubmitting}
>
<ReportIcon size={13} />
{t("report_profile")}
</button>
</>
);
}

View File

@ -4,6 +4,10 @@ import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
interface AddDownloadSourceModalProps {
visible: boolean;
@ -11,47 +15,83 @@ interface AddDownloadSourceModalProps {
onAddDownloadSource: () => void;
}
interface FormValues {
url: string;
}
export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
const [value, setValue] = useState("");
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
const schema = yup.object().shape({
url: yup.string().required(t("required_field")).url(t("must_be_valid_url")),
});
const {
register,
handleSubmit,
setValue,
setError,
clearErrors,
formState: { errors },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const { t } = useTranslation("settings");
const { sourceUrl } = useContext(settingsContext);
const handleValidateDownloadSource = useCallback(async (url: string) => {
setIsLoading(true);
const onSubmit = useCallback(
async (values: FormValues) => {
setIsLoading(true);
try {
const result = await window.electron.validateDownloadSource(url);
setValidationResult(result);
} finally {
setIsLoading(false);
}
}, []);
try {
const result = await window.electron.validateDownloadSource(values.url);
setValidationResult(result);
setUrl(values.url);
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message.endsWith("Source with the same url already exists")
) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
}
}
} finally {
setIsLoading(false);
}
},
[setError, t]
);
useEffect(() => {
setValue("");
setValue("url", "");
clearErrors();
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue(sourceUrl);
handleValidateDownloadSource(sourceUrl);
setValue("url", sourceUrl);
handleSubmit(onSubmit)();
}
}, [visible, handleValidateDownloadSource, sourceUrl]);
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(value);
await window.electron.addDownloadSource(url);
onClose();
onAddDownloadSource();
};
@ -72,17 +112,17 @@ export function AddDownloadSourceModal({
}}
>
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
value={value}
onChange={(e) => setValue(e.target.value)}
error={errors.url}
rightContent={
<Button
type="button"
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={() => handleValidateDownloadSource(value)}
disabled={isLoading || !value}
onClick={handleSubmit(onSubmit)}
disabled={isLoading}
>
{t("validate_download_source")}
</Button>
@ -115,7 +155,11 @@ export function AddDownloadSourceModal({
</small>
</div>
<Button type="button" onClick={handleAddDownloadSource}>
<Button
type="button"
onClick={handleAddDownloadSource}
disabled={isLoading}
>
{t("import")}
</Button>
</div>

View File

@ -38,11 +38,9 @@ export function SettingsGeneral() {
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
useEffect(() => {
async function fetchdefaultDownloadsPath() {
setDefaultDownloadsPath(await window.electron.getDefaultDownloadsPath());
}
fetchdefaultDownloadsPath();
window.electron.getDefaultDownloadsPath().then((path) => {
setDefaultDownloadsPath(path);
});
setLanguageOptions(
orderBy(
@ -89,6 +87,15 @@ export function SettingsGeneral() {
function updateFormWithUserPreferences() {
if (userPreferences) {
const languageKeys = Object.keys(languageResources);
const language =
languageKeys.find((language) => {
return language === userPreferences.language;
}) ??
languageKeys.find((language) => {
return language.startsWith(userPreferences.language.split("-")[0]);
});
setForm((prev) => ({
...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
@ -96,7 +103,7 @@ export function SettingsGeneral() {
userPreferences.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled,
language: userPreferences.language,
language: language ?? "en",
}));
}
}
@ -127,28 +134,27 @@ export function SettingsGeneral() {
/>
<h3>{t("notifications")}</h3>
<>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
handleChange({
downloadNotificationsEnabled: !form.downloadNotificationsEnabled,
})
}
/>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
handleChange({
repackUpdatesNotificationsEnabled:
!form.repackUpdatesNotificationsEnabled,
})
}
/>
</>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
handleChange({
downloadNotificationsEnabled: !form.downloadNotificationsEnabled,
})
}
/>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
handleChange({
repackUpdatesNotificationsEnabled:
!form.repackUpdatesNotificationsEnabled,
})
}
/>
</>
);
}

View File

@ -0,0 +1,47 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const form = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const blockedUserAvatar = style({
width: "32px",
height: "32px",
borderRadius: "4px",
filter: "grayscale(100%)",
});
export const blockedUser = style({
display: "flex",
minWidth: "240px",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: vars.color.darkBackground,
border: `1px solid ${vars.color.border}`,
borderRadius: "4px",
padding: `${SPACING_UNIT}px`,
});
export const unblockButton = style({
color: vars.color.muted,
cursor: "pointer",
transition: "all ease 0.2s",
":hover": {
opacity: "0.7",
},
});
export const blockedUsersList = style({
padding: "0",
margin: "0",
listStyle: "none",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,139 @@
import { SelectField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-privacy.css";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useCallback, useContext, useEffect, useState } from "react";
import { XCircleFillIcon } from "@primer/octicons-react";
import { settingsContext } from "@renderer/context";
interface FormValues {
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
}
export function SettingsPrivacy() {
const { t } = useTranslation("settings");
const [isUnblocking, setIsUnblocking] = useState(false);
const { showSuccessToast } = useToast();
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
const {
control,
formState: { isSubmitting },
setValue,
handleSubmit,
} = useForm<FormValues>();
const { patchUser, userDetails } = useUserDetails();
const { unblockUser } = useUserDetails();
useEffect(() => {
if (userDetails?.profileVisibility) {
setValue("profileVisibility", userDetails.profileVisibility);
}
}, [userDetails, setValue]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
{ value: "PRIVATE", label: t("private") },
];
const onSubmit = async (values: FormValues) => {
await patchUser(values);
showSuccessToast(t("changes_saved"));
};
const handleUnblockClick = useCallback(
(id: string) => {
setIsUnblocking(true);
unblockUser(id)
.then(() => {
fetchBlockedUsers();
showSuccessToast(t("user_unblocked"));
})
.finally(() => {
setIsUnblocking(false);
});
},
[unblockUser, fetchBlockedUsers, t, showSuccessToast]
);
return (
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="profileVisibility"
render={({ field }) => {
const handleChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
field.onChange(event);
handleSubmit(onSubmit)();
};
return (
<>
<SelectField
label={t("profile_visibility")}
value={field.value}
onChange={handleChange}
options={visibilityOptions.map((visiblity) => ({
key: visiblity.value,
value: visiblity.value,
label: visiblity.label,
}))}
disabled={isSubmitting}
/>
<small>{t("profile_visibility_description")}</small>
</>
);
}}
/>
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
{t("blocked_users")}
</h3>
<ul className={styles.blockedUsersList}>
{blockedUsers.map((user) => {
return (
<li key={user.id} className={styles.blockedUser}>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<img
src={user.profileImageUrl!}
alt={user.displayName}
className={styles.blockedUserAvatar}
/>
<span>{user.displayName}</span>
</div>
<button
type="button"
className={styles.unblockButton}
onClick={() => handleUnblockClick(user.id)}
disabled={isUnblocking}
>
<XCircleFillIcon />
</button>
</li>
);
})}
</ul>
</form>
);
}

View File

@ -11,16 +11,26 @@ import {
SettingsContextConsumer,
SettingsContextProvider,
} from "@renderer/context";
import { SettingsPrivacy } from "./settings-privacy";
import { useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
export function Settings() {
const { t } = useTranslation("settings");
const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
const { userDetails } = useUserDetails();
const categories = useMemo(() => {
const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
if (userDetails) return [...categories, t("privacy")];
return categories;
}, [userDetails, t]);
return (
<SettingsContextProvider>
@ -39,7 +49,11 @@ export function Settings() {
return <SettingsDownloadSources />;
}
return <SettingsRealDebrid />;
if (currentCategoryIndex === 3) {
return <SettingsRealDebrid />;
}
return <SettingsPrivacy />;
};
return (

View File

@ -42,13 +42,13 @@ export const UserFriendModalAddFriend = ({
const handleClickRequest = (userId: string) => {
closeModal();
navigate(`/user/${userId}`);
navigate(`/profile/${userId}`);
};
const handleClickSeeProfile = () => {
closeModal();
if (friendCode.length === 8) {
navigate(`/user/${friendCode}`);
navigate(`/profile/${friendCode}`);
}
};

View File

@ -80,7 +80,7 @@ export const UserFriendModalList = ({
const handleClickFriend = (userId: string) => {
closeModal();
navigate(`/user/${userId}`);
navigate(`/profile/${userId}`);
};
const handleUndoFriendship = (userId: string) => {

View File

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

View File

@ -1,40 +0,0 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserConfirmUndoFriendshipModalProps {
visible: boolean;
displayName: string;
onConfirm: () => void;
onClose: () => void;
}
export function UserConfirmUndoFriendshipModal({
visible,
displayName,
onConfirm,
onClose,
}: UserConfirmUndoFriendshipModalProps) {
const { t } = useTranslation("user_profile");
return (
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p>{t("undo_friendship_modal_text", { displayName })}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("undo_friendship")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

Some files were not shown because too many files have changed in this diff Show More