mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-03 08:43:48 +03:00
Merge pull request #950 from hydralauncher/feature/profile-redesign
Feature/profile redesign
This commit is contained in:
commit
3d132de860
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
@ -35,6 +35,7 @@
|
|||||||
"@electron-toolkit/preload": "^3.0.0",
|
"@electron-toolkit/preload": "^3.0.0",
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
"@fontsource/noto-sans": "^5.0.22",
|
"@fontsource/noto-sans": "^5.0.22",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@primer/octicons-react": "^19.9.0",
|
"@primer/octicons-react": "^19.9.0",
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@sentry/electron": "^5.1.0",
|
"@sentry/electron": "^5.1.0",
|
||||||
@ -64,6 +65,7 @@
|
|||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"parse-torrent": "^11.0.16",
|
"parse-torrent": "^11.0.16",
|
||||||
"piscina": "^4.5.1",
|
"piscina": "^4.5.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.0",
|
||||||
"react-loading-skeleton": "^3.4.0",
|
"react-loading-skeleton": "^3.4.0",
|
||||||
"react-redux": "^9.1.1",
|
"react-redux": "^9.1.1",
|
||||||
@ -72,6 +74,7 @@
|
|||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"user-agents": "^1.1.193",
|
"user-agents": "^1.1.193",
|
||||||
"yaml": "^2.4.1",
|
"yaml": "^2.4.1",
|
||||||
|
"yup": "^1.4.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -260,11 +260,6 @@
|
|||||||
"request_accepted": "Žádost přijata",
|
"request_accepted": "Žádost přijata",
|
||||||
"user_blocked_successfully": "Uživatel úspěšně zablokován",
|
"user_blocked_successfully": "Uživatel úspěšně zablokován",
|
||||||
"user_block_modal_text": "Tohle zablokuje {{displayName}}",
|
"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é",
|
"blocked_users": "Zablokovaní uživatelé",
|
||||||
"unblock": "Odblokovat",
|
"unblock": "Odblokovat",
|
||||||
"no_friends_added": "Nemáš přidané žádné přátele",
|
"no_friends_added": "Nemáš přidané žádné přátele",
|
||||||
|
@ -260,11 +260,6 @@
|
|||||||
"request_accepted": "Anfrage akzeptiert",
|
"request_accepted": "Anfrage akzeptiert",
|
||||||
"user_blocked_successfully": "Nutzer erfolgreich blockiert",
|
"user_blocked_successfully": "Nutzer erfolgreich blockiert",
|
||||||
"user_block_modal_text": "{{displayName}} wird dadurch 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",
|
"blocked_users": "Blockierte Nutzer",
|
||||||
"unblock": "Freigeben",
|
"unblock": "Freigeben",
|
||||||
"no_friends_added": "Du hast noch keine Freunde hinzugefügt",
|
"no_friends_added": "Du hast noch keine Freunde hinzugefügt",
|
||||||
|
@ -8,7 +8,9 @@
|
|||||||
"trending": "Trending",
|
"trending": "Trending",
|
||||||
"surprise_me": "Surprise me",
|
"surprise_me": "Surprise me",
|
||||||
"no_results": "No results found",
|
"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": {
|
"sidebar": {
|
||||||
"catalogue": "Catalogue",
|
"catalogue": "Catalogue",
|
||||||
@ -22,7 +24,8 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"queued": "{{title}} (Queued)",
|
"queued": "{{title}} (Queued)",
|
||||||
"game_has_no_executable": "Game has no executable selected",
|
"game_has_no_executable": "Game has no executable selected",
|
||||||
"sign_in": "Sign in"
|
"sign_in": "Sign in",
|
||||||
|
"friends": "Friends"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Search games",
|
"search": "Search games",
|
||||||
@ -115,7 +118,19 @@
|
|||||||
"download_paused": "Download paused",
|
"download_paused": "Download paused",
|
||||||
"last_downloaded_option": "Last downloaded option",
|
"last_downloaded_option": "Last downloaded option",
|
||||||
"create_shortcut_success": "Shortcut created successfully",
|
"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": {
|
"activation": {
|
||||||
"title": "Activate Hydra",
|
"title": "Activate Hydra",
|
||||||
@ -191,7 +206,18 @@
|
|||||||
"found_download_option_zero": "No download option found",
|
"found_download_option_zero": "No download option found",
|
||||||
"found_download_option_one": "Found {{countFormatted}} download option",
|
"found_download_option_one": "Found {{countFormatted}} download option",
|
||||||
"found_download_option_other": "Found {{countFormatted}} download options",
|
"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": {
|
"notifications": {
|
||||||
"download_complete": "Download complete",
|
"download_complete": "Download complete",
|
||||||
@ -261,11 +287,6 @@
|
|||||||
"request_accepted": "Request accepted",
|
"request_accepted": "Request accepted",
|
||||||
"user_blocked_successfully": "User blocked successfully",
|
"user_blocked_successfully": "User blocked successfully",
|
||||||
"user_block_modal_text": "This will block {{displayName}}",
|
"user_block_modal_text": "This will block {{displayName}}",
|
||||||
"settings": "Settings",
|
|
||||||
"public": "Public",
|
|
||||||
"private": "Private",
|
|
||||||
"friends_only": "Friends only",
|
|
||||||
"privacy": "Privacy",
|
|
||||||
"blocked_users": "Blocked users",
|
"blocked_users": "Blocked users",
|
||||||
"unblock": "Unblock",
|
"unblock": "Unblock",
|
||||||
"no_friends_added": "You still don't have added friends",
|
"no_friends_added": "You still don't have added friends",
|
||||||
@ -274,6 +295,22 @@
|
|||||||
"no_blocked_users": "You have no blocked users",
|
"no_blocked_users": "You have no blocked users",
|
||||||
"friend_code_copied": "Friend code copied",
|
"friend_code_copied": "Friend code copied",
|
||||||
"undo_friendship_modal_text": "This will undo your friendship with {{displayName}}",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,8 @@
|
|||||||
"trending": "Tendencias",
|
"trending": "Tendencias",
|
||||||
"surprise_me": "¡Sorpréndeme!",
|
"surprise_me": "¡Sorpréndeme!",
|
||||||
"no_results": "No se encontraron resultados",
|
"no_results": "No se encontraron resultados",
|
||||||
|
"hot": "🔥 Caliente ahora",
|
||||||
|
"weekly": "📅 Los mejores juegos de la semana",
|
||||||
"start_typing": "Empieza a escribir para buscar..."
|
"start_typing": "Empieza a escribir para buscar..."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@ -22,7 +24,8 @@
|
|||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"queued": "{{title}} (En Cola)",
|
"queued": "{{title}} (En Cola)",
|
||||||
"game_has_no_executable": "El juego no tiene un ejecutable",
|
"game_has_no_executable": "El juego no tiene un ejecutable",
|
||||||
"sign_in": "Iniciar sesión"
|
"sign_in": "Iniciar sesión",
|
||||||
|
"friends": "Amigos"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar juegos",
|
"search": "Buscar juegos",
|
||||||
@ -115,7 +118,17 @@
|
|||||||
"download_paused": "Descarga pausada",
|
"download_paused": "Descarga pausada",
|
||||||
"last_downloaded_option": "Última opción descargada",
|
"last_downloaded_option": "Última opción descargada",
|
||||||
"create_shortcut_success": "Atajo creado con éxito",
|
"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": {
|
"activation": {
|
||||||
"title": "Activar Hydra",
|
"title": "Activar Hydra",
|
||||||
@ -191,7 +204,21 @@
|
|||||||
"found_download_option_zero": "No se encontró una opción de descarga",
|
"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_one": "Se encontró {{countFormatted}} opción de descarga",
|
||||||
"found_download_option_other": "Se encontraron {{countFormatted}} opciones 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": {
|
"notifications": {
|
||||||
"download_complete": "Descarga completada",
|
"download_complete": "Descarga completada",
|
||||||
@ -261,11 +288,6 @@
|
|||||||
"request_accepted": "Solicitud aceptada",
|
"request_accepted": "Solicitud aceptada",
|
||||||
"user_blocked_successfully": "Usuario bloqueado exitosamente",
|
"user_blocked_successfully": "Usuario bloqueado exitosamente",
|
||||||
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
|
"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",
|
"blocked_users": "Usuarios bloqueados",
|
||||||
"unblock": "Desbloquear",
|
"unblock": "Desbloquear",
|
||||||
"no_friends_added": "Todavía no tienes amigos añadidos",
|
"no_friends_added": "Todavía no tienes amigos añadidos",
|
||||||
@ -274,6 +296,23 @@
|
|||||||
"no_blocked_users": "No has bloqueado a ningún usuario",
|
"no_blocked_users": "No has bloqueado a ningún usuario",
|
||||||
"friend_code_copied": "Código de amigo copiado",
|
"friend_code_copied": "Código de amigo copiado",
|
||||||
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}",
|
"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"
|
"image_process_failure": "Error al procesar la imagen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@
|
|||||||
"home": {
|
"home": {
|
||||||
"featured": "Destaques",
|
"featured": "Destaques",
|
||||||
"trending": "Populares",
|
"trending": "Populares",
|
||||||
|
"hot": "🔥 Populares agora",
|
||||||
|
"weekly": "📅 Mais baixados da semana",
|
||||||
"surprise_me": "Surpreenda-me",
|
"surprise_me": "Surpreenda-me",
|
||||||
"no_results": "Nenhum resultado encontrado",
|
"no_results": "Nenhum resultado encontrado",
|
||||||
"start_typing": "Comece a digitar para pesquisar…"
|
"start_typing": "Comece a digitar para pesquisar…"
|
||||||
@ -22,7 +24,8 @@
|
|||||||
"home": "Início",
|
"home": "Início",
|
||||||
"queued": "{{title}} (Na fila)",
|
"queued": "{{title}} (Na fila)",
|
||||||
"game_has_no_executable": "Jogo não possui executável selecionado",
|
"game_has_no_executable": "Jogo não possui executável selecionado",
|
||||||
"sign_in": "Login"
|
"sign_in": "Login",
|
||||||
|
"friends": "Amigos"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar jogos",
|
"search": "Buscar jogos",
|
||||||
@ -111,7 +114,19 @@
|
|||||||
"download_paused": "Download pausado",
|
"download_paused": "Download pausado",
|
||||||
"last_downloaded_option": "Última opção baixada",
|
"last_downloaded_option": "Última opção baixada",
|
||||||
"create_shortcut_success": "Atalho criado com sucesso",
|
"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": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
@ -190,7 +205,18 @@
|
|||||||
"found_download_option_zero": "Nenhuma opção de download encontrada",
|
"found_download_option_zero": "Nenhuma opção de download encontrada",
|
||||||
"found_download_option_one": "{{countFormatted}} 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",
|
"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": {
|
"notifications": {
|
||||||
"download_complete": "Download concluído",
|
"download_complete": "Download concluído",
|
||||||
@ -241,7 +267,7 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"successfully_signed_out": "Deslogado com sucesso",
|
"successfully_signed_out": "Deslogado com sucesso",
|
||||||
"sign_out": "Sair da conta",
|
"sign_out": "Sair da conta",
|
||||||
"sign_out_modal_title": "Tem certeza?",
|
"sign_out_modal_title": "Deseja mesmo sair?",
|
||||||
"playing_for": "Jogando por {{amount}}",
|
"playing_for": "Jogando por {{amount}}",
|
||||||
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
|
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
|
||||||
"add_friends": "Adicionar Amigos",
|
"add_friends": "Adicionar Amigos",
|
||||||
@ -264,11 +290,6 @@
|
|||||||
"request_accepted": "Pedido de amizade aceito",
|
"request_accepted": "Pedido de amizade aceito",
|
||||||
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
||||||
"user_block_modal_text": "Bloquear {{displayName}}",
|
"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",
|
"blocked_users": "Usuários bloqueados",
|
||||||
"unblock": "Desbloquear",
|
"unblock": "Desbloquear",
|
||||||
"no_friends_added": "Você ainda não possui amigos adicionados",
|
"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",
|
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
|
||||||
"friend_code_copied": "Código de amigo copiado",
|
"friend_code_copied": "Código de amigo copiado",
|
||||||
"undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,11 @@
|
|||||||
"download_paused": "Transferência pausada",
|
"download_paused": "Transferência pausada",
|
||||||
"last_downloaded_option": "Última opção transferida",
|
"last_downloaded_option": "Última opção transferida",
|
||||||
"create_shortcut_success": "Atalho criado com sucesso",
|
"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": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
@ -169,8 +173,6 @@
|
|||||||
"save_changes": "Guardar alterações",
|
"save_changes": "Guardar alterações",
|
||||||
"changes_saved": "Definições guardadas com sucesso",
|
"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.",
|
"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",
|
"validate_download_source": "Validar",
|
||||||
"remove_download_source": "Remover",
|
"remove_download_source": "Remover",
|
||||||
"add_download_source": "Adicionar fonte",
|
"add_download_source": "Adicionar fonte",
|
||||||
@ -266,11 +268,6 @@
|
|||||||
"request_accepted": "Pedido de amizade aceito",
|
"request_accepted": "Pedido de amizade aceito",
|
||||||
"user_blocked_successfully": "Utilizador bloqueado com sucesso",
|
"user_blocked_successfully": "Utilizador bloqueado com sucesso",
|
||||||
"user_block_modal_text": "Bloquear {{displayName}}",
|
"user_block_modal_text": "Bloquear {{displayName}}",
|
||||||
"settings": "Definições",
|
|
||||||
"privacy": "Privacidade",
|
|
||||||
"private": "Privado",
|
|
||||||
"friends_only": "Apenas amigos",
|
|
||||||
"public": "Público",
|
|
||||||
"blocked_users": "Utilizadores bloqueados",
|
"blocked_users": "Utilizadores bloqueados",
|
||||||
"unblock": "Desbloquear",
|
"unblock": "Desbloquear",
|
||||||
"no_friends_added": "Ainda não adicionaste amigos",
|
"no_friends_added": "Ainda não adicionaste amigos",
|
||||||
|
@ -7,7 +7,10 @@
|
|||||||
"featured": "Рекомендованное",
|
"featured": "Рекомендованное",
|
||||||
"trending": "В тренде",
|
"trending": "В тренде",
|
||||||
"surprise_me": "Удиви меня",
|
"surprise_me": "Удиви меня",
|
||||||
"no_results": "Ничего не найдено"
|
"no_results": "Ничего не найдено",
|
||||||
|
"hot": "🔥 Сейчас жарко",
|
||||||
|
"start_typing": "Начинаю вводить текст для поиска...",
|
||||||
|
"weekly": "📅 Лучшие игры недели"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"catalogue": "Каталог",
|
"catalogue": "Каталог",
|
||||||
@ -21,7 +24,8 @@
|
|||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"queued": "{{title}} (В очереди)",
|
"queued": "{{title}} (В очереди)",
|
||||||
"game_has_no_executable": "Файл запуска игры не выбран",
|
"game_has_no_executable": "Файл запуска игры не выбран",
|
||||||
"sign_in": "Войти"
|
"sign_in": "Войти",
|
||||||
|
"friends": "Друзья"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
@ -114,7 +118,17 @@
|
|||||||
"download_paused": "Загрузка приостановлена",
|
"download_paused": "Загрузка приостановлена",
|
||||||
"last_downloaded_option": "Последний вариант загрузки",
|
"last_downloaded_option": "Последний вариант загрузки",
|
||||||
"create_shortcut_success": "Ярлык создан",
|
"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": {
|
"activation": {
|
||||||
"title": "Активировать Hydra",
|
"title": "Активировать Hydra",
|
||||||
@ -190,7 +204,21 @@
|
|||||||
"found_download_option_zero": "Не найдено вариантов загрузки",
|
"found_download_option_zero": "Не найдено вариантов загрузки",
|
||||||
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
|
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
|
||||||
"found_download_option_other": "Найдено {{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": {
|
"notifications": {
|
||||||
"download_complete": "Загрузка завершена",
|
"download_complete": "Загрузка завершена",
|
||||||
@ -260,17 +288,31 @@
|
|||||||
"request_accepted": "Запрос принят",
|
"request_accepted": "Запрос принят",
|
||||||
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
||||||
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
||||||
"settings": "Настройки",
|
|
||||||
"public": "Публичный",
|
|
||||||
"private": "Приватный",
|
|
||||||
"friends_only": "Только друзья",
|
|
||||||
"privacy": "Приватность",
|
|
||||||
"blocked_users": "Заблокированные пользователи",
|
"blocked_users": "Заблокированные пользователи",
|
||||||
"unblock": "Разблокировать",
|
"unblock": "Разблокировать",
|
||||||
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
||||||
"pending": "Ожидание",
|
"pending": "Ожидание",
|
||||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
"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}}."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import * as Sentry from "@sentry/electron/main";
|
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 { dataSource } from "@main/data-source";
|
||||||
import { DownloadQueue, Game, UserAuth } from "@main/entity";
|
import { DownloadQueue, Game, UserAuth } from "@main/entity";
|
||||||
|
|
||||||
@ -23,6 +28,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
|||||||
/* Removes user from Sentry */
|
/* Removes user from Sentry */
|
||||||
Sentry.setUser(null);
|
Sentry.setUser(null);
|
||||||
|
|
||||||
|
/* Cancels any ongoing downloads */
|
||||||
|
DownloadManager.cancelDownload();
|
||||||
|
|
||||||
/* Disconnects libtorrent */
|
/* Disconnects libtorrent */
|
||||||
PythonInstance.killTorrent();
|
PythonInstance.killTorrent();
|
||||||
|
|
||||||
|
@ -1,36 +1,44 @@
|
|||||||
import { getSteamAppAsset } from "@main/helpers";
|
import type { GameShop } from "@types";
|
||||||
import type { CatalogueEntry, GameShop } from "@types";
|
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { RepacksManager, requestSteam250 } from "@main/services";
|
import { HydraApi, RepacksManager } from "@main/services";
|
||||||
import { formatName } from "@shared";
|
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 response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
|
||||||
const trendingGames = await requestSteam250("/90day");
|
`/games/${category}?${params.toString()}`,
|
||||||
const results: CatalogueEntry[] = [];
|
{},
|
||||||
|
{ needsAuth: false }
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < resultSize; i++) {
|
return Promise.all(
|
||||||
if (!trendingGames[i]) {
|
response.map(async (game) => {
|
||||||
i++;
|
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||||
continue;
|
name: "getById",
|
||||||
}
|
});
|
||||||
|
|
||||||
const { title, objectID } = trendingGames[i]!;
|
const repacks = RepacksManager.search({
|
||||||
const repacks = RepacksManager.search({ query: formatName(title) });
|
query: formatName(steamGame.name),
|
||||||
|
});
|
||||||
|
|
||||||
const catalogueEntry = {
|
return {
|
||||||
objectID,
|
title: steamGame.name,
|
||||||
title,
|
shop: game.shop,
|
||||||
shop: "steam" as GameShop,
|
repacks,
|
||||||
cover: getSteamAppAsset("library", objectID),
|
cover: steamUrlBuilder.library(game.objectId),
|
||||||
|
objectID: game.objectId,
|
||||||
};
|
};
|
||||||
|
})
|
||||||
results.push({ ...catalogueEntry, repacks });
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getCatalogue", getCatalogue);
|
registerEvent("getCatalogue", getCatalogue);
|
||||||
|
23
src/main/events/catalogue/get-game-stats.ts
Normal file
23
src/main/events/catalogue/get-game-stats.ts
Normal 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);
|
@ -1,8 +1,8 @@
|
|||||||
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
|
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
|
||||||
|
|
||||||
import { getSteamAppAsset } from "@main/helpers";
|
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { RepacksManager } from "@main/services";
|
import { RepacksManager } from "@main/services";
|
||||||
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
export interface SearchGamesArgs {
|
export interface SearchGamesArgs {
|
||||||
query?: string;
|
query?: string;
|
||||||
@ -16,7 +16,7 @@ export const convertSteamGameToCatalogueEntry = (
|
|||||||
objectID: String(game.id),
|
objectID: String(game.id),
|
||||||
title: game.name,
|
title: game.name,
|
||||||
shop: "steam" as GameShop,
|
shop: "steam" as GameShop,
|
||||||
cover: getSteamAppAsset("library", String(game.id)),
|
cover: steamUrlBuilder.library(String(game.id)),
|
||||||
repacks: [],
|
repacks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import "./catalogue/get-how-long-to-beat";
|
|||||||
import "./catalogue/get-random-game";
|
import "./catalogue/get-random-game";
|
||||||
import "./catalogue/search-games";
|
import "./catalogue/search-games";
|
||||||
import "./catalogue/search-game-repacks";
|
import "./catalogue/search-game-repacks";
|
||||||
|
import "./catalogue/get-game-stats";
|
||||||
import "./catalogue/get-trending-games";
|
import "./catalogue/get-trending-games";
|
||||||
import "./hardware/get-disk-free-space";
|
import "./hardware/get-disk-free-space";
|
||||||
import "./library/add-game-to-library";
|
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";
|
||||||
import "./library/open-game-installer-path";
|
import "./library/open-game-installer-path";
|
||||||
import "./library/update-executable-path";
|
import "./library/update-executable-path";
|
||||||
|
import "./library/verify-executable-path";
|
||||||
import "./library/remove-game";
|
import "./library/remove-game";
|
||||||
import "./library/remove-game-from-library";
|
import "./library/remove-game-from-library";
|
||||||
import "./misc/open-external";
|
import "./misc/open-external";
|
||||||
@ -44,10 +46,12 @@ import "./auth/sign-out";
|
|||||||
import "./auth/open-auth-window";
|
import "./auth/open-auth-window";
|
||||||
import "./auth/get-session-hash";
|
import "./auth/get-session-hash";
|
||||||
import "./user/get-user";
|
import "./user/get-user";
|
||||||
import "./user/get-user-blocks";
|
import "./user/get-blocked-users";
|
||||||
import "./user/block-user";
|
import "./user/block-user";
|
||||||
import "./user/unblock-user";
|
import "./user/unblock-user";
|
||||||
import "./user/get-user-friends";
|
import "./user/get-user-friends";
|
||||||
|
import "./user/get-user-stats";
|
||||||
|
import "./user/report-user";
|
||||||
import "./profile/get-friend-requests";
|
import "./profile/get-friend-requests";
|
||||||
import "./profile/get-me";
|
import "./profile/get-me";
|
||||||
import "./profile/undo-friendship";
|
import "./profile/undo-friendship";
|
||||||
|
@ -3,10 +3,11 @@ import { gameRepository } from "@main/repository";
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64 } from "@main/helpers";
|
||||||
|
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { createGame } from "@main/services/library-sync";
|
import { createGame } from "@main/services/library-sync";
|
||||||
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -32,7 +33,7 @@ const addGameToLibrary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository
|
await gameRepository
|
||||||
@ -53,7 +54,7 @@ const addGameToLibrary = async (
|
|||||||
|
|
||||||
const game = await gameRepository.findOne({ where: { objectID } });
|
const game = await gameRepository.findOne({ where: { objectID } });
|
||||||
|
|
||||||
createGame(game!);
|
createGame(game!).catch(() => {});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
13
src/main/events/library/verify-executable-path.ts
Normal file
13
src/main/events/library/verify-executable-path.ts
Normal 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);
|
@ -1,15 +1,33 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import * as Sentry from "@sentry/electron/main";
|
import * as Sentry from "@sentry/electron/main";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi, logger } from "@main/services";
|
||||||
import { UserProfile } from "@types";
|
import { UserProfile } from "@types";
|
||||||
import { userAuthRepository } from "@main/repository";
|
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 (
|
const getMe = async (
|
||||||
_event: Electron.IpcMainInvokeEvent
|
_event: Electron.IpcMainInvokeEvent
|
||||||
): Promise<UserProfile | null> => {
|
): Promise<UserProfile | null> => {
|
||||||
return HydraApi.get(`/profile/me`)
|
return HydraApi.get(`/profile/me`)
|
||||||
.then((me) => {
|
.then(async (me) => {
|
||||||
userAuthRepository.upsert(
|
userAuthRepository.upsert(
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -20,6 +38,17 @@ const getMe = async (
|
|||||||
["id"]
|
["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 });
|
Sentry.setUser({ id: me.id, username: me.username });
|
||||||
|
|
||||||
return me;
|
return me;
|
||||||
|
@ -2,7 +2,7 @@ import { registerEvent } from "../register-event";
|
|||||||
import { HydraApi, PythonInstance } from "@main/services";
|
import { HydraApi, PythonInstance } from "@main/services";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { UpdateProfileProps, UserProfile } from "@types";
|
import type { UpdateProfileRequest, UserProfile } from "@types";
|
||||||
import { omit } from "lodash-es";
|
import { omit } from "lodash-es";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ interface PresignedResponse {
|
|||||||
profileImageUrl: string;
|
profileImageUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
|
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
|
||||||
return HydraApi.patch<UserProfile>("/profile", updateProfile);
|
return HydraApi.patch<UserProfile>("/profile", updateProfile);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ const getNewProfileImageUrl = async (localImageUrl: string) => {
|
|||||||
|
|
||||||
const updateProfile = async (
|
const updateProfile = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
updateProfile: UpdateProfileProps
|
updateProfile: UpdateProfileRequest
|
||||||
) => {
|
) => {
|
||||||
if (!updateProfile.profileImageUrl) {
|
if (!updateProfile.profileImageUrl) {
|
||||||
return patchUserProfile(omit(updateProfile, "profileImageUrl"));
|
return patchUserProfile(omit(updateProfile, "profileImageUrl"));
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
import {
|
|
||||||
downloadQueueRepository,
|
|
||||||
gameRepository,
|
|
||||||
repackRepository,
|
|
||||||
} from "@main/repository";
|
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
import type { StartGameDownloadPayload } from "@types";
|
import type { StartGameDownloadPayload } from "@types";
|
||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64 } from "@main/helpers";
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
|
|
||||||
import { Not } from "typeorm";
|
import { Not } from "typeorm";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { createGame } from "@main/services/library-sync";
|
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 (
|
const startGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -21,6 +18,12 @@ const startGameDownload = async (
|
|||||||
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
|
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
|
||||||
payload;
|
payload;
|
||||||
|
|
||||||
|
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([
|
const [game, repack] = await Promise.all([
|
||||||
gameRepository.findOne({
|
gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -65,7 +68,7 @@ const startGameDownload = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository
|
await gameRepository
|
||||||
@ -96,13 +99,14 @@ const startGameDownload = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createGame(updatedGame!);
|
createGame(updatedGame!).catch(() => {});
|
||||||
|
|
||||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
|
||||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
|
||||||
|
|
||||||
await DownloadManager.cancelDownload(updatedGame!.id);
|
await DownloadManager.cancelDownload(updatedGame!.id);
|
||||||
await DownloadManager.startDownload(updatedGame!);
|
await DownloadManager.startDownload(updatedGame!);
|
||||||
|
|
||||||
|
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||||
|
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("startGameDownload", startGameDownload);
|
registerEvent("startGameDownload", startGameDownload);
|
||||||
|
@ -2,7 +2,7 @@ import { registerEvent } from "../register-event";
|
|||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { UserBlocks } from "@types";
|
import { UserBlocks } from "@types";
|
||||||
|
|
||||||
export const getUserBlocks = async (
|
export const getBlockedUsers = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
take: number,
|
take: number,
|
||||||
skip: number
|
skip: number
|
||||||
@ -10,4 +10,4 @@ export const getUserBlocks = async (
|
|||||||
return HydraApi.get(`/profile/blocks`, { take, skip });
|
return HydraApi.get(`/profile/blocks`, { take, skip });
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getUserBlocks", getUserBlocks);
|
registerEvent("getBlockedUsers", getBlockedUsers);
|
12
src/main/events/user/get-user-stats.ts
Normal file
12
src/main/events/user/get-user-stats.ts
Normal 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);
|
@ -1,76 +1,81 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi, logger } from "@main/services";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { GameRunning, UserGame, UserProfile } from "@types";
|
import type { UserProfile } from "@types";
|
||||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
import { steamUrlBuilder } from "@shared";
|
||||||
import { getSteamAppAsset } from "@main/helpers";
|
|
||||||
import { getUserFriends } from "./get-user-friends";
|
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 (
|
const getUser = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<UserProfile | null> => {
|
): Promise<UserProfile | null> => {
|
||||||
try {
|
try {
|
||||||
const [profile, friends] = await Promise.all([
|
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
|
||||||
HydraApi.get(`/users/${userId}`),
|
|
||||||
getUserFriends(userId, 12, 0).catch(() => {
|
if (!profile) return null;
|
||||||
return { totalFriends: 0, friends: [] };
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const recentGames = await Promise.all(
|
const recentGames = await Promise.all(
|
||||||
profile.recentGames.map(async (game) => {
|
profile.recentGames
|
||||||
return getSteamUserGame(game);
|
.map(async (game) => {
|
||||||
|
const steamGame = await getSteamGame(game.objectId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
...steamGame,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
|
.filter((game) => game)
|
||||||
);
|
);
|
||||||
|
|
||||||
const libraryGames = await Promise.all(
|
const libraryGames = await Promise.all(
|
||||||
profile.libraryGames.map(async (game) => {
|
profile.libraryGames
|
||||||
return getSteamUserGame(game);
|
.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 {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
libraryGames,
|
libraryGames,
|
||||||
recentGames,
|
recentGames,
|
||||||
friends: friends.friends,
|
|
||||||
totalFriends: friends.totalFriends,
|
|
||||||
currentGame,
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
return null;
|
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);
|
registerEvent("getUser", getUser);
|
||||||
|
16
src/main/events/user/report-user.ts
Normal file
16
src/main/events/user/report-user.ts
Normal 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);
|
@ -2,23 +2,6 @@ import axios from "axios";
|
|||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import UserAgent from "user-agents";
|
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) =>
|
export const getFileBuffer = async (url: string) =>
|
||||||
fetch(url, { method: "GET" }).then((response) =>
|
fetch(url, { method: "GET" }).then((response) =>
|
||||||
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
|
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) =>
|
export const sleep = (ms: number) =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
@ -2,12 +2,13 @@ import knex, { Knex } from "knex";
|
|||||||
import { databasePath } from "./constants";
|
import { databasePath } from "./constants";
|
||||||
import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3";
|
import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3";
|
||||||
import { RepackUris } from "./migrations/20240830143906_RepackUris";
|
import { RepackUris } from "./migrations/20240830143906_RepackUris";
|
||||||
|
import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language";
|
||||||
|
|
||||||
export type HydraMigration = Knex.Migration & { name: string };
|
export type HydraMigration = Knex.Migration & { name: string };
|
||||||
|
|
||||||
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||||
getMigrations(): Promise<HydraMigration[]> {
|
getMigrations(): Promise<HydraMigration[]> {
|
||||||
return Promise.resolve([Hydra2_0_3, RepackUris]);
|
return Promise.resolve([Hydra2_0_3, RepackUris, UpdateUserLanguage]);
|
||||||
}
|
}
|
||||||
getMigrationName(migration: HydraMigration): string {
|
getMigrationName(migration: HydraMigration): string {
|
||||||
return migration.name;
|
return migration.name;
|
||||||
|
13
src/main/migrations/20240913213944_update_user_language.ts
Normal file
13
src/main/migrations/20240913213944_update_user_language.ts
Normal 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) => {},
|
||||||
|
};
|
@ -11,6 +11,7 @@ import { GenericHttpDownloader } from "./generic-http-downloader";
|
|||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static currentDownloader: Downloader | null = null;
|
private static currentDownloader: Downloader | null = null;
|
||||||
|
private static downloadingGameId: number | null = null;
|
||||||
|
|
||||||
public static async watchDownloads() {
|
public static async watchDownloads() {
|
||||||
let status: DownloadProgress | null = null;
|
let status: DownloadProgress | null = null;
|
||||||
@ -76,13 +77,14 @@ export class DownloadManager {
|
|||||||
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
this.currentDownloader = null;
|
this.currentDownloader = null;
|
||||||
|
this.downloadingGameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resumeDownload(game: Game) {
|
static async resumeDownload(game: Game) {
|
||||||
return this.startDownload(game);
|
return this.startDownload(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload(gameId: number) {
|
static async cancelDownload(gameId = this.downloadingGameId!) {
|
||||||
if (this.currentDownloader === Downloader.Torrent) {
|
if (this.currentDownloader === Downloader.Torrent) {
|
||||||
PythonInstance.cancelDownload(gameId);
|
PythonInstance.cancelDownload(gameId);
|
||||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||||
@ -93,6 +95,7 @@ export class DownloadManager {
|
|||||||
|
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
this.currentDownloader = null;
|
this.currentDownloader = null;
|
||||||
|
this.downloadingGameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
static async startDownload(game: Game) {
|
||||||
@ -131,5 +134,6 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentDownloader = game.downloader;
|
this.currentDownloader = game.downloader;
|
||||||
|
this.downloadingGameId = game.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,54 +81,54 @@ export class HydraApi {
|
|||||||
baseURL: import.meta.env.MAIN_VITE_API_URL,
|
baseURL: import.meta.env.MAIN_VITE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.instance.interceptors.request.use(
|
// this.instance.interceptors.request.use(
|
||||||
(request) => {
|
// (request) => {
|
||||||
logger.log(" ---- REQUEST -----");
|
// logger.log(" ---- REQUEST -----");
|
||||||
logger.log(request.method, request.url, request.params, request.data);
|
// logger.log(request.method, request.url, request.params, request.data);
|
||||||
return request;
|
// return request;
|
||||||
},
|
// },
|
||||||
(error) => {
|
// (error) => {
|
||||||
logger.error("request error", error);
|
// logger.error("request error", error);
|
||||||
return Promise.reject(error);
|
// return Promise.reject(error);
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
|
|
||||||
this.instance.interceptors.response.use(
|
// this.instance.interceptors.response.use(
|
||||||
(response) => {
|
// (response) => {
|
||||||
logger.log(" ---- RESPONSE -----");
|
// logger.log(" ---- RESPONSE -----");
|
||||||
logger.log(
|
// logger.log(
|
||||||
response.status,
|
// response.status,
|
||||||
response.config.method,
|
// response.config.method,
|
||||||
response.config.url,
|
// response.config.url,
|
||||||
response.data
|
// response.data
|
||||||
);
|
// );
|
||||||
return response;
|
// return response;
|
||||||
},
|
// },
|
||||||
(error) => {
|
// (error) => {
|
||||||
logger.error(" ---- RESPONSE ERROR -----");
|
// logger.error(" ---- RESPONSE ERROR -----");
|
||||||
|
|
||||||
const { config } = error;
|
// const { config } = error;
|
||||||
|
|
||||||
logger.error(
|
// logger.error(
|
||||||
config.method,
|
// config.method,
|
||||||
config.baseURL,
|
// config.baseURL,
|
||||||
config.url,
|
// config.url,
|
||||||
config.headers,
|
// config.headers,
|
||||||
config.data
|
// config.data
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (error.response) {
|
// if (error.response) {
|
||||||
logger.error("Response", error.response.status, error.response.data);
|
// logger.error("Response", error.response.status, error.response.data);
|
||||||
} else if (error.request) {
|
// } else if (error.request) {
|
||||||
logger.error("Request", error.request);
|
// logger.error("Request", error.request);
|
||||||
} else {
|
// } else {
|
||||||
logger.error("Error", error.message);
|
// logger.error("Error", error.message);
|
||||||
}
|
// }
|
||||||
|
|
||||||
logger.error(" ----- END RESPONSE ERROR -------");
|
// logger.error(" ----- END RESPONSE ERROR -------");
|
||||||
return Promise.reject(error);
|
// return Promise.reject(error);
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
|
|
||||||
const userAuth = await userAuthRepository.findOne({
|
const userAuth = await userAuthRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
|
@ -1,21 +1,31 @@
|
|||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
import { HydraApi } from "../hydra-api";
|
import { HydraApi } from "../hydra-api";
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
export const createGame = async (game: Game) => {
|
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,
|
objectId: game.objectID,
|
||||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
lastTimePlayed: game.lastTimePlayed,
|
lastTimePlayed: game.lastTimePlayed,
|
||||||
})
|
}).then((response) => {
|
||||||
.then((response) => {
|
|
||||||
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
|
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
|
||||||
|
|
||||||
gameRepository.update(
|
gameRepository.update(
|
||||||
{ objectID: game.objectID },
|
{ objectID: game.objectID },
|
||||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||||
);
|
);
|
||||||
})
|
});
|
||||||
.catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { HydraApi } from "../hydra-api";
|
import { HydraApi } from "../hydra-api";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { getSteamAppAsset } from "@main/helpers";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
export const mergeWithRemoteGames = async () => {
|
export const mergeWithRemoteGames = async () => {
|
||||||
return HydraApi.get("/profile/games")
|
return HydraApi.get("/profile/games")
|
||||||
@ -44,7 +44,7 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
|
|
||||||
if (steamGame) {
|
if (steamGame) {
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
gameRepository.insert({
|
gameRepository.insert({
|
||||||
|
@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
|
|||||||
deltaInMillis: number,
|
deltaInMillis: number,
|
||||||
lastTimePlayed: Date
|
lastTimePlayed: Date
|
||||||
) => {
|
) => {
|
||||||
HydraApi.put(`/profile/games/${game.remoteId}`, {
|
return HydraApi.put(`/profile/games/${game.remoteId}`, {
|
||||||
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
|
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
|
||||||
lastTimePlayed,
|
lastTimePlayed,
|
||||||
}).catch(() => {});
|
});
|
||||||
};
|
};
|
||||||
|
@ -81,3 +81,5 @@ export const publishNotificationUpdateReadyToInstall = async (
|
|||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
}).show();
|
}).show();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const publishNewFriendRequestNotification = async () => {};
|
||||||
|
@ -70,9 +70,9 @@ function onOpenGame(game: Game) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (game.remoteId) {
|
if (game.remoteId) {
|
||||||
updateGamePlaytime(game, 0, new Date());
|
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||||
} else {
|
} 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 (currentTick % TICKS_TO_UPDATE_API === 0) {
|
||||||
if (game.remoteId) {
|
const gamePromise = game.remoteId
|
||||||
updateGamePlaytime(
|
? updateGamePlaytime(
|
||||||
game,
|
game,
|
||||||
now - gamePlaytime.lastSyncTick,
|
now - gamePlaytime.lastSyncTick,
|
||||||
game.lastTimePlayed!
|
game.lastTimePlayed!
|
||||||
);
|
)
|
||||||
} else {
|
: createGame(game);
|
||||||
createGame(game);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
gamePromise
|
||||||
|
.then(() => {
|
||||||
gamesPlaytime.set(game.id, {
|
gamesPlaytime.set(game.id, {
|
||||||
...gamePlaytime,
|
...gamePlaytime,
|
||||||
lastSyncTick: now,
|
lastSyncTick: now,
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,8 +121,8 @@ const onCloseGame = (game: Game) => {
|
|||||||
game,
|
game,
|
||||||
performance.now() - gamePlaytime.firstTick,
|
performance.now() - gamePlaytime.firstTick,
|
||||||
game.lastTimePlayed!
|
game.lastTimePlayed!
|
||||||
);
|
).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
createGame(game);
|
createGame(game).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -10,8 +10,9 @@ import type {
|
|||||||
StartGameDownloadPayload,
|
StartGameDownloadPayload,
|
||||||
GameRunning,
|
GameRunning,
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
UpdateProfileProps,
|
UpdateProfileRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
|
import type { CatalogueCategory } from "@shared";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electron", {
|
contextBridge.exposeInMainWorld("electron", {
|
||||||
/* Torrenting */
|
/* Torrenting */
|
||||||
@ -34,7 +35,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
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) =>
|
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
||||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
@ -44,6 +46,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("getGames", take, prevCursor),
|
ipcRenderer.invoke("getGames", take, prevCursor),
|
||||||
searchGameRepacks: (query: string) =>
|
searchGameRepacks: (query: string) =>
|
||||||
ipcRenderer.invoke("searchGameRepacks", query),
|
ipcRenderer.invoke("searchGameRepacks", query),
|
||||||
|
getGameStats: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||||
|
|
||||||
/* User preferences */
|
/* User preferences */
|
||||||
@ -71,6 +75,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("createGameShortcut", id),
|
ipcRenderer.invoke("createGameShortcut", id),
|
||||||
updateExecutablePath: (id: number, executablePath: string) =>
|
updateExecutablePath: (id: number, executablePath: string) =>
|
||||||
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
|
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
|
||||||
|
verifyExecutablePathInUse: (executablePath: string) =>
|
||||||
|
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
||||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||||
openGameInstaller: (gameId: number) =>
|
openGameInstaller: (gameId: number) =>
|
||||||
ipcRenderer.invoke("openGameInstaller", gameId),
|
ipcRenderer.invoke("openGameInstaller", gameId),
|
||||||
@ -139,7 +145,7 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
getMe: () => ipcRenderer.invoke("getMe"),
|
getMe: () => ipcRenderer.invoke("getMe"),
|
||||||
undoFriendship: (userId: string) =>
|
undoFriendship: (userId: string) =>
|
||||||
ipcRenderer.invoke("undoFriendship", userId),
|
ipcRenderer.invoke("undoFriendship", userId),
|
||||||
updateProfile: (updateProfile: UpdateProfileProps) =>
|
updateProfile: (updateProfile: UpdateProfileRequest) =>
|
||||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||||
processProfileImage: (imagePath: string) =>
|
processProfileImage: (imagePath: string) =>
|
||||||
ipcRenderer.invoke("processProfileImage", imagePath),
|
ipcRenderer.invoke("processProfileImage", imagePath),
|
||||||
@ -155,8 +161,11 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
||||||
getUserFriends: (userId: string, take: number, skip: number) =>
|
getUserFriends: (userId: string, take: number, skip: number) =>
|
||||||
ipcRenderer.invoke("getUserFriends", userId, take, skip),
|
ipcRenderer.invoke("getUserFriends", userId, take, skip),
|
||||||
getUserBlocks: (take: number, skip: number) =>
|
getBlockedUsers: (take: number, skip: number) =>
|
||||||
ipcRenderer.invoke("getUserBlocks", take, skip),
|
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 */
|
/* Auth */
|
||||||
signOut: () => ipcRenderer.invoke("signOut"),
|
signOut: () => ipcRenderer.invoke("signOut"),
|
||||||
|
@ -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";
|
import { SPACING_UNIT, vars } from "./theme.css";
|
||||||
|
|
||||||
|
export const appContainer = createContainer();
|
||||||
|
|
||||||
globalStyle("*", {
|
globalStyle("*", {
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
});
|
});
|
||||||
@ -90,6 +97,8 @@ export const container = style({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
containerName: appContainer,
|
||||||
|
containerType: "inline-size",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const content = style({
|
export const content = style({
|
||||||
|
@ -58,11 +58,11 @@ export const button = styleVariants({
|
|||||||
danger: [
|
danger: [
|
||||||
base,
|
base,
|
||||||
{
|
{
|
||||||
border: `solid 1px #a31533`,
|
borderColor: "transparent",
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "white",
|
|
||||||
":hover": {
|
|
||||||
backgroundColor: "#a31533",
|
backgroundColor: "#a31533",
|
||||||
|
color: "#c0c1c7",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "#b3203f",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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",
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
|
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||||
import type { CatalogueEntry } from "@types";
|
import type { CatalogueEntry, GameStats } from "@types";
|
||||||
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
|
|
||||||
import * as styles from "./game-card.css";
|
import * as styles from "./game-card.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Badge } from "../badge/badge";
|
import { Badge } from "../badge/badge";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useFormat } from "@renderer/hooks";
|
||||||
|
|
||||||
export interface GameCardProps
|
export interface GameCardProps
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
@ -22,12 +24,29 @@ const shopIcon = {
|
|||||||
export function GameCard({ game, ...props }: GameCardProps) {
|
export function GameCard({ game, ...props }: GameCardProps) {
|
||||||
const { t } = useTranslation("game_card");
|
const { t } = useTranslation("game_card");
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<GameStats | null>(null);
|
||||||
|
|
||||||
const uniqueRepackers = Array.from(
|
const uniqueRepackers = Array.from(
|
||||||
new Set(game.repacks.map(({ repacker }) => repacker))
|
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 (
|
return (
|
||||||
<button {...props} type="button" className={styles.card}>
|
<button
|
||||||
|
{...props}
|
||||||
|
type="button"
|
||||||
|
className={styles.card}
|
||||||
|
onMouseEnter={handleHover}
|
||||||
|
>
|
||||||
<div className={styles.backdrop}>
|
<div className={styles.backdrop}>
|
||||||
<img src={game.cover} alt={game.title} className={styles.cover} />
|
<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>
|
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.specifics}>
|
<div className={styles.specifics}>
|
||||||
<div className={styles.specificsItem}>
|
<div className={styles.specificsItem}>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
<span>{game.repacks.length}</span>
|
<span>
|
||||||
|
{stats ? numberFormatter.format(stats.downloadCount) : "…"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{game.repacks.length > 0 && (
|
|
||||||
<div className={styles.specificsItem}>
|
<div className={styles.specificsItem}>
|
||||||
<FileDirectoryIcon />
|
<PeopleIcon />
|
||||||
<span>{game.repacks.at(0)?.fileSize}</span>
|
<span>
|
||||||
|
{stats ? numberFormatter.format(stats?.playerCount) : "…"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -39,7 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
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");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
|
|
||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
|
@ -48,6 +48,9 @@ export function Hero() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
{game.logo && (
|
||||||
|
<img src={game.logo} width="250px" alt={game.description} />
|
||||||
|
)}
|
||||||
<p className={styles.description}>{game.description}</p>
|
<p className={styles.description}>{game.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,3 +11,4 @@ export * from "./link/link";
|
|||||||
export * from "./select-field/select-field";
|
export * from "./select-field/select-field";
|
||||||
export * from "./toast/toast";
|
export * from "./toast/toast";
|
||||||
export * from "./badge/badge";
|
export * from "./badge/badge";
|
||||||
|
export * from "./confirmation-modal/confirmation-modal";
|
||||||
|
@ -1,21 +1,13 @@
|
|||||||
import { createVar, style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
|
||||||
export const profileContainerBackground = createVar();
|
|
||||||
|
|
||||||
export const profileContainer = style({
|
export const profileContainer = style({
|
||||||
background: profileContainerBackground,
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
cursor: "pointer",
|
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
|
||||||
":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`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileButton = style({
|
export const profileButton = style({
|
||||||
@ -25,20 +17,24 @@ export const profileButton = style({
|
|||||||
color: vars.color.muted,
|
color: vars.color.muted,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileButtonContent = style({
|
export const profileButtonContent = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||||
height: "40px",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileAvatar = style({
|
export const profileAvatar = style({
|
||||||
width: "35px",
|
width: "35px",
|
||||||
height: "35px",
|
height: "35px",
|
||||||
borderRadius: "50%",
|
borderRadius: "4px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -56,17 +52,6 @@ export const profileButtonInformation = style({
|
|||||||
minWidth: 0,
|
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({
|
export const profileButtonTitle = style({
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fontSize: vars.size.body,
|
fontSize: vars.size.body,
|
||||||
@ -77,14 +62,31 @@ export const profileButtonTitle = style({
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const friendRequestButton = style({
|
export const friendsButton = style({
|
||||||
color: vars.color.success,
|
color: vars.color.muted,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
overflow: "hidden",
|
|
||||||
width: "40px",
|
width: "40px",
|
||||||
|
minWidth: "40px",
|
||||||
|
minHeight: "40px",
|
||||||
height: "40px",
|
height: "40px",
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
position: "relative",
|
||||||
|
transition: "all ease 0.3s",
|
||||||
":hover": {
|
":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",
|
||||||
|
});
|
||||||
|
@ -1,60 +1,79 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
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 * as styles from "./sidebar-profile.css";
|
||||||
import { assignInlineVars } from "@vanilla-extract/dynamic";
|
|
||||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { profileContainerBackground } from "./sidebar-profile.css";
|
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||||
import { FriendRequest } from "@types";
|
|
||||||
|
const LONG_POLLING_INTERVAL = 10_000;
|
||||||
|
|
||||||
export function SidebarProfile() {
|
export function SidebarProfile() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation("sidebar");
|
const { t } = useTranslation("sidebar");
|
||||||
|
|
||||||
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
|
const { userDetails, friendRequests, showFriendsModal, fetchFriendRequests } =
|
||||||
useUserDetails();
|
useUserDetails();
|
||||||
|
|
||||||
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setReceivedRequests(
|
|
||||||
friendRequests.filter((request) => request.type === "RECEIVED")
|
|
||||||
);
|
|
||||||
}, [friendRequests]);
|
|
||||||
|
|
||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const receivedRequests = useMemo(() => {
|
||||||
|
return friendRequests.filter((request) => request.type === "RECEIVED");
|
||||||
|
}, [friendRequests]);
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
if (userDetails === null) {
|
if (userDetails === null) {
|
||||||
window.electron.openAuthWindow();
|
window.electron.openAuthWindow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/user/${userDetails!.id}`);
|
navigate(`/profile/${userDetails!.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileButtonBackground = useMemo(() => {
|
useEffect(() => {
|
||||||
if (profileBackground) return profileBackground;
|
pollingInterval.current = setInterval(() => {
|
||||||
return undefined;
|
fetchFriendRequests();
|
||||||
}, [profileBackground]);
|
}, LONG_POLLING_INTERVAL);
|
||||||
|
|
||||||
const showPendingRequests =
|
return () => {
|
||||||
userDetails && receivedRequests.length > 0 && !gameRunning;
|
if (pollingInterval.current) {
|
||||||
|
clearInterval(pollingInterval.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchFriendRequests]);
|
||||||
|
|
||||||
|
const friendsButton = useMemo(() => {
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={styles.profileContainer}
|
type="button"
|
||||||
style={assignInlineVars({
|
className={styles.friendsButton}
|
||||||
[profileContainerBackground]: profileButtonBackground,
|
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}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.profileButton}
|
className={styles.profileButton}
|
||||||
onClick={handleButtonClick}
|
onClick={handleProfileClick}
|
||||||
>
|
>
|
||||||
<div className={styles.profileButtonContent}>
|
<div className={styles.profileButtonContent}>
|
||||||
<div className={styles.profileAvatar}>
|
<div className={styles.profileAvatar}>
|
||||||
@ -75,34 +94,31 @@ export function SidebarProfile() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{userDetails && gameRunning && (
|
{userDetails && gameRunning && (
|
||||||
<div>
|
<div
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<small>{gameRunning.title}</small>
|
<small>{gameRunning.title}</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{userDetails && gameRunning?.iconUrl && (
|
{userDetails && gameRunning && (
|
||||||
<img
|
<img
|
||||||
alt={gameRunning.title}
|
alt={gameRunning.title}
|
||||||
width={24}
|
width={24}
|
||||||
style={{ borderRadius: 4 }}
|
style={{ borderRadius: 4 }}
|
||||||
src={gameRunning.iconUrl}
|
src={gameRunning.iconUrl!}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{showPendingRequests && (
|
|
||||||
<button
|
{friendsButton}
|
||||||
type="button"
|
|
||||||
className={styles.friendRequestButton}
|
|
||||||
onClick={() =>
|
|
||||||
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PersonAddIcon size={24} />
|
|
||||||
{receivedRequests.length}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -21,25 +21,24 @@ export const sidebar = recipe({
|
|||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
darwin: {
|
||||||
|
true: {
|
||||||
|
paddingTop: `${SPACING_UNIT * 6}px`,
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
paddingTop: `${SPACING_UNIT * 2}px`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const content = recipe({
|
export const content = style({
|
||||||
base: {
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
padding: `${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
macos: {
|
|
||||||
true: {
|
|
||||||
paddingTop: `${SPACING_UNIT * 6}px`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const handle = style({
|
export const handle = style({
|
||||||
|
@ -23,6 +23,8 @@ const SIDEBAR_MAX_WIDTH = 450;
|
|||||||
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
|
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
const filterRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { t } = useTranslation("sidebar");
|
const { t } = useTranslation("sidebar");
|
||||||
const { library, updateLibrary } = useLibrary();
|
const { library, updateLibrary } = useLibrary();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -78,6 +80,10 @@ export function Sidebar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilteredLibrary(sortedLibrary);
|
setFilteredLibrary(sortedLibrary);
|
||||||
|
|
||||||
|
if (filterRef.current) {
|
||||||
|
filterRef.current.value = "";
|
||||||
|
}
|
||||||
}, [sortedLibrary]);
|
}, [sortedLibrary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -139,7 +145,7 @@ export function Sidebar() {
|
|||||||
navigate(path);
|
navigate(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.detail == 2) {
|
if (event.detail === 2) {
|
||||||
if (game.executablePath) {
|
if (game.executablePath) {
|
||||||
window.electron.openGame(game.id, game.executablePath);
|
window.electron.openGame(game.id, game.executablePath);
|
||||||
} else {
|
} else {
|
||||||
@ -149,10 +155,12 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<aside
|
<aside
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={styles.sidebar({ resizing: isResizing })}
|
className={styles.sidebar({
|
||||||
|
resizing: isResizing,
|
||||||
|
darwin: window.electron.platform === "darwin",
|
||||||
|
})}
|
||||||
style={{
|
style={{
|
||||||
width: sidebarWidth,
|
width: sidebarWidth,
|
||||||
minWidth: sidebarWidth,
|
minWidth: sidebarWidth,
|
||||||
@ -161,13 +169,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<SidebarProfile />
|
<SidebarProfile />
|
||||||
|
|
||||||
<div
|
<div className={styles.content}>
|
||||||
className={styles.content({
|
|
||||||
macos: window.electron.platform === "darwin",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
|
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<ul className={styles.menu}>
|
<ul className={styles.menu}>
|
||||||
{routes.map(({ nameKey, path, render }) => (
|
{routes.map(({ nameKey, path, render }) => (
|
||||||
@ -194,6 +196,7 @@ export function Sidebar() {
|
|||||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
ref={filterRef}
|
||||||
placeholder={t("filter")}
|
placeholder={t("filter")}
|
||||||
onChange={handleFilter}
|
onChange={handleFilter}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
@ -205,8 +208,7 @@ export function Sidebar() {
|
|||||||
key={game.id}
|
key={game.id}
|
||||||
className={styles.menuItem({
|
className={styles.menuItem({
|
||||||
active:
|
active:
|
||||||
location.pathname ===
|
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
||||||
`/game/${game.shop}/${game.objectID}`,
|
|
||||||
muted: game.status === "removed",
|
muted: game.status === "removed",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@ -241,6 +243,5 @@ export function Sidebar() {
|
|||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,16 +22,6 @@ export const textField = recipe({
|
|||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
focused: {
|
|
||||||
true: {
|
|
||||||
borderColor: "#DADBE1",
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
":hover": {
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
theme: {
|
theme: {
|
||||||
primary: {
|
primary: {
|
||||||
backgroundColor: vars.color.darkBackground,
|
backgroundColor: vars.color.darkBackground,
|
||||||
@ -40,11 +30,21 @@ export const textField = recipe({
|
|||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
state: {
|
hasError: {
|
||||||
error: {
|
true: {
|
||||||
borderColor: vars.color.danger,
|
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",
|
display: "flex",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const errorLabel = style({
|
||||||
|
color: vars.color.danger,
|
||||||
|
});
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import React, { useId, useMemo, useState } from "react";
|
import React, { useId, useMemo, useState } from "react";
|
||||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
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 { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import * as styles from "./text-field.css";
|
||||||
|
|
||||||
export interface TextFieldProps
|
export interface TextFieldProps
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
@ -21,23 +23,27 @@ export interface TextFieldProps
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
rightContent?: React.ReactNode | null;
|
rightContent?: React.ReactNode | null;
|
||||||
state?: NonNullable<RecipeVariants<typeof styles.textField>>["state"];
|
error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextField({
|
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
theme = "primary",
|
theme = "primary",
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
textFieldProps,
|
textFieldProps,
|
||||||
containerProps,
|
containerProps,
|
||||||
rightContent = null,
|
rightContent = null,
|
||||||
state,
|
error,
|
||||||
...props
|
...props
|
||||||
}: TextFieldProps) {
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation("forms");
|
const { t } = useTranslation("forms");
|
||||||
|
|
||||||
@ -48,21 +54,48 @@ export function TextField({
|
|||||||
return props.type ?? "text";
|
return props.type ?? "text";
|
||||||
}, [props.type, isPasswordVisible]);
|
}, [props.type, isPasswordVisible]);
|
||||||
|
|
||||||
|
const hintContent = useMemo(() => {
|
||||||
|
if (error && error.message)
|
||||||
|
return (
|
||||||
|
<small className={styles.errorLabel}>{error.message as string}</small>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hint) return <small>{hint}</small>;
|
||||||
|
return null;
|
||||||
|
}, [hint, error]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.textFieldContainer} {...containerProps}>
|
<div className={styles.textFieldContainer} {...containerProps}>
|
||||||
{label && <label htmlFor={id}>{label}</label>}
|
{label && <label htmlFor={id}>{label}</label>}
|
||||||
|
|
||||||
<div className={styles.textFieldWrapper}>
|
<div className={styles.textFieldWrapper}>
|
||||||
<div
|
<div
|
||||||
className={styles.textField({ focused: isFocused, theme, state })}
|
className={styles.textField({
|
||||||
|
theme,
|
||||||
|
hasError,
|
||||||
|
focused: isFocused,
|
||||||
|
})}
|
||||||
{...textFieldProps}
|
{...textFieldProps}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className={styles.textFieldInput({ readOnly: props.readOnly })}
|
className={styles.textFieldInput({ readOnly: props.readOnly })}
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -85,7 +118,10 @@ export function TextField({
|
|||||||
{rightContent}
|
{rightContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hint && <small>{hint}</small>}
|
{hintContent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TextField.displayName = "TextField";
|
||||||
|
@ -9,3 +9,5 @@ export const DOWNLOADER_NAME = {
|
|||||||
[Downloader.PixelDrain]: "PixelDrain",
|
[Downloader.PixelDrain]: "PixelDrain",
|
||||||
[Downloader.Qiwi]: "Qiwi",
|
[Downloader.Qiwi]: "Qiwi",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||||
|
@ -5,10 +5,17 @@ import { setHeaderTitle } from "@renderer/features";
|
|||||||
import { getSteamLanguage } from "@renderer/helpers";
|
import { getSteamLanguage } from "@renderer/helpers";
|
||||||
import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { GameDetailsContext } from "./game-details.context.types";
|
import { GameDetailsContext } from "./game-details.context.types";
|
||||||
|
import { SteamContentDescriptor } from "@shared";
|
||||||
|
|
||||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||||
game: null,
|
game: null,
|
||||||
@ -22,11 +29,14 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||||||
gameColor: "",
|
gameColor: "",
|
||||||
showRepacksModal: false,
|
showRepacksModal: false,
|
||||||
showGameOptionsModal: false,
|
showGameOptionsModal: false,
|
||||||
|
stats: null,
|
||||||
|
hasNSFWContentBlocked: false,
|
||||||
setGameColor: () => {},
|
setGameColor: () => {},
|
||||||
selectGameExecutable: async () => null,
|
selectGameExecutable: async () => null,
|
||||||
updateGame: async () => {},
|
updateGame: async () => {},
|
||||||
setShowGameOptionsModal: () => {},
|
setShowGameOptionsModal: () => {},
|
||||||
setShowRepacksModal: () => {},
|
setShowRepacksModal: () => {},
|
||||||
|
setHasNSFWContentBlocked: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Provider } = gameDetailsContext;
|
const { Provider } = gameDetailsContext;
|
||||||
@ -41,9 +51,12 @@ export function GameDetailsContextProvider({
|
|||||||
}: GameDetailsContextProps) {
|
}: GameDetailsContextProps) {
|
||||||
const { objectID, shop } = useParams();
|
const { objectID, shop } = useParams();
|
||||||
|
|
||||||
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [gameColor, setGameColor] = useState("");
|
const [gameColor, setGameColor] = useState("");
|
||||||
@ -85,13 +98,25 @@ export function GameDetailsContextProvider({
|
|||||||
getSteamLanguage(i18n.language)
|
getSteamLanguage(i18n.language)
|
||||||
),
|
),
|
||||||
window.electron.searchGameRepacks(gameTitle),
|
window.electron.searchGameRepacks(gameTitle),
|
||||||
|
window.electron.getGameStats(objectID!, shop as GameShop),
|
||||||
])
|
])
|
||||||
.then(([appDetailsResult, repacksResult]) => {
|
.then(([appDetailsResult, repacksResult, statsResult]) => {
|
||||||
if (appDetailsResult.status === "fulfilled")
|
if (appDetailsResult.status === "fulfilled") {
|
||||||
setGameDetails(appDetailsResult.value);
|
setShopDetails(appDetailsResult.value);
|
||||||
|
|
||||||
|
if (
|
||||||
|
appDetailsResult.value!.content_descriptors.ids.includes(
|
||||||
|
SteamContentDescriptor.AdultOnlySexualContent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setHasNSFWContentBlocked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (repacksResult.status === "fulfilled")
|
if (repacksResult.status === "fulfilled")
|
||||||
setRepacks(repacksResult.value);
|
setRepacks(repacksResult.value);
|
||||||
|
|
||||||
|
if (statsResult.status === "fulfilled") setStats(statsResult.value);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -101,7 +126,7 @@ export function GameDetailsContextProvider({
|
|||||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGameDetails(null);
|
setShopDetails(null);
|
||||||
setGame(null);
|
setGame(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setisGameRunning(false);
|
setisGameRunning(false);
|
||||||
@ -167,6 +192,9 @@ export function GameDetailsContextProvider({
|
|||||||
gameColor,
|
gameColor,
|
||||||
showGameOptionsModal,
|
showGameOptionsModal,
|
||||||
showRepacksModal,
|
showRepacksModal,
|
||||||
|
stats,
|
||||||
|
hasNSFWContentBlocked,
|
||||||
|
setHasNSFWContentBlocked,
|
||||||
setGameColor,
|
setGameColor,
|
||||||
selectGameExecutable,
|
selectGameExecutable,
|
||||||
updateGame,
|
updateGame,
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
|
import type {
|
||||||
|
Game,
|
||||||
|
GameRepack,
|
||||||
|
GameShop,
|
||||||
|
GameStats,
|
||||||
|
ShopDetails,
|
||||||
|
} from "@types";
|
||||||
|
|
||||||
export interface GameDetailsContext {
|
export interface GameDetailsContext {
|
||||||
game: Game | null;
|
game: Game | null;
|
||||||
@ -12,9 +18,12 @@ export interface GameDetailsContext {
|
|||||||
gameColor: string;
|
gameColor: string;
|
||||||
showRepacksModal: boolean;
|
showRepacksModal: boolean;
|
||||||
showGameOptionsModal: boolean;
|
showGameOptionsModal: boolean;
|
||||||
|
stats: GameStats | null;
|
||||||
|
hasNSFWContentBlocked: boolean;
|
||||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||||
selectGameExecutable: () => Promise<string | null>;
|
selectGameExecutable: () => Promise<string | null>;
|
||||||
updateGame: () => Promise<void>;
|
updateGame: () => Promise<void>;
|
||||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./game-details/game-details.context";
|
export * from "./game-details/game-details.context";
|
||||||
export * from "./settings/settings.context";
|
export * from "./settings/settings.context";
|
||||||
|
export * from "./user-profile/user-profile.context";
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { createContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { setUserPreferences } from "@renderer/features";
|
import { setUserPreferences } from "@renderer/features";
|
||||||
import { useAppDispatch } from "@renderer/hooks";
|
import { useAppDispatch } from "@renderer/hooks";
|
||||||
import type { UserPreferences } from "@types";
|
import type { UserBlocks, UserPreferences } from "@types";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
export interface SettingsContext {
|
export interface SettingsContext {
|
||||||
@ -11,6 +11,8 @@ export interface SettingsContext {
|
|||||||
clearSourceUrl: () => void;
|
clearSourceUrl: () => void;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
currentCategoryIndex: number;
|
currentCategoryIndex: number;
|
||||||
|
blockedUsers: UserBlocks["blocks"];
|
||||||
|
fetchBlockedUsers: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsContext = createContext<SettingsContext>({
|
export const settingsContext = createContext<SettingsContext>({
|
||||||
@ -19,6 +21,8 @@ export const settingsContext = createContext<SettingsContext>({
|
|||||||
clearSourceUrl: () => {},
|
clearSourceUrl: () => {},
|
||||||
sourceUrl: null,
|
sourceUrl: null,
|
||||||
currentCategoryIndex: 0,
|
currentCategoryIndex: 0,
|
||||||
|
blockedUsers: [],
|
||||||
|
fetchBlockedUsers: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Provider } = settingsContext;
|
const { Provider } = settingsContext;
|
||||||
@ -35,6 +39,8 @@ export function SettingsContextProvider({
|
|||||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||||
|
|
||||||
|
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const defaultSourceUrl = searchParams.get("urls");
|
const defaultSourceUrl = searchParams.get("urls");
|
||||||
|
|
||||||
@ -48,6 +54,15 @@ export function SettingsContextProvider({
|
|||||||
}
|
}
|
||||||
}, [defaultSourceUrl]);
|
}, [defaultSourceUrl]);
|
||||||
|
|
||||||
|
const fetchBlockedUsers = useCallback(async () => {
|
||||||
|
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
|
||||||
|
setBlockedUsers(blockedUsers.blocks);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlockedUsers();
|
||||||
|
}, [fetchBlockedUsers]);
|
||||||
|
|
||||||
const clearSourceUrl = () => setSourceUrl(null);
|
const clearSourceUrl = () => setSourceUrl(null);
|
||||||
|
|
||||||
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
||||||
@ -63,8 +78,10 @@ export function SettingsContextProvider({
|
|||||||
updateUserPreferences,
|
updateUserPreferences,
|
||||||
setCurrentCategoryIndex,
|
setCurrentCategoryIndex,
|
||||||
clearSourceUrl,
|
clearSourceUrl,
|
||||||
|
fetchBlockedUsers,
|
||||||
currentCategoryIndex,
|
currentCategoryIndex,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
blockedUsers,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
110
src/renderer/src/context/user-profile/user-profile.context.tsx
Normal file
110
src/renderer/src/context/user-profile/user-profile.context.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
19
src/renderer/src/declaration.d.ts
vendored
19
src/renderer/src/declaration.d.ts
vendored
@ -1,3 +1,4 @@
|
|||||||
|
import type { CatalogueCategory } from "@shared";
|
||||||
import type {
|
import type {
|
||||||
AppUpdaterEvent,
|
AppUpdaterEvent,
|
||||||
CatalogueEntry,
|
CatalogueEntry,
|
||||||
@ -18,7 +19,10 @@ import type {
|
|||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
UserFriends,
|
UserFriends,
|
||||||
UserBlocks,
|
UserBlocks,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
GameStats,
|
||||||
TrendingGame,
|
TrendingGame,
|
||||||
|
UserStats,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
|
||||||
@ -40,7 +44,7 @@ declare global {
|
|||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||||
getCatalogue: () => Promise<CatalogueEntry[]>;
|
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||||
getGameShopDetails: (
|
getGameShopDetails: (
|
||||||
objectID: string,
|
objectID: string,
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
@ -57,6 +61,7 @@ declare global {
|
|||||||
prevCursor?: number
|
prevCursor?: number
|
||||||
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
|
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
|
||||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||||
|
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
@ -67,6 +72,7 @@ declare global {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
createGameShortcut: (id: number) => Promise<boolean>;
|
createGameShortcut: (id: number) => Promise<boolean>;
|
||||||
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
|
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
|
||||||
|
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
||||||
getLibrary: () => Promise<LibraryGame[]>;
|
getLibrary: () => Promise<LibraryGame[]>;
|
||||||
openGameInstaller: (gameId: number) => Promise<boolean>;
|
openGameInstaller: (gameId: number) => Promise<boolean>;
|
||||||
openGameInstallerPath: (gameId: number) => Promise<boolean>;
|
openGameInstallerPath: (gameId: number) => Promise<boolean>;
|
||||||
@ -138,11 +144,20 @@ declare global {
|
|||||||
take: number,
|
take: number,
|
||||||
skip: number
|
skip: number
|
||||||
) => Promise<UserFriends>;
|
) => 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 */
|
/* Profile */
|
||||||
getMe: () => Promise<UserProfile | null>;
|
getMe: () => Promise<UserProfile | null>;
|
||||||
undoFriendship: (userId: string) => Promise<void>;
|
undoFriendship: (userId: string) => Promise<void>;
|
||||||
|
updateProfile: (
|
||||||
|
updateProfile: UpdateProfileRequest
|
||||||
|
) => Promise<UserProfile>;
|
||||||
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
|
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
|
||||||
processProfileImage: (
|
processProfileImage: (
|
||||||
path: string
|
path: string
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
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 {
|
export interface UserDetailsState {
|
||||||
userDetails: UserDetails | null;
|
userDetails: UserProfile | null;
|
||||||
profileBackground: null | string;
|
profileBackground: null | string;
|
||||||
friendRequests: FriendRequest[];
|
friendRequests: FriendRequest[];
|
||||||
isFriendsModalVisible: boolean;
|
isFriendsModalVisible: boolean;
|
||||||
@ -24,7 +24,7 @@ export const userDetailsSlice = createSlice({
|
|||||||
name: "user-details",
|
name: "user-details",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setUserDetails: (state, action: PayloadAction<UserDetails | null>) => {
|
setUserDetails: (state, action: PayloadAction<UserProfile | null>) => {
|
||||||
state.userDetails = action.payload;
|
state.userDetails = action.payload;
|
||||||
},
|
},
|
||||||
setProfileBackground: (state, action: PayloadAction<string | null>) => {
|
setProfileBackground: (state, action: PayloadAction<string | null>) => {
|
||||||
|
@ -1,16 +1,6 @@
|
|||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
import { average } from "color.js";
|
|
||||||
|
|
||||||
export const steamUrlBuilder = {
|
|
||||||
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) => {
|
export const formatDownloadProgress = (progress?: number) => {
|
||||||
if (!progress) return "0%";
|
if (!progress) return "0%";
|
||||||
@ -46,14 +36,3 @@ export const buildGameDetailsPath = (
|
|||||||
|
|
||||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
||||||
new Color(color).darken(amount).alpha(alpha).toString();
|
new Color(color).darken(amount).alpha(alpha).toString();
|
||||||
|
|
||||||
export const profileBackgroundFromProfileImage = async (
|
|
||||||
profileImageUrl: string
|
|
||||||
) => {
|
|
||||||
const output = await average(profileImageUrl, {
|
|
||||||
amount: 1,
|
|
||||||
format: "hex",
|
|
||||||
});
|
|
||||||
|
|
||||||
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
|
|
||||||
};
|
|
||||||
|
@ -4,3 +4,4 @@ export * from "./use-date";
|
|||||||
export * from "./use-toast";
|
export * from "./use-toast";
|
||||||
export * from "./redux";
|
export * from "./redux";
|
||||||
export * from "./use-user-details";
|
export * from "./use-user-details";
|
||||||
|
export * from "./use-format";
|
||||||
|
@ -25,11 +25,10 @@ export function useDownload() {
|
|||||||
const startDownload = async (payload: StartGameDownloadPayload) => {
|
const startDownload = async (payload: StartGameDownloadPayload) => {
|
||||||
dispatch(clearDownload());
|
dispatch(clearDownload());
|
||||||
|
|
||||||
return window.electron.startGameDownload(payload).then((game) => {
|
const game = await window.electron.startGameDownload(payload);
|
||||||
updateLibrary();
|
|
||||||
|
|
||||||
|
await updateLibrary();
|
||||||
return game;
|
return game;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pauseDownload = async (gameId: number) => {
|
const pauseDownload = async (gameId: number) => {
|
||||||
|
14
src/renderer/src/hooks/use-format.ts
Normal file
14
src/renderer/src/hooks/use-format.ts
Normal 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 };
|
||||||
|
}
|
0
src/renderer/src/hooks/use-friendship.ts
Normal file
0
src/renderer/src/hooks/use-friendship.ts
Normal file
@ -7,10 +7,12 @@ import {
|
|||||||
setFriendsModalVisible,
|
setFriendsModalVisible,
|
||||||
setFriendsModalHidden,
|
setFriendsModalHidden,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import { profileBackgroundFromProfileImage } from "@renderer/helpers";
|
import type {
|
||||||
import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
|
FriendRequestAction,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
UserProfile,
|
||||||
|
} from "@types";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||||
import { logger } from "@renderer/logger";
|
|
||||||
|
|
||||||
export function useUserDetails() {
|
export function useUserDetails() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -38,17 +40,18 @@ export function useUserDetails() {
|
|||||||
}, [clearUserDetails]);
|
}, [clearUserDetails]);
|
||||||
|
|
||||||
const updateUserDetails = useCallback(
|
const updateUserDetails = useCallback(
|
||||||
async (userDetails: UserDetails) => {
|
async (userDetails: UserProfile) => {
|
||||||
dispatch(setUserDetails(userDetails));
|
dispatch(setUserDetails(userDetails));
|
||||||
|
|
||||||
if (userDetails.profileImageUrl) {
|
if (userDetails.profileImageUrl) {
|
||||||
const profileBackground = await profileBackgroundFromProfileImage(
|
// TODO: Decide if we want to use this
|
||||||
userDetails.profileImageUrl
|
// const profileBackground = await profileBackgroundFromProfileImage(
|
||||||
).catch((err) => {
|
// userDetails.profileImageUrl
|
||||||
logger.error("profileBackgroundFromProfileImage", err);
|
// ).catch((err) => {
|
||||||
return `#151515B3`;
|
// logger.error("profileBackgroundFromProfileImage", err);
|
||||||
});
|
// return `#151515B3`;
|
||||||
dispatch(setProfileBackground(profileBackground));
|
// });
|
||||||
|
// dispatch(setProfileBackground(profileBackground));
|
||||||
|
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
"userDetails",
|
"userDetails",
|
||||||
@ -64,7 +67,7 @@ export function useUserDetails() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch, profileBackground]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchUserDetails = useCallback(async () => {
|
const fetchUserDetails = useCallback(async () => {
|
||||||
@ -78,14 +81,14 @@ export function useUserDetails() {
|
|||||||
}, [clearUserDetails]);
|
}, [clearUserDetails]);
|
||||||
|
|
||||||
const patchUser = useCallback(
|
const patchUser = useCallback(
|
||||||
async (props: UpdateProfileProps) => {
|
async (values: UpdateProfileRequest) => {
|
||||||
const response = await window.electron.updateProfile(props);
|
const response = await window.electron.updateProfile(values);
|
||||||
return updateUserDetails(response);
|
return updateUserDetails(response);
|
||||||
},
|
},
|
||||||
[updateUserDetails]
|
[updateUserDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchFriendRequests = useCallback(() => {
|
const fetchFriendRequests = useCallback(async () => {
|
||||||
return window.electron
|
return window.electron
|
||||||
.getFriendRequests()
|
.getFriendRequests()
|
||||||
.then((friendRequests) => {
|
.then((friendRequests) => {
|
||||||
@ -124,13 +127,10 @@ export function useUserDetails() {
|
|||||||
[fetchFriendRequests]
|
[fetchFriendRequests]
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoFriendship = (userId: string) => {
|
const undoFriendship = (userId: string) =>
|
||||||
return window.electron.undoFriendship(userId);
|
window.electron.undoFriendship(userId);
|
||||||
};
|
|
||||||
|
|
||||||
const blockUser = (userId: string) => {
|
const blockUser = (userId: string) => window.electron.blockUser(userId);
|
||||||
return window.electron.blockUser(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const unblockUser = (userId: string) => {
|
const unblockUser = (userId: string) => {
|
||||||
return window.electron.unblockUser(userId);
|
return window.electron.unblockUser(userId);
|
||||||
|
@ -22,12 +22,12 @@ import {
|
|||||||
SearchResults,
|
SearchResults,
|
||||||
Settings,
|
Settings,
|
||||||
Catalogue,
|
Catalogue,
|
||||||
|
Profile,
|
||||||
} from "@renderer/pages";
|
} from "@renderer/pages";
|
||||||
|
|
||||||
import { store } from "./store";
|
import { store } from "./store";
|
||||||
|
|
||||||
import resources from "@locales";
|
import resources from "@locales";
|
||||||
import { User } from "./pages/user/user";
|
|
||||||
|
|
||||||
Sentry.init({});
|
Sentry.init({});
|
||||||
|
|
||||||
@ -41,8 +41,14 @@ i18n
|
|||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
const userPreferences = await window.electron.getUserPreferences();
|
||||||
|
|
||||||
|
if (userPreferences?.language) {
|
||||||
|
i18n.changeLanguage(userPreferences.language);
|
||||||
|
} else {
|
||||||
window.electron.updateUserPreferences({ language: i18n.language });
|
window.electron.updateUserPreferences({ language: i18n.language });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
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="/game/:shop/:objectID" Component={GameDetails} />
|
||||||
<Route path="/search" Component={SearchResults} />
|
<Route path="/search" Component={SearchResults} />
|
||||||
<Route path="/settings" Component={Settings} />
|
<Route path="/settings" Component={Settings} />
|
||||||
<Route path="/user/:userId" Component={User} />
|
<Route path="/profile/:userId" Component={Profile} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
|
@ -6,10 +6,9 @@ import { Badge, Button } from "@renderer/components";
|
|||||||
import {
|
import {
|
||||||
buildGameDetailsPath,
|
buildGameDetailsPath,
|
||||||
formatDownloadProgress,
|
formatDownloadProgress,
|
||||||
steamUrlBuilder,
|
|
||||||
} from "@renderer/helpers";
|
} from "@renderer/helpers";
|
||||||
|
|
||||||
import { Downloader, formatBytes } from "@shared";
|
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
|
||||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||||
import { useAppSelector, useDownload } from "@renderer/hooks";
|
import { useAppSelector, useDownload } from "@renderer/hooks";
|
||||||
|
|
||||||
|
@ -2,8 +2,6 @@ import { useContext, useEffect, useRef, useState } from "react";
|
|||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
|
|
||||||
import { steamUrlBuilder } from "@renderer/helpers";
|
|
||||||
|
|
||||||
import { HeroPanel } from "./hero";
|
import { HeroPanel } from "./hero";
|
||||||
import { DescriptionHeader } from "./description-header/description-header";
|
import { DescriptionHeader } from "./description-header/description-header";
|
||||||
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
||||||
@ -12,6 +10,7 @@ import { Sidebar } from "./sidebar/sidebar";
|
|||||||
import * as styles from "./game-details.css";
|
import * as styles from "./game-details.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
const HERO_ANIMATION_THRESHOLD = 25;
|
const HERO_ANIMATION_THRESHOLD = 25;
|
||||||
|
|
||||||
@ -22,8 +21,14 @@ export function GameDetailsContent() {
|
|||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const { objectID, shopDetails, game, gameColor, setGameColor } =
|
const {
|
||||||
useContext(gameDetailsContext);
|
objectID,
|
||||||
|
shopDetails,
|
||||||
|
game,
|
||||||
|
gameColor,
|
||||||
|
setGameColor,
|
||||||
|
hasNSFWContentBlocked,
|
||||||
|
} = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||||
|
|
||||||
@ -65,7 +70,7 @@ export function GameDetailsContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.libraryHero(objectID!)}
|
src={steamUrlBuilder.libraryHero(objectID!)}
|
||||||
className={styles.heroImage}
|
className={styles.heroImage}
|
||||||
@ -94,7 +99,7 @@ export function GameDetailsContent() {
|
|||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.logo(objectID!)}
|
src={steamUrlBuilder.logo(objectID!)}
|
||||||
style={{ width: 300, alignSelf: "flex-end" }}
|
className={styles.gameLogo}
|
||||||
alt={game?.title}
|
alt={game?.title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,7 +43,7 @@ export function GameDetailsSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={sidebarStyles.contentSidebar}>
|
<div className={sidebarStyles.contentSidebar}>
|
||||||
<div className={sidebarStyles.contentSidebarTitle}>
|
{/* <div className={sidebarStyles.contentSidebarTitle}>
|
||||||
<h3>HowLongToBeat</h3>
|
<h3>HowLongToBeat</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul className={sidebarStyles.howLongToBeatCategoriesList}>
|
<ul className={sidebarStyles.howLongToBeatCategoriesList}>
|
||||||
@ -53,7 +53,7 @@ export function GameDetailsSkeleton() {
|
|||||||
className={sidebarStyles.howLongToBeatCategorySkeleton}
|
className={sidebarStyles.howLongToBeatCategorySkeleton}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul> */}
|
||||||
<div
|
<div
|
||||||
className={sidebarStyles.contentSidebarTitle}
|
className={sidebarStyles.contentSidebarTitle}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
|
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
export const HERO_HEIGHT = 300;
|
export const HERO_HEIGHT = 300;
|
||||||
|
|
||||||
@ -9,12 +10,22 @@ export const slideIn = keyframes({
|
|||||||
"100%": { transform: "translateY(0)" },
|
"100%": { transform: "translateY(0)" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const wrapper = style({
|
export const wrapper = recipe({
|
||||||
|
base: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
transition: "all ease 0.3s",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
blurredContent: {
|
||||||
|
true: {
|
||||||
|
filter: "blur(20px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const hero = style({
|
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({
|
export const heroImageSkeleton = style({
|
||||||
height: "300px",
|
height: "300px",
|
||||||
"@media": {
|
"@media": {
|
||||||
|
@ -3,7 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { GameRepack, GameShop, Steam250Game } from "@types";
|
import { GameRepack, GameShop, Steam250Game } from "@types";
|
||||||
|
|
||||||
import { Button } from "@renderer/components";
|
import { Button, ConfirmationModal } from "@renderer/components";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
|
||||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||||
@ -83,6 +83,8 @@ export function GameDetails() {
|
|||||||
shop,
|
shop,
|
||||||
showRepacksModal,
|
showRepacksModal,
|
||||||
showGameOptionsModal,
|
showGameOptionsModal,
|
||||||
|
hasNSFWContentBlocked,
|
||||||
|
setHasNSFWContentBlocked,
|
||||||
updateGame,
|
updateGame,
|
||||||
setShowRepacksModal,
|
setShowRepacksModal,
|
||||||
setShowGameOptionsModal,
|
setShowGameOptionsModal,
|
||||||
@ -107,6 +109,11 @@ export function GameDetails() {
|
|||||||
setShowGameOptionsModal(false);
|
setShowGameOptionsModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNSFWContentRefuse = () => {
|
||||||
|
setHasNSFWContentBlocked(false);
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme
|
<SkeletonTheme
|
||||||
baseColor={vars.color.background}
|
baseColor={vars.color.background}
|
||||||
@ -120,6 +127,19 @@ export function GameDetails() {
|
|||||||
onClose={() => setShowRepacksModal(false)}
|
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 && (
|
{game && (
|
||||||
<GameOptionsModal
|
<GameOptionsModal
|
||||||
visible={showGameOptionsModal}
|
visible={showGameOptionsModal}
|
||||||
|
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import * as styles from "./hero-panel-actions.css";
|
import * as styles from "./hero-panel-actions.css";
|
||||||
|
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
|
import { DownloadIcon } from "@renderer/components/sidebar/download-icon";
|
||||||
|
|
||||||
export function HeroPanelActions() {
|
export function HeroPanelActions() {
|
||||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||||
@ -25,6 +26,11 @@ export function HeroPanelActions() {
|
|||||||
selectGameExecutable,
|
selectGameExecutable,
|
||||||
} = useContext(gameDetailsContext);
|
} = useContext(gameDetailsContext);
|
||||||
|
|
||||||
|
const { lastPacket } = useDownload();
|
||||||
|
|
||||||
|
const isGameDownloading =
|
||||||
|
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||||
|
|
||||||
const { updateLibrary } = useLibrary();
|
const { updateLibrary } = useLibrary();
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
@ -84,6 +90,47 @@ export function HeroPanelActions() {
|
|||||||
</Button>
|
</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) {
|
if (repacks.length && !game) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -96,26 +143,7 @@ export function HeroPanelActions() {
|
|||||||
if (game) {
|
if (game) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{isGameRunning ? (
|
{gameActionButton()}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.separator} />
|
<div className={styles.separator} />
|
||||||
|
|
||||||
|
@ -2,19 +2,20 @@ import { useContext, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as styles from "./hero-panel.css";
|
import * as styles from "./hero-panel.css";
|
||||||
import { formatDownloadProgress } from "@renderer/helpers";
|
import { formatDownloadProgress } from "@renderer/helpers";
|
||||||
import { useDate, useDownload } from "@renderer/hooks";
|
import { useDate, useDownload, useFormat } from "@renderer/hooks";
|
||||||
import { Link } from "@renderer/components";
|
import { Link } from "@renderer/components";
|
||||||
|
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
|
||||||
|
|
||||||
export function HeroPanelPlaytime() {
|
export function HeroPanelPlaytime() {
|
||||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||||
|
|
||||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { i18n, t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
const { progress, lastPacket } = useDownload();
|
const { progress, lastPacket } = useDownload();
|
||||||
|
|
||||||
@ -30,13 +31,7 @@ export function HeroPanelPlaytime() {
|
|||||||
}
|
}
|
||||||
}, [game?.lastTimePlayed, formatDistance]);
|
}, [game?.lastTimePlayed, formatDistance]);
|
||||||
|
|
||||||
const numberFormatter = useMemo(() => {
|
const formattedPlayTime = useMemo(() => {
|
||||||
return new Intl.NumberFormat(i18n.language, {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
const formatPlayTime = () => {
|
|
||||||
const milliseconds = game?.playTimeInMilliseconds || 0;
|
const milliseconds = game?.playTimeInMilliseconds || 0;
|
||||||
const seconds = milliseconds / 1000;
|
const seconds = milliseconds / 1000;
|
||||||
const minutes = seconds / 60;
|
const minutes = seconds / 60;
|
||||||
@ -49,7 +44,7 @@ export function HeroPanelPlaytime() {
|
|||||||
|
|
||||||
const hours = minutes / 60;
|
const hours = minutes / 60;
|
||||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||||
};
|
}, [game?.playTimeInMilliseconds, numberFormatter, t]);
|
||||||
|
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
|
|
||||||
@ -96,7 +91,7 @@ export function HeroPanelPlaytime() {
|
|||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{t("play_time", {
|
{t("play_time", {
|
||||||
amount: formatPlayTime(),
|
amount: formattedPlayTime,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -8,9 +8,9 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
|||||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||||
|
|
||||||
import type { GameRepack } from "@types";
|
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 { DOWNLOADER_NAME } from "@renderer/constants";
|
||||||
import { useAppSelector } from "@renderer/hooks";
|
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||||
|
|
||||||
export interface DownloadSettingsModalProps {
|
export interface DownloadSettingsModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -31,6 +31,8 @@ export function DownloadSettingsModal({
|
|||||||
}: DownloadSettingsModalProps) {
|
}: DownloadSettingsModalProps) {
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const { showErrorToast } = useToast();
|
||||||
|
|
||||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
|
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
|
||||||
const [selectedPath, setSelectedPath] = useState("");
|
const [selectedPath, setSelectedPath] = useState("");
|
||||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||||
@ -104,9 +106,15 @@ export function DownloadSettingsModal({
|
|||||||
if (repack) {
|
if (repack) {
|
||||||
setDownloadStarting(true);
|
setDownloadStarting(true);
|
||||||
|
|
||||||
startDownload(repack, selectedDownloader!, selectedPath).finally(() => {
|
startDownload(repack, selectedDownloader!, selectedPath)
|
||||||
setDownloadStarting(false);
|
.then(() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showErrorToast(t("download_error"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDownloadStarting(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -121,15 +129,14 @@ export function DownloadSettingsModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div>
|
<div
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
marginBottom: `${SPACING_UNIT}px`,
|
display: "flex",
|
||||||
display: "block",
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("downloader")}
|
<span>{t("downloader")}</span>
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className={styles.downloaders}>
|
<div className={styles.downloaders}>
|
||||||
{downloaders.map((downloader) => (
|
{downloaders.map((downloader) => (
|
||||||
@ -152,6 +159,13 @@ export function DownloadSettingsModal({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -57,8 +57,17 @@ export function GameOptionsModal({
|
|||||||
const path = await selectGameExecutable();
|
const path = await selectGameExecutable();
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
await window.electron.updateExecutablePath(game.id, path);
|
const gameUsingPath =
|
||||||
updateGame();
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,10 +7,6 @@ export const contentSidebar = style({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
"@media": {
|
"@media": {
|
||||||
"(min-width: 768px)": {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: "200px",
|
|
||||||
},
|
|
||||||
"(min-width: 1024px)": {
|
"(min-width: 1024px)": {
|
||||||
maxWidth: "300px",
|
maxWidth: "300px",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@ -70,7 +66,7 @@ export const howLongToBeatCategory = style({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "4px",
|
gap: "4px",
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
borderRadius: "8px",
|
borderRadius: "4px",
|
||||||
padding: `8px 16px`,
|
padding: `8px 16px`,
|
||||||
border: `solid 1px ${vars.color.border}`,
|
border: `solid 1px ${vars.color.border}`,
|
||||||
});
|
});
|
||||||
@ -81,10 +77,39 @@ export const howLongToBeatCategoryLabel = style({
|
|||||||
|
|
||||||
export const howLongToBeatCategorySkeleton = style({
|
export const howLongToBeatCategorySkeleton = style({
|
||||||
border: `solid 1px ${vars.color.border}`,
|
border: `solid 1px ${vars.color.border}`,
|
||||||
borderRadius: "8px",
|
borderRadius: "4px",
|
||||||
height: "76px",
|
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`, {
|
globalStyle(`${requirementsDetails} a`, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
color: vars.color.body,
|
color: vars.color.body,
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
|
||||||
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@renderer/components";
|
import { Button } from "@renderer/components";
|
||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
|
import { useFormat } from "@renderer/hooks";
|
||||||
|
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
const [_howLongToBeat, setHowLongToBeat] = useState<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
data: HowLongToBeatCategory[] | null;
|
data: HowLongToBeatCategory[] | null;
|
||||||
}>({ isLoading: true, data: null });
|
}>({ isLoading: true, data: null });
|
||||||
@ -16,10 +17,13 @@ export function Sidebar() {
|
|||||||
const [activeRequirement, setActiveRequirement] =
|
const [activeRequirement, setActiveRequirement] =
|
||||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
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 { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (objectID) {
|
if (objectID) {
|
||||||
setHowLongToBeat({ isLoading: true, data: null });
|
setHowLongToBeat({ isLoading: true, data: null });
|
||||||
@ -37,14 +41,46 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.contentSidebar}>
|
<aside className={styles.contentSidebar}>
|
||||||
<HowLongToBeatSection
|
{/* <HowLongToBeatSection
|
||||||
howLongToBeatData={howLongToBeat.data}
|
howLongToBeatData={howLongToBeat.data}
|
||||||
isLoading={howLongToBeat.isLoading}
|
isLoading={howLongToBeat.isLoading}
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
|
{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>
|
<h3>{t("requirements")}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.requirementButtonContainer}>
|
<div className={styles.requirementButtonContainer}>
|
||||||
<Button
|
<Button
|
||||||
|
@ -60,3 +60,11 @@ export const noResults = style({
|
|||||||
gap: "16px",
|
gap: "16px",
|
||||||
gridColumn: "1 / -1",
|
gridColumn: "1 / -1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buttonsList = style({
|
||||||
|
display: "flex",
|
||||||
|
listStyle: "none",
|
||||||
|
margin: "0",
|
||||||
|
padding: "0",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
});
|
||||||
|
@ -13,6 +13,7 @@ import * as styles from "./home.css";
|
|||||||
import { vars } from "@renderer/theme.css";
|
import { vars } from "@renderer/theme.css";
|
||||||
import Lottie from "lottie-react";
|
import Lottie from "lottie-react";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import { CatalogueCategory } from "@shared";
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { t } = useTranslation("home");
|
const { t } = useTranslation("home");
|
||||||
@ -21,15 +22,25 @@ export function Home() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
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);
|
setIsLoading(true);
|
||||||
|
|
||||||
window.electron
|
window.electron
|
||||||
.getCatalogue()
|
.getCatalogue(category)
|
||||||
.then((catalogue) => {
|
.then((catalogue) => {
|
||||||
setCatalogue(catalogue);
|
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -56,13 +67,21 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (category: CatalogueCategory) => {
|
||||||
|
if (category !== currentCatalogueCategory) {
|
||||||
|
getCatalogue(category);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
getCatalogue();
|
getCatalogue(CatalogueCategory.Hot);
|
||||||
|
|
||||||
getRandomGame();
|
getRandomGame();
|
||||||
}, [getCatalogue, getRandomGame]);
|
}, [getCatalogue, getRandomGame]);
|
||||||
|
|
||||||
|
const categories = Object.values(CatalogueCategory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||||
<section className={styles.content}>
|
<section className={styles.content}>
|
||||||
@ -71,7 +90,22 @@ export function Home() {
|
|||||||
<Hero />
|
<Hero />
|
||||||
|
|
||||||
<section className={styles.homeHeader}>
|
<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
|
<Button
|
||||||
onClick={handleRandomizerClick}
|
onClick={handleRandomizerClick}
|
||||||
@ -89,12 +123,14 @@ export function Home() {
|
|||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<h2>{t(currentCatalogueCategory)}</h2>
|
||||||
|
|
||||||
<section className={styles.cards}>
|
<section className={styles.cards}>
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 12 }).map((_, index) => (
|
? Array.from({ length: 12 }).map((_, index) => (
|
||||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||||
))
|
))
|
||||||
: catalogue.map((result) => (
|
: catalogue[currentCatalogueCategory].map((result) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={result.objectID}
|
key={result.objectID}
|
||||||
game={result}
|
game={result}
|
||||||
|
@ -7,7 +7,7 @@ import type { DebouncedFunc } from "lodash";
|
|||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
|
|
||||||
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
|
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
|
||||||
import { clearSearch } from "@renderer/features";
|
import { clearSearch, setSearch } from "@renderer/features";
|
||||||
import { useAppDispatch } from "@renderer/hooks";
|
import { useAppDispatch } from "@renderer/hooks";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -37,6 +37,10 @@ export function SearchResults() {
|
|||||||
navigate(buildGameDetailsPath(game));
|
navigate(buildGameDetailsPath(game));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setSearch(searchParams.get("query") ?? ""));
|
||||||
|
}, [dispatch, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||||
|
@ -4,3 +4,4 @@ export * from "./downloads/downloads";
|
|||||||
export * from "./home/search-results";
|
export * from "./home/search-results";
|
||||||
export * from "./settings/settings";
|
export * from "./settings/settings";
|
||||||
export * from "./catalogue/catalogue";
|
export * from "./catalogue/catalogue";
|
||||||
|
export * from "./profile/profile";
|
||||||
|
@ -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",
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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`,
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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",
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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",
|
||||||
|
});
|
347
src/renderer/src/pages/profile/profile-hero/profile-hero.tsx
Normal file
347
src/renderer/src/pages/profile/profile-hero/profile-hero.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
9
src/renderer/src/pages/profile/profile.css.ts
Normal file
9
src/renderer/src/pages/profile/profile.css.ts
Normal 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`,
|
||||||
|
});
|
21
src/renderer/src/pages/profile/profile.tsx
Normal file
21
src/renderer/src/pages/profile/profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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",
|
||||||
|
});
|
131
src/renderer/src/pages/profile/report-profile/report-profile.tsx
Normal file
131
src/renderer/src/pages/profile/report-profile/report-profile.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -4,6 +4,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button, Modal, TextField } from "@renderer/components";
|
import { Button, Modal, TextField } from "@renderer/components";
|
||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||||
import { settingsContext } from "@renderer/context";
|
import { settingsContext } from "@renderer/context";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
|
||||||
interface AddDownloadSourceModalProps {
|
interface AddDownloadSourceModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -11,47 +15,83 @@ interface AddDownloadSourceModalProps {
|
|||||||
onAddDownloadSource: () => void;
|
onAddDownloadSource: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function AddDownloadSourceModal({
|
export function AddDownloadSourceModal({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onAddDownloadSource,
|
onAddDownloadSource,
|
||||||
}: AddDownloadSourceModalProps) {
|
}: AddDownloadSourceModalProps) {
|
||||||
const [value, setValue] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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<{
|
const [validationResult, setValidationResult] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
|
|
||||||
const { sourceUrl } = useContext(settingsContext);
|
const { sourceUrl } = useContext(settingsContext);
|
||||||
|
|
||||||
const handleValidateDownloadSource = useCallback(async (url: string) => {
|
const onSubmit = useCallback(
|
||||||
|
async (values: FormValues) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.validateDownloadSource(url);
|
const result = await window.electron.validateDownloadSource(values.url);
|
||||||
setValidationResult(result);
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[setError, t]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("");
|
setValue("url", "");
|
||||||
|
clearErrors();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
|
|
||||||
if (sourceUrl) {
|
if (sourceUrl) {
|
||||||
setValue(sourceUrl);
|
setValue("url", sourceUrl);
|
||||||
handleValidateDownloadSource(sourceUrl);
|
handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}, [visible, handleValidateDownloadSource, sourceUrl]);
|
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||||
|
|
||||||
const handleAddDownloadSource = async () => {
|
const handleAddDownloadSource = async () => {
|
||||||
await window.electron.addDownloadSource(value);
|
await window.electron.addDownloadSource(url);
|
||||||
onClose();
|
onClose();
|
||||||
onAddDownloadSource();
|
onAddDownloadSource();
|
||||||
};
|
};
|
||||||
@ -72,17 +112,17 @@ export function AddDownloadSourceModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
|
{...register("url")}
|
||||||
label={t("download_source_url")}
|
label={t("download_source_url")}
|
||||||
placeholder={t("insert_valid_json_url")}
|
placeholder={t("insert_valid_json_url")}
|
||||||
value={value}
|
error={errors.url}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
rightContent={
|
rightContent={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
theme="outline"
|
theme="outline"
|
||||||
style={{ alignSelf: "flex-end" }}
|
style={{ alignSelf: "flex-end" }}
|
||||||
onClick={() => handleValidateDownloadSource(value)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
disabled={isLoading || !value}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{t("validate_download_source")}
|
{t("validate_download_source")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -115,7 +155,11 @@ export function AddDownloadSourceModal({
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="button" onClick={handleAddDownloadSource}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddDownloadSource}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{t("import")}
|
{t("import")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,11 +38,9 @@ export function SettingsGeneral() {
|
|||||||
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
|
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchdefaultDownloadsPath() {
|
window.electron.getDefaultDownloadsPath().then((path) => {
|
||||||
setDefaultDownloadsPath(await window.electron.getDefaultDownloadsPath());
|
setDefaultDownloadsPath(path);
|
||||||
}
|
});
|
||||||
|
|
||||||
fetchdefaultDownloadsPath();
|
|
||||||
|
|
||||||
setLanguageOptions(
|
setLanguageOptions(
|
||||||
orderBy(
|
orderBy(
|
||||||
@ -89,6 +87,15 @@ export function SettingsGeneral() {
|
|||||||
|
|
||||||
function updateFormWithUserPreferences() {
|
function updateFormWithUserPreferences() {
|
||||||
if (userPreferences) {
|
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) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
|
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
|
||||||
@ -96,7 +103,7 @@ export function SettingsGeneral() {
|
|||||||
userPreferences.downloadNotificationsEnabled,
|
userPreferences.downloadNotificationsEnabled,
|
||||||
repackUpdatesNotificationsEnabled:
|
repackUpdatesNotificationsEnabled:
|
||||||
userPreferences.repackUpdatesNotificationsEnabled,
|
userPreferences.repackUpdatesNotificationsEnabled,
|
||||||
language: userPreferences.language,
|
language: language ?? "en",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,7 +134,7 @@ export function SettingsGeneral() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<h3>{t("notifications")}</h3>
|
<h3>{t("notifications")}</h3>
|
||||||
<>
|
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
label={t("enable_download_notifications")}
|
label={t("enable_download_notifications")}
|
||||||
checked={form.downloadNotificationsEnabled}
|
checked={form.downloadNotificationsEnabled}
|
||||||
@ -149,6 +156,5 @@ export function SettingsGeneral() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
47
src/renderer/src/pages/settings/settings-privacy.css.ts
Normal file
47
src/renderer/src/pages/settings/settings-privacy.css.ts
Normal 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`,
|
||||||
|
});
|
139
src/renderer/src/pages/settings/settings-privacy.tsx
Normal file
139
src/renderer/src/pages/settings/settings-privacy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -11,10 +11,16 @@ import {
|
|||||||
SettingsContextConsumer,
|
SettingsContextConsumer,
|
||||||
SettingsContextProvider,
|
SettingsContextProvider,
|
||||||
} from "@renderer/context";
|
} from "@renderer/context";
|
||||||
|
import { SettingsPrivacy } from "./settings-privacy";
|
||||||
|
import { useUserDetails } from "@renderer/hooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
|
const { userDetails } = useUserDetails();
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
const categories = [
|
const categories = [
|
||||||
t("general"),
|
t("general"),
|
||||||
t("behavior"),
|
t("behavior"),
|
||||||
@ -22,6 +28,10 @@ export function Settings() {
|
|||||||
"Real-Debrid",
|
"Real-Debrid",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (userDetails) return [...categories, t("privacy")];
|
||||||
|
return categories;
|
||||||
|
}, [userDetails, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContextProvider>
|
<SettingsContextProvider>
|
||||||
<SettingsContextConsumer>
|
<SettingsContextConsumer>
|
||||||
@ -39,7 +49,11 @@ export function Settings() {
|
|||||||
return <SettingsDownloadSources />;
|
return <SettingsDownloadSources />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentCategoryIndex === 3) {
|
||||||
return <SettingsRealDebrid />;
|
return <SettingsRealDebrid />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsPrivacy />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -42,13 +42,13 @@ export const UserFriendModalAddFriend = ({
|
|||||||
|
|
||||||
const handleClickRequest = (userId: string) => {
|
const handleClickRequest = (userId: string) => {
|
||||||
closeModal();
|
closeModal();
|
||||||
navigate(`/user/${userId}`);
|
navigate(`/profile/${userId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickSeeProfile = () => {
|
const handleClickSeeProfile = () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
if (friendCode.length === 8) {
|
if (friendCode.length === 8) {
|
||||||
navigate(`/user/${friendCode}`);
|
navigate(`/profile/${friendCode}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ export const UserFriendModalList = ({
|
|||||||
|
|
||||||
const handleClickFriend = (userId: string) => {
|
const handleClickFriend = (userId: string) => {
|
||||||
closeModal();
|
closeModal();
|
||||||
navigate(`/user/${userId}`);
|
navigate(`/profile/${userId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoFriendship = (userId: string) => {
|
const handleUndoFriendship = (userId: string) => {
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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
Loading…
Reference in New Issue
Block a user