diff --git a/package.json b/package.json index 321f3af6..fc23b92b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "2.0.3", + "version": "2.1.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -35,6 +35,7 @@ "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", "@fontsource/noto-sans": "^5.0.22", + "@hookform/resolvers": "^3.9.0", "@primer/octicons-react": "^19.9.0", "@reduxjs/toolkit": "^2.2.3", "@sentry/electron": "^5.1.0", @@ -64,6 +65,7 @@ "lottie-react": "^2.4.0", "parse-torrent": "^11.0.16", "piscina": "^4.5.1", + "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", @@ -72,6 +74,7 @@ "typeorm": "^0.3.20", "user-agents": "^1.1.193", "yaml": "^2.4.1", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/locales/cs/translation.json b/src/locales/cs/translation.json index e7cbbe6f..ba0e695d 100644 --- a/src/locales/cs/translation.json +++ b/src/locales/cs/translation.json @@ -260,11 +260,6 @@ "request_accepted": "Žádost přijata", "user_blocked_successfully": "Uživatel úspěšně zablokován", "user_block_modal_text": "Tohle zablokuje {{displayName}}", - "settings": "Nastavení", - "public": "Veřejné", - "private": "Soukromé", - "friends_only": "Pouze přátelé", - "privacy": "Soukromí", "blocked_users": "Zablokovaní uživatelé", "unblock": "Odblokovat", "no_friends_added": "Nemáš přidané žádné přátele", diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index e8361961..cc2622aa 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -260,11 +260,6 @@ "request_accepted": "Anfrage akzeptiert", "user_blocked_successfully": "Nutzer erfolgreich blockiert", "user_block_modal_text": "{{displayName}} wird dadurch blockiert", - "settings": "Einstellungen", - "public": "Öffentlich", - "private": "Privat", - "friends_only": "Nur Freunde", - "privacy": "Privatsphäre", "blocked_users": "Blockierte Nutzer", "unblock": "Freigeben", "no_friends_added": "Du hast noch keine Freunde hinzugefügt", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 710288c9..c2a76b39 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -8,7 +8,9 @@ "trending": "Trending", "surprise_me": "Surprise me", "no_results": "No results found", - "start_typing": "Starting typing to search..." + "start_typing": "Starting typing to search...", + "hot": "🔥 Hot now", + "weekly": "📅 Top games of the week" }, "sidebar": { "catalogue": "Catalogue", @@ -22,7 +24,8 @@ "home": "Home", "queued": "{{title}} (Queued)", "game_has_no_executable": "Game has no executable selected", - "sign_in": "Sign in" + "sign_in": "Sign in", + "friends": "Friends" }, "header": { "search": "Search games", @@ -115,7 +118,19 @@ "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", "create_shortcut_success": "Shortcut created successfully", - "create_shortcut_error": "Error creating shortcut" + "create_shortcut_error": "Error creating shortcut", + "nsfw_content_title": "This game contains innapropriate content", + "nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?", + "allow_nsfw_content": "Continue", + "refuse_nsfw_content": "Go back", + "stats": "Stats", + "download_count": "Downloads", + "player_count": "Active players", + "download_error": "This download option is not available", + "download": "Download", + "executable_path_in_use": "Executable already in use by \"{{game}}\"", + "warning": "Warning:", + "hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress." }, "activation": { "title": "Activate Hydra", @@ -191,7 +206,18 @@ "found_download_option_zero": "No download option found", "found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_other": "Found {{countFormatted}} download options", - "import": "Import" + "import": "Import", + "public": "Public", + "private": "Private", + "friends_only": "Friends only", + "privacy": "Privacy", + "profile_visibility": "Profile visibility", + "profile_visibility_description": "Choose who can see your profile and library", + "required_field": "This field is required", + "source_already_exists": "This source has been already added", + "must_be_valid_url": "The source must be a valid URL", + "blocked_users": "Blocked users", + "user_unblocked": "User has been unblocked" }, "notifications": { "download_complete": "Download complete", @@ -261,11 +287,6 @@ "request_accepted": "Request accepted", "user_blocked_successfully": "User blocked successfully", "user_block_modal_text": "This will block {{displayName}}", - "settings": "Settings", - "public": "Public", - "private": "Private", - "friends_only": "Friends only", - "privacy": "Privacy", "blocked_users": "Blocked users", "unblock": "Unblock", "no_friends_added": "You still don't have added friends", @@ -274,6 +295,22 @@ "no_blocked_users": "You have no blocked users", "friend_code_copied": "Friend code copied", "undo_friendship_modal_text": "This will undo your friendship with {{displayName}}", - "image_process_failure": "Failure while processing the image" + "privacy_hint": "To adjust who can see this, go to the <0>Settings", + "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" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 171ac015..b8595d13 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -8,6 +8,8 @@ "trending": "Tendencias", "surprise_me": "¡Sorpréndeme!", "no_results": "No se encontraron resultados", + "hot": "🔥 Caliente ahora", + "weekly": "📅 Los mejores juegos de la semana", "start_typing": "Empieza a escribir para buscar..." }, "sidebar": { @@ -22,7 +24,8 @@ "home": "Inicio", "queued": "{{title}} (En Cola)", "game_has_no_executable": "El juego no tiene un ejecutable", - "sign_in": "Iniciar sesión" + "sign_in": "Iniciar sesión", + "friends": "Amigos" }, "header": { "search": "Buscar juegos", @@ -115,7 +118,17 @@ "download_paused": "Descarga pausada", "last_downloaded_option": "Última opción descargada", "create_shortcut_success": "Atajo creado con éxito", - "create_shortcut_error": "Error al crear un atajo" + "create_shortcut_error": "Error al crear un atajo", + "allow_nsfw_content": "Continuar", + "download": "Descargar", + "download_count": "Descargas", + "download_error": "Esta opción de descarga no está disponible.", + "executable_path_in_use": "Ejecutable ya en uso por \"{{game}}\"", + "nsfw_content_description": "{{title}} incluye contenido que puede no ser adecuado para todas las edades. \n¿Estás seguro de que quieres continuar?", + "nsfw_content_title": "Este juego contiene contenido inapropiado.", + "player_count": "Jugadores activos", + "refuse_nsfw_content": "Volver", + "stats": "Estadísticas" }, "activation": { "title": "Activar Hydra", @@ -191,7 +204,21 @@ "found_download_option_zero": "No se encontró una opción de descarga", "found_download_option_one": "Se encontró {{countFormatted}} opción de descarga", "found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga", - "import": "Importar" + "import": "Importar", + "blocked_users": "Usuarios bloqueados", + "download_options_one": "", + "download_options_other": "", + "download_options_zero": "", + "friends_only": "solo amigos", + "must_be_valid_url": "La fuente debe ser una URL válida.", + "privacy": "Privacidad", + "private": "Privado", + "profile_visibility": "Visibilidad del perfil", + "profile_visibility_description": "Elige quién puede ver tu perfil y biblioteca", + "public": "Público", + "required_field": "Este campo es obligatorio", + "source_already_exists": "Esta fuente ya ha sido agregada.", + "user_unblocked": "El usuario ha sido desbloqueado" }, "notifications": { "download_complete": "Descarga completada", @@ -261,11 +288,6 @@ "request_accepted": "Solicitud aceptada", "user_blocked_successfully": "Usuario bloqueado exitosamente", "user_block_modal_text": "Esto va a bloquear a {{displayName}}", - "settings": "Ajustes", - "public": "Público", - "private": "Privado", - "friends_only": "Solo Amigos", - "privacy": "Privacidad", "blocked_users": "Usuarios bloqueados", "unblock": "Desbloquear", "no_friends_added": "Todavía no tienes amigos añadidos", @@ -274,6 +296,23 @@ "no_blocked_users": "No has bloqueado a ningún usuario", "friend_code_copied": "Código de amigo copiado", "undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}", + "displayname_max_length": "El nombre para mostrar debe tener como máximo 50 caracteres", + "displayname_min_length": "El nombre para mostrar debe tener al menos 3 caracteres", + "locked_profile": "Este perfil es privado.", + "privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración.", + "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" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 1303f7c8..28fa6571 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -6,6 +6,8 @@ "home": { "featured": "Destaques", "trending": "Populares", + "hot": "🔥 Populares agora", + "weekly": "📅 Mais baixados da semana", "surprise_me": "Surpreenda-me", "no_results": "Nenhum resultado encontrado", "start_typing": "Comece a digitar para pesquisar…" @@ -22,7 +24,8 @@ "home": "Início", "queued": "{{title}} (Na fila)", "game_has_no_executable": "Jogo não possui executável selecionado", - "sign_in": "Login" + "sign_in": "Login", + "friends": "Amigos" }, "header": { "search": "Buscar jogos", @@ -111,7 +114,19 @@ "download_paused": "Download pausado", "last_downloaded_option": "Última opção baixada", "create_shortcut_success": "Atalho criado com sucesso", - "create_shortcut_error": "Erro ao criar atalho" + "create_shortcut_error": "Erro ao criar atalho", + "nsfw_content_title": "Este jogo contém conteúdo inapropriado", + "nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?", + "allow_nsfw_content": "Continuar", + "refuse_nsfw_content": "Voltar", + "stats": "Estatísticas", + "download_count": "Downloads", + "player_count": "Jogadores ativos", + "download_error": "Essa opção de download falhou", + "download": "Baixar", + "executable_path_in_use": "Executável em uso por \"{{game}}\"", + "warning": "Aviso:", + "hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso." }, "activation": { "title": "Ativação", @@ -190,7 +205,18 @@ "found_download_option_zero": "Nenhuma opção de download encontrada", "found_download_option_one": "{{countFormatted}} opção de download encontrada", "found_download_option_other": "{{countFormatted}} opções de download encontradas", - "import": "Importar" + "import": "Importar", + "privacy": "Privacidade", + "private": "Privado", + "friends_only": "Apenas amigos", + "public": "Público", + "profile_visibility": "Visibilidade do perfil", + "profile_visibility_description": "Escolha quem pode ver seu perfil e biblioteca", + "required_field": "Este campo é obrigatório", + "source_already_exists": "Essa fonte já foi adicionada", + "must_be_valid_url": "A fonte deve ser uma URL válida", + "blocked_users": "Usuários bloqueados", + "user_unblocked": "Usuário desbloqueado" }, "notifications": { "download_complete": "Download concluído", @@ -241,7 +267,7 @@ "cancel": "Cancelar", "successfully_signed_out": "Deslogado com sucesso", "sign_out": "Sair da conta", - "sign_out_modal_title": "Tem certeza?", + "sign_out_modal_title": "Deseja mesmo sair?", "playing_for": "Jogando por {{amount}}", "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?", "add_friends": "Adicionar Amigos", @@ -264,11 +290,6 @@ "request_accepted": "Pedido de amizade aceito", "user_blocked_successfully": "Usuário bloqueado com sucesso", "user_block_modal_text": "Bloquear {{displayName}}", - "settings": "Configurações", - "privacy": "Privacidade", - "private": "Privado", - "friends_only": "Apenas amigos", - "public": "Público", "blocked_users": "Usuários bloqueados", "unblock": "Desbloquear", "no_friends_added": "Você ainda não possui amigos adicionados", @@ -277,6 +298,23 @@ "no_blocked_users": "Você não tem nenhum usuário bloqueado", "friend_code_copied": "Código de amigo copiado", "undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}", - "image_process_failure": "Falha ao processar a imagem" + "privacy_hint": "Pra controlar quem pode ver seu perfil, acesse a <0>Tela de Configurações", + "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" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index cd4fc44c..7e16f762 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -111,7 +111,11 @@ "download_paused": "Transferência pausada", "last_downloaded_option": "Última opção transferida", "create_shortcut_success": "Atalho criado com sucesso", - "create_shortcut_error": "Erro ao criar atalho" + "create_shortcut_error": "Erro ao criar atalho", + "download": "Transferir", + "executable_path_in_use": "Executável em uso por \"{{game}}\"", + "warning": "Aviso:", + "hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso." }, "activation": { "title": "Ativação", @@ -169,8 +173,6 @@ "save_changes": "Guardar alterações", "changes_saved": "Definições guardadas com sucesso", "download_sources_description": "O Hydra vai procurar links de transferência em todas as fontes ativadas. A URL da página de detalhes da loja não é guardada no seu dispositivo. Utilizamos um sistema de metadados criado pela comunidade para fornecer suporte a mais fontes de transferência de jogos.", - "enable_source": "Ativar", - "disable_source": "Desativar", "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", @@ -266,11 +268,6 @@ "request_accepted": "Pedido de amizade aceito", "user_blocked_successfully": "Utilizador bloqueado com sucesso", "user_block_modal_text": "Bloquear {{displayName}}", - "settings": "Definições", - "privacy": "Privacidade", - "private": "Privado", - "friends_only": "Apenas amigos", - "public": "Público", "blocked_users": "Utilizadores bloqueados", "unblock": "Desbloquear", "no_friends_added": "Ainda não adicionaste amigos", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index be3a000e..e3dfbad5 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -7,7 +7,10 @@ "featured": "Рекомендованное", "trending": "В тренде", "surprise_me": "Удиви меня", - "no_results": "Ничего не найдено" + "no_results": "Ничего не найдено", + "hot": "🔥 Сейчас жарко", + "start_typing": "Начинаю вводить текст для поиска...", + "weekly": "📅 Лучшие игры недели" }, "sidebar": { "catalogue": "Каталог", @@ -21,7 +24,8 @@ "home": "Главная", "queued": "{{title}} (В очереди)", "game_has_no_executable": "Файл запуска игры не выбран", - "sign_in": "Войти" + "sign_in": "Войти", + "friends": "Друзья" }, "header": { "search": "Поиск", @@ -114,7 +118,17 @@ "download_paused": "Загрузка приостановлена", "last_downloaded_option": "Последний вариант загрузки", "create_shortcut_success": "Ярлык создан", - "create_shortcut_error": "Не удалось создать ярлык" + "create_shortcut_error": "Не удалось создать ярлык", + "allow_nsfw_content": "Продолжать", + "download": "Скачать", + "download_count": "Загрузки", + "download_error": "Этот вариант загрузки недоступен", + "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", + "nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?", + "nsfw_content_title": "Эта игра содержит неприемлемый контент", + "player_count": "Активные игроки", + "refuse_nsfw_content": "Возвращаться", + "stats": "Статистика" }, "activation": { "title": "Активировать Hydra", @@ -190,7 +204,21 @@ "found_download_option_zero": "Не найдено вариантов загрузки", "found_download_option_one": "Найден {{countFormatted}} вариант загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", - "import": "Импортировать" + "import": "Импортировать", + "blocked_users": "Заблокированные пользователи", + "download_options_one": "", + "download_options_other": "", + "download_options_zero": "", + "friends_only": "Только друзья", + "must_be_valid_url": "Источник должен быть действительным URL-адресом.", + "privacy": "Конфиденциальность", + "private": "Частный", + "profile_visibility": "Видимость профиля", + "profile_visibility_description": "Выберите, кто может видеть ваш профиль и библиотеку", + "public": "Общественный", + "required_field": "Это поле обязательно к заполнению", + "source_already_exists": "Этот источник уже добавлен", + "user_unblocked": "Пользователь разблокирован" }, "notifications": { "download_complete": "Загрузка завершена", @@ -260,17 +288,31 @@ "request_accepted": "Запрос принят", "user_blocked_successfully": "Пользователь успешно заблокирован", "user_block_modal_text": "{{displayName}} будет заблокирован", - "settings": "Настройки", - "public": "Публичный", - "private": "Приватный", - "friends_only": "Только друзья", - "privacy": "Приватность", "blocked_users": "Заблокированные пользователи", "unblock": "Разблокировать", "no_friends_added": "Вы ещё не добавили ни одного друга", "pending": "Ожидание", "no_pending_invites": "У вас нет запросов ожидающих ответа", "no_blocked_users": "Вы не заблокировали ни одного пользователя", - "friend_code_copied": "Код друга скопирован" + "friend_code_copied": "Код друга скопирован", + "displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.", + "displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.", + "image_process_failure": "Сбой при обработке изображения", + "locked_profile": "Этот профиль является частным", + "privacy_hint": "Чтобы указать, кто может это видеть, перейдите в <0>Настройки.", + "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}}." } } diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 9998c733..724626b7 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -1,6 +1,11 @@ import { registerEvent } from "../register-event"; import * as Sentry from "@sentry/electron/main"; -import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services"; +import { + DownloadManager, + HydraApi, + PythonInstance, + gamesPlaytime, +} from "@main/services"; import { dataSource } from "@main/data-source"; import { DownloadQueue, Game, UserAuth } from "@main/entity"; @@ -23,6 +28,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { /* Removes user from Sentry */ Sentry.setUser(null); + /* Cancels any ongoing downloads */ + DownloadManager.cancelDownload(); + /* Disconnects libtorrent */ PythonInstance.killTorrent(); diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index fc772428..8d6183a5 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -1,36 +1,44 @@ -import { getSteamAppAsset } from "@main/helpers"; -import type { CatalogueEntry, GameShop } from "@types"; +import type { GameShop } from "@types"; import { registerEvent } from "../register-event"; -import { RepacksManager, requestSteam250 } from "@main/services"; -import { formatName } from "@shared"; +import { HydraApi, RepacksManager } from "@main/services"; +import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared"; +import { steamGamesWorker } from "@main/workers"; -const resultSize = 12; +const getCatalogue = async ( + _event: Electron.IpcMainInvokeEvent, + category: CatalogueCategory +) => { + const params = new URLSearchParams({ + take: "12", + skip: "0", + }); -const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => { - const trendingGames = await requestSteam250("/90day"); - const results: CatalogueEntry[] = []; + const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>( + `/games/${category}?${params.toString()}`, + {}, + { needsAuth: false } + ); - for (let i = 0; i < resultSize; i++) { - if (!trendingGames[i]) { - i++; - continue; - } + return Promise.all( + response.map(async (game) => { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); - const { title, objectID } = trendingGames[i]!; - const repacks = RepacksManager.search({ query: formatName(title) }); + const repacks = RepacksManager.search({ + query: formatName(steamGame.name), + }); - const catalogueEntry = { - objectID, - title, - shop: "steam" as GameShop, - cover: getSteamAppAsset("library", objectID), - }; - - results.push({ ...catalogueEntry, repacks }); - } - - return results; + return { + title: steamGame.name, + shop: game.shop, + repacks, + cover: steamUrlBuilder.library(game.objectId), + objectID: game.objectId, + }; + }) + ); }; registerEvent("getCatalogue", getCatalogue); diff --git a/src/main/events/catalogue/get-game-stats.ts b/src/main/events/catalogue/get-game-stats.ts new file mode 100644 index 00000000..87cba054 --- /dev/null +++ b/src/main/events/catalogue/get-game-stats.ts @@ -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( + `/games/stats?${params.toString()}` + ); + return response; +}; + +registerEvent("getGameStats", getGameStats); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index c5878dcb..5fb5098e 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -1,8 +1,8 @@ import type { GameShop, CatalogueEntry, SteamGame } from "@types"; -import { getSteamAppAsset } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; import { RepacksManager } from "@main/services"; +import { steamUrlBuilder } from "@shared"; export interface SearchGamesArgs { query?: string; @@ -16,7 +16,7 @@ export const convertSteamGameToCatalogueEntry = ( objectID: String(game.id), title: game.name, shop: "steam" as GameShop, - cover: getSteamAppAsset("library", String(game.id)), + cover: steamUrlBuilder.library(String(game.id)), repacks: [], }); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index bea1984c..f507341a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -8,6 +8,7 @@ import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; import "./catalogue/search-games"; import "./catalogue/search-game-repacks"; +import "./catalogue/get-game-stats"; import "./catalogue/get-trending-games"; import "./hardware/get-disk-free-space"; import "./library/add-game-to-library"; @@ -21,6 +22,7 @@ import "./library/open-game-executable-path"; import "./library/open-game-installer"; import "./library/open-game-installer-path"; import "./library/update-executable-path"; +import "./library/verify-executable-path"; import "./library/remove-game"; import "./library/remove-game-from-library"; import "./misc/open-external"; @@ -44,10 +46,12 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; -import "./user/get-user-blocks"; +import "./user/get-blocked-users"; import "./user/block-user"; import "./user/unblock-user"; import "./user/get-user-friends"; +import "./user/get-user-stats"; +import "./user/report-user"; import "./profile/get-friend-requests"; import "./profile/get-me"; import "./profile/undo-friendship"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 1bda0c93..13a7e5e0 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -3,10 +3,11 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; -import { getFileBase64, getSteamAppAsset } from "@main/helpers"; +import { getFileBase64 } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; +import { steamUrlBuilder } from "@shared"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -32,7 +33,7 @@ const addGameToLibrary = async ( }); const iconUrl = steamGame?.clientIcon - ? getSteamAppAsset("icon", objectID, steamGame.clientIcon) + ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) : null; await gameRepository @@ -53,7 +54,7 @@ const addGameToLibrary = async ( const game = await gameRepository.findOne({ where: { objectID } }); - createGame(game!); + createGame(game!).catch(() => {}); }); }; diff --git a/src/main/events/library/verify-executable-path.ts b/src/main/events/library/verify-executable-path.ts new file mode 100644 index 00000000..22295ac7 --- /dev/null +++ b/src/main/events/library/verify-executable-path.ts @@ -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); diff --git a/src/main/events/profile/get-me.ts b/src/main/events/profile/get-me.ts index 1626125b..5154da8d 100644 --- a/src/main/events/profile/get-me.ts +++ b/src/main/events/profile/get-me.ts @@ -1,15 +1,33 @@ import { registerEvent } from "../register-event"; import * as Sentry from "@sentry/electron/main"; -import { HydraApi } from "@main/services"; +import { HydraApi, logger } from "@main/services"; import { UserProfile } from "@types"; import { userAuthRepository } from "@main/repository"; -import { UserNotLoggedInError } from "@shared"; +import { steamUrlBuilder, UserNotLoggedInError } from "@shared"; +import { steamGamesWorker } from "@main/workers"; + +const getSteamGame = async (objectId: string) => { + try { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", + }); + + return { + title: steamGame.name, + iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon), + }; + } catch (err) { + logger.error("Failed to get Steam game", err); + + return null; + } +}; const getMe = async ( _event: Electron.IpcMainInvokeEvent ): Promise => { return HydraApi.get(`/profile/me`) - .then((me) => { + .then(async (me) => { userAuthRepository.upsert( { id: 1, @@ -20,6 +38,17 @@ const getMe = async ( ["id"] ); + if (me.currentGame) { + const steamGame = await getSteamGame(me.currentGame.objectId); + + if (steamGame) { + me.currentGame = { + ...me.currentGame, + ...steamGame, + }; + } + } + Sentry.setUser({ id: me.id, username: me.username }); return me; diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index c3ec1337..eb80bc47 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -2,7 +2,7 @@ import { registerEvent } from "../register-event"; import { HydraApi, PythonInstance } from "@main/services"; import fs from "node:fs"; import path from "node:path"; -import type { UpdateProfileProps, UserProfile } from "@types"; +import type { UpdateProfileRequest, UserProfile } from "@types"; import { omit } from "lodash-es"; import axios from "axios"; @@ -11,7 +11,7 @@ interface PresignedResponse { profileImageUrl: string; } -const patchUserProfile = async (updateProfile: UpdateProfileProps) => { +const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { return HydraApi.patch("/profile", updateProfile); }; @@ -40,7 +40,7 @@ const getNewProfileImageUrl = async (localImageUrl: string) => { const updateProfile = async ( _event: Electron.IpcMainInvokeEvent, - updateProfile: UpdateProfileProps + updateProfile: UpdateProfileRequest ) => { if (!updateProfile.profileImageUrl) { return patchUserProfile(omit(updateProfile, "profileImageUrl")); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index f4db999f..a24e0ffb 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,18 +1,15 @@ -import { - downloadQueueRepository, - gameRepository, - repackRepository, -} from "@main/repository"; - import { registerEvent } from "../register-event"; import type { StartGameDownloadPayload } from "@types"; -import { getFileBase64, getSteamAppAsset } from "@main/helpers"; +import { getFileBase64 } from "@main/helpers"; import { DownloadManager } from "@main/services"; import { Not } from "typeorm"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; +import { steamUrlBuilder } from "@shared"; +import { dataSource } from "@main/data-source"; +import { DownloadQueue, Game, Repack } from "@main/entity"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -21,88 +18,95 @@ const startGameDownload = async ( const { repackId, objectID, title, shop, downloadPath, downloader, uri } = payload; - const [game, repack] = await Promise.all([ - gameRepository.findOne({ + return dataSource.transaction(async (transactionalEntityManager) => { + const gameRepository = transactionalEntityManager.getRepository(Game); + const repackRepository = transactionalEntityManager.getRepository(Repack); + const downloadQueueRepository = + transactionalEntityManager.getRepository(DownloadQueue); + + const [game, repack] = await Promise.all([ + gameRepository.findOne({ + where: { + objectID, + shop, + }, + }), + repackRepository.findOne({ + where: { + id: repackId, + }, + }), + ]); + + if (!repack) return; + + await DownloadManager.pauseDownload(); + + await gameRepository.update( + { status: "active", progress: Not(1) }, + { status: "paused" } + ); + + if (game) { + await gameRepository.update( + { + id: game.id, + }, + { + status: "active", + progress: 0, + bytesDownloaded: 0, + downloadPath, + downloader, + uri, + isDeleted: false, + } + ); + } else { + const steamGame = await steamGamesWorker.run(Number(objectID), { + name: "getById", + }); + + const iconUrl = steamGame?.clientIcon + ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) + : null; + + await gameRepository + .insert({ + title, + iconUrl, + objectID, + downloader, + shop, + status: "active", + downloadPath, + uri, + }) + .then((result) => { + if (iconUrl) { + getFileBase64(iconUrl).then((base64) => + gameRepository.update({ objectID }, { iconUrl: base64 }) + ); + } + + return result; + }); + } + + const updatedGame = await gameRepository.findOne({ where: { objectID, - shop, }, - }), - repackRepository.findOne({ - where: { - id: repackId, - }, - }), - ]); - - if (!repack) return; - - await DownloadManager.pauseDownload(); - - await gameRepository.update( - { status: "active", progress: Not(1) }, - { status: "paused" } - ); - - if (game) { - await gameRepository.update( - { - id: game.id, - }, - { - status: "active", - progress: 0, - bytesDownloaded: 0, - downloadPath, - downloader, - uri, - isDeleted: false, - } - ); - } else { - const steamGame = await steamGamesWorker.run(Number(objectID), { - name: "getById", }); - const iconUrl = steamGame?.clientIcon - ? getSteamAppAsset("icon", objectID, steamGame.clientIcon) - : null; + createGame(updatedGame!).catch(() => {}); - await gameRepository - .insert({ - title, - iconUrl, - objectID, - downloader, - shop, - status: "active", - downloadPath, - uri, - }) - .then((result) => { - if (iconUrl) { - getFileBase64(iconUrl).then((base64) => - gameRepository.update({ objectID }, { iconUrl: base64 }) - ); - } + await DownloadManager.cancelDownload(updatedGame!.id); + await DownloadManager.startDownload(updatedGame!); - return result; - }); - } - - const updatedGame = await gameRepository.findOne({ - where: { - objectID, - }, + await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); + await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); }); - - createGame(updatedGame!); - - await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); - await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); - - await DownloadManager.cancelDownload(updatedGame!.id); - await DownloadManager.startDownload(updatedGame!); }; registerEvent("startGameDownload", startGameDownload); diff --git a/src/main/events/user/get-user-blocks.ts b/src/main/events/user/get-blocked-users.ts similarity index 76% rename from src/main/events/user/get-user-blocks.ts rename to src/main/events/user/get-blocked-users.ts index 65bb3eb4..3d213898 100644 --- a/src/main/events/user/get-user-blocks.ts +++ b/src/main/events/user/get-blocked-users.ts @@ -2,7 +2,7 @@ import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; import { UserBlocks } from "@types"; -export const getUserBlocks = async ( +export const getBlockedUsers = async ( _event: Electron.IpcMainInvokeEvent, take: number, skip: number @@ -10,4 +10,4 @@ export const getUserBlocks = async ( return HydraApi.get(`/profile/blocks`, { take, skip }); }; -registerEvent("getUserBlocks", getUserBlocks); +registerEvent("getBlockedUsers", getBlockedUsers); diff --git a/src/main/events/user/get-user-stats.ts b/src/main/events/user/get-user-stats.ts new file mode 100644 index 00000000..f88a4f12 --- /dev/null +++ b/src/main/events/user/get-user-stats.ts @@ -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 => { + return HydraApi.get(`/users/${userId}/stats`); +}; + +registerEvent("getUserStats", getUserStats); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 68d69969..b8bd7a0a 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -1,76 +1,81 @@ import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; +import { HydraApi, logger } from "@main/services"; import { steamGamesWorker } from "@main/workers"; -import { GameRunning, UserGame, UserProfile } from "@types"; -import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; -import { getSteamAppAsset } from "@main/helpers"; -import { getUserFriends } from "./get-user-friends"; +import type { UserProfile } from "@types"; +import { steamUrlBuilder } from "@shared"; + +const getSteamGame = async (objectId: string) => { + try { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", + }); + + return { + title: steamGame.name, + iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon), + }; + } catch (err) { + logger.error("Failed to get Steam game", err); + + return null; + } +}; const getUser = async ( _event: Electron.IpcMainInvokeEvent, userId: string ): Promise => { try { - const [profile, friends] = await Promise.all([ - HydraApi.get(`/users/${userId}`), - getUserFriends(userId, 12, 0).catch(() => { - return { totalFriends: 0, friends: [] }; - }), - ]); + const profile = await HydraApi.get(`/users/${userId}`); + + if (!profile) return null; const recentGames = await Promise.all( - profile.recentGames.map(async (game) => { - return getSteamUserGame(game); - }) + profile.recentGames + .map(async (game) => { + const steamGame = await getSteamGame(game.objectId); + + return { + ...game, + ...steamGame, + }; + }) + .filter((game) => game) ); const libraryGames = await Promise.all( - profile.libraryGames.map(async (game) => { - return getSteamUserGame(game); - }) + profile.libraryGames + .map(async (game) => { + const steamGame = await getSteamGame(game.objectId); + + return { + ...game, + ...steamGame, + }; + }) + .filter((game) => game) ); - const currentGame = await getGameRunning(profile.currentGame); + if (profile.currentGame) { + const steamGame = await getSteamGame(profile.currentGame.objectId); + + if (steamGame) { + profile.currentGame = { + ...profile.currentGame, + ...steamGame, + }; + } + } return { ...profile, libraryGames, recentGames, - friends: friends.friends, - totalFriends: friends.totalFriends, - currentGame, }; } catch (err) { + console.log(err); return null; } }; -const getGameRunning = async (currentGame): Promise => { - if (!currentGame) { - return null; - } - - const gameRunning = await getSteamUserGame(currentGame); - - return { - ...gameRunning, - sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000, - }; -}; - -const getSteamUserGame = async (game): Promise => { - 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); diff --git a/src/main/events/user/report-user.ts b/src/main/events/user/report-user.ts new file mode 100644 index 00000000..1e8efbaa --- /dev/null +++ b/src/main/events/user/report-user.ts @@ -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 => { + return HydraApi.post(`/users/${userId}/report`, { + reason, + description, + }); +}; + +registerEvent("reportUser", reportUser); diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index b0ff391f..91ce0eb9 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -2,23 +2,6 @@ import axios from "axios"; import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; -export const getSteamAppAsset = ( - category: "library" | "hero" | "logo" | "icon", - objectID: string, - clientIcon?: string -) => { - if (category === "library") - return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`; - - if (category === "hero") - return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`; - - if (category === "logo") - return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`; - - return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`; -}; - export const getFileBuffer = async (url: string) => fetch(url, { method: "GET" }).then((response) => response.arrayBuffer().then((buffer) => Buffer.from(buffer)) @@ -34,15 +17,6 @@ export const getFileBase64 = async (url: string) => }) ); -export const steamUrlBuilder = { - library: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`, - libraryHero: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`, - logo: (objectID: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`, -}; - export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts index 031760f6..9d045e3a 100644 --- a/src/main/knex-client.ts +++ b/src/main/knex-client.ts @@ -2,12 +2,13 @@ import knex, { Knex } from "knex"; import { databasePath } from "./constants"; import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3"; import { RepackUris } from "./migrations/20240830143906_RepackUris"; +import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language"; export type HydraMigration = Knex.Migration & { name: string }; class MigrationSource implements Knex.MigrationSource { getMigrations(): Promise { - return Promise.resolve([Hydra2_0_3, RepackUris]); + return Promise.resolve([Hydra2_0_3, RepackUris, UpdateUserLanguage]); } getMigrationName(migration: HydraMigration): string { return migration.name; diff --git a/src/main/migrations/20240913213944_update_user_language.ts b/src/main/migrations/20240913213944_update_user_language.ts new file mode 100644 index 00000000..3297eb0d --- /dev/null +++ b/src/main/migrations/20240913213944_update_user_language.ts @@ -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) => {}, +}; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index d4733a32..1f9383e1 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -11,6 +11,7 @@ import { GenericHttpDownloader } from "./generic-http-downloader"; export class DownloadManager { private static currentDownloader: Downloader | null = null; + private static downloadingGameId: number | null = null; public static async watchDownloads() { let status: DownloadProgress | null = null; @@ -76,13 +77,14 @@ export class DownloadManager { WindowManager.mainWindow?.setProgressBar(-1); this.currentDownloader = null; + this.downloadingGameId = null; } static async resumeDownload(game: Game) { return this.startDownload(game); } - static async cancelDownload(gameId: number) { + static async cancelDownload(gameId = this.downloadingGameId!) { if (this.currentDownloader === Downloader.Torrent) { PythonInstance.cancelDownload(gameId); } else if (this.currentDownloader === Downloader.RealDebrid) { @@ -93,6 +95,7 @@ export class DownloadManager { WindowManager.mainWindow?.setProgressBar(-1); this.currentDownloader = null; + this.downloadingGameId = null; } static async startDownload(game: Game) { @@ -131,5 +134,6 @@ export class DownloadManager { } this.currentDownloader = game.downloader; + this.downloadingGameId = game.id; } } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index bbf390d0..fffd3f98 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -81,54 +81,54 @@ export class HydraApi { baseURL: import.meta.env.MAIN_VITE_API_URL, }); - this.instance.interceptors.request.use( - (request) => { - logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.params, request.data); - return request; - }, - (error) => { - logger.error("request error", error); - return Promise.reject(error); - } - ); + // this.instance.interceptors.request.use( + // (request) => { + // logger.log(" ---- REQUEST -----"); + // logger.log(request.method, request.url, request.params, request.data); + // return request; + // }, + // (error) => { + // logger.error("request error", error); + // return Promise.reject(error); + // } + // ); - this.instance.interceptors.response.use( - (response) => { - logger.log(" ---- RESPONSE -----"); - logger.log( - response.status, - response.config.method, - response.config.url, - response.data - ); - return response; - }, - (error) => { - logger.error(" ---- RESPONSE ERROR -----"); + // this.instance.interceptors.response.use( + // (response) => { + // logger.log(" ---- RESPONSE -----"); + // logger.log( + // response.status, + // response.config.method, + // response.config.url, + // response.data + // ); + // return response; + // }, + // (error) => { + // logger.error(" ---- RESPONSE ERROR -----"); - const { config } = error; + // const { config } = error; - logger.error( - config.method, - config.baseURL, - config.url, - config.headers, - config.data - ); + // logger.error( + // config.method, + // config.baseURL, + // config.url, + // config.headers, + // config.data + // ); - if (error.response) { - logger.error("Response", error.response.status, error.response.data); - } else if (error.request) { - logger.error("Request", error.request); - } else { - logger.error("Error", error.message); - } + // if (error.response) { + // logger.error("Response", error.response.status, error.response.data); + // } else if (error.request) { + // logger.error("Request", error.request); + // } else { + // logger.error("Error", error.message); + // } - logger.error(" ----- END RESPONSE ERROR -------"); - return Promise.reject(error); - } - ); + // logger.error(" ----- END RESPONSE ERROR -------"); + // return Promise.reject(error); + // } + // ); const userAuth = await userAuthRepository.findOne({ where: { id: 1 }, diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index 6699788c..396ddbdd 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -1,21 +1,31 @@ import { Game } from "@main/entity"; import { HydraApi } from "../hydra-api"; import { gameRepository } from "@main/repository"; +import { logger } from "../logger"; export const createGame = async (game: Game) => { - HydraApi.post(`/profile/games`, { + HydraApi.post( + "/games/download", + { + objectId: game.objectID, + shop: game.shop, + }, + { needsAuth: false } + ).catch((err) => { + logger.error("Failed to create game download", err); + }); + + return HydraApi.post(`/profile/games`, { objectId: game.objectID, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, lastTimePlayed: game.lastTimePlayed, - }) - .then((response) => { - const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; + }).then((response) => { + const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; - gameRepository.update( - { objectID: game.objectID }, - { remoteId, playTimeInMilliseconds, lastTimePlayed } - ); - }) - .catch(() => {}); + gameRepository.update( + { objectID: game.objectID }, + { remoteId, playTimeInMilliseconds, lastTimePlayed } + ); + }); }; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 2b3f51b3..d286ad6c 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -1,7 +1,7 @@ import { gameRepository } from "@main/repository"; import { HydraApi } from "../hydra-api"; import { steamGamesWorker } from "@main/workers"; -import { getSteamAppAsset } from "@main/helpers"; +import { steamUrlBuilder } from "@shared"; export const mergeWithRemoteGames = async () => { return HydraApi.get("/profile/games") @@ -44,7 +44,7 @@ export const mergeWithRemoteGames = async () => { if (steamGame) { const iconUrl = steamGame?.clientIcon - ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) : null; gameRepository.insert({ diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 5cfc4103..28c3bed3 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -6,8 +6,8 @@ export const updateGamePlaytime = async ( deltaInMillis: number, lastTimePlayed: Date ) => { - HydraApi.put(`/profile/games/${game.remoteId}`, { + return HydraApi.put(`/profile/games/${game.remoteId}`, { playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), lastTimePlayed, - }).catch(() => {}); + }); }; diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts index 274ffc91..aa43571d 100644 --- a/src/main/services/notifications.ts +++ b/src/main/services/notifications.ts @@ -81,3 +81,5 @@ export const publishNotificationUpdateReadyToInstall = async ( icon: trayIcon, }).show(); }; + +export const publishNewFriendRequestNotification = async () => {}; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 080f1efc..64b14f4d 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -70,9 +70,9 @@ function onOpenGame(game: Game) { }); if (game.remoteId) { - updateGamePlaytime(game, 0, new Date()); + updateGamePlaytime(game, 0, new Date()).catch(() => {}); } else { - createGame({ ...game, lastTimePlayed: new Date() }); + createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {}); } } @@ -93,20 +93,22 @@ function onTickGame(game: Game) { }); if (currentTick % TICKS_TO_UPDATE_API === 0) { - if (game.remoteId) { - updateGamePlaytime( - game, - now - gamePlaytime.lastSyncTick, - game.lastTimePlayed! - ); - } else { - createGame(game); - } + const gamePromise = game.remoteId + ? updateGamePlaytime( + game, + now - gamePlaytime.lastSyncTick, + game.lastTimePlayed! + ) + : createGame(game); - gamesPlaytime.set(game.id, { - ...gamePlaytime, - lastSyncTick: now, - }); + gamePromise + .then(() => { + gamesPlaytime.set(game.id, { + ...gamePlaytime, + lastSyncTick: now, + }); + }) + .catch(() => {}); } } @@ -119,8 +121,8 @@ const onCloseGame = (game: Game) => { game, performance.now() - gamePlaytime.firstTick, game.lastTimePlayed! - ); + ).catch(() => {}); } else { - createGame(game); + createGame(game).catch(() => {}); } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 28552ae7..8d56073c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,8 +10,9 @@ import type { StartGameDownloadPayload, GameRunning, FriendRequestAction, - UpdateProfileProps, + UpdateProfileRequest, } from "@types"; +import type { CatalogueCategory } from "@shared"; contextBridge.exposeInMainWorld("electron", { /* Torrenting */ @@ -34,7 +35,8 @@ contextBridge.exposeInMainWorld("electron", { /* Catalogue */ searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), - getCatalogue: () => ipcRenderer.invoke("getCatalogue"), + getCatalogue: (category: CatalogueCategory) => + ipcRenderer.invoke("getCatalogue", category), getGameShopDetails: (objectID: string, shop: GameShop, language: string) => ipcRenderer.invoke("getGameShopDetails", objectID, shop, language), getRandomGame: () => ipcRenderer.invoke("getRandomGame"), @@ -44,6 +46,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getGames", take, prevCursor), searchGameRepacks: (query: string) => ipcRenderer.invoke("searchGameRepacks", query), + getGameStats: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("getGameStats", objectId, shop), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), /* User preferences */ @@ -71,6 +75,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("createGameShortcut", id), updateExecutablePath: (id: number, executablePath: string) => ipcRenderer.invoke("updateExecutablePath", id, executablePath), + verifyExecutablePathInUse: (executablePath: string) => + ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), openGameInstaller: (gameId: number) => ipcRenderer.invoke("openGameInstaller", gameId), @@ -139,7 +145,7 @@ contextBridge.exposeInMainWorld("electron", { getMe: () => ipcRenderer.invoke("getMe"), undoFriendship: (userId: string) => ipcRenderer.invoke("undoFriendship", userId), - updateProfile: (updateProfile: UpdateProfileProps) => + updateProfile: (updateProfile: UpdateProfileRequest) => ipcRenderer.invoke("updateProfile", updateProfile), processProfileImage: (imagePath: string) => ipcRenderer.invoke("processProfileImage", imagePath), @@ -155,8 +161,11 @@ contextBridge.exposeInMainWorld("electron", { unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), getUserFriends: (userId: string, take: number, skip: number) => ipcRenderer.invoke("getUserFriends", userId, take, skip), - getUserBlocks: (take: number, skip: number) => - ipcRenderer.invoke("getUserBlocks", take, skip), + getBlockedUsers: (take: number, skip: number) => + ipcRenderer.invoke("getBlockedUsers", take, skip), + getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId), + reportUser: (userId: string, reason: string, description: string) => + ipcRenderer.invoke("reportUser", userId, reason, description), /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), diff --git a/src/renderer/src/app.css.ts b/src/renderer/src/app.css.ts index c829021a..4e0cf7a0 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -1,6 +1,13 @@ -import { ComplexStyleRule, globalStyle, style } from "@vanilla-extract/css"; +import { + ComplexStyleRule, + createContainer, + globalStyle, + style, +} from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "./theme.css"; +export const appContainer = createContainer(); + globalStyle("*", { boxSizing: "border-box", }); @@ -90,6 +97,8 @@ export const container = style({ overflow: "hidden", display: "flex", flexDirection: "column", + containerName: appContainer, + containerType: "inline-size", }); export const content = style({ diff --git a/src/renderer/src/components/button/button.css.ts b/src/renderer/src/components/button/button.css.ts index c730ecfd..51f7509e 100644 --- a/src/renderer/src/components/button/button.css.ts +++ b/src/renderer/src/components/button/button.css.ts @@ -58,11 +58,11 @@ export const button = styleVariants({ danger: [ base, { - border: `solid 1px #a31533`, - backgroundColor: "transparent", - color: "white", + borderColor: "transparent", + backgroundColor: "#a31533", + color: "#c0c1c7", ":hover": { - backgroundColor: "#a31533", + backgroundColor: "#b3203f", }, }, ], diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.css.ts b/src/renderer/src/components/confirmation-modal/confirmation-modal.css.ts new file mode 100644 index 00000000..a9aec403 --- /dev/null +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.css.ts @@ -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", +}); diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx new file mode 100644 index 00000000..31929c60 --- /dev/null +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx @@ -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 { + 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 ( + +
+

{descriptionText}

+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 7df5aa13..7181e9b3 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -1,11 +1,13 @@ -import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react"; -import type { CatalogueEntry } from "@types"; +import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; +import type { CatalogueEntry, GameStats } from "@types"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import * as styles from "./game-card.css"; import { useTranslation } from "react-i18next"; import { Badge } from "../badge/badge"; +import { useCallback, useState } from "react"; +import { useFormat } from "@renderer/hooks"; export interface GameCardProps extends React.DetailedHTMLProps< @@ -22,12 +24,29 @@ const shopIcon = { export function GameCard({ game, ...props }: GameCardProps) { const { t } = useTranslation("game_card"); + const [stats, setStats] = useState(null); + const uniqueRepackers = Array.from( new Set(game.repacks.map(({ repacker }) => repacker)) ); + const handleHover = useCallback(() => { + if (!stats) { + window.electron.getGameStats(game.objectID, game.shop).then((stats) => { + setStats(stats); + }); + } + }, [game, stats]); + + const { numberFormatter } = useFormat(); + return ( - + ); + }, [userDetails, t, receivedRequests, showFriendsModal]); return ( -
+
- {showPendingRequests && ( - - )} + + {friendsButton}
); } diff --git a/src/renderer/src/components/sidebar/sidebar.css.ts b/src/renderer/src/components/sidebar/sidebar.css.ts index 75ac2dd5..10faf795 100644 --- a/src/renderer/src/components/sidebar/sidebar.css.ts +++ b/src/renderer/src/components/sidebar/sidebar.css.ts @@ -21,27 +21,26 @@ export const sidebar = recipe({ pointerEvents: "none", }, }, - }, -}); - -export const content = recipe({ - base: { - display: "flex", - flexDirection: "column", - padding: `${SPACING_UNIT * 2}px`, - gap: `${SPACING_UNIT * 2}px`, - width: "100%", - overflow: "auto", - }, - variants: { - macos: { + darwin: { true: { paddingTop: `${SPACING_UNIT * 6}px`, }, + false: { + paddingTop: `${SPACING_UNIT * 2}px`, + }, }, }, }); +export const content = style({ + display: "flex", + flexDirection: "column", + padding: `${SPACING_UNIT * 2}px`, + gap: `${SPACING_UNIT * 2}px`, + width: "100%", + overflow: "auto", +}); + export const handle = style({ width: "5px", height: "100%", diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index c5b3f3a9..383d2197 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -23,6 +23,8 @@ const SIDEBAR_MAX_WIDTH = 450; const initialSidebarWidth = window.localStorage.getItem("sidebarWidth"); export function Sidebar() { + const filterRef = useRef(null); + const { t } = useTranslation("sidebar"); const { library, updateLibrary } = useLibrary(); const navigate = useNavigate(); @@ -78,6 +80,10 @@ export function Sidebar() { useEffect(() => { setFilteredLibrary(sortedLibrary); + + if (filterRef.current) { + filterRef.current.value = ""; + } }, [sortedLibrary]); useEffect(() => { @@ -139,7 +145,7 @@ export function Sidebar() { navigate(path); } - if (event.detail == 2) { + if (event.detail === 2) { if (game.executablePath) { window.electron.openGame(game.id, game.executablePath); } else { @@ -149,98 +155,93 @@ export function Sidebar() { }; return ( - <> -
- - )} + const handleFocus: React.FocusEventHandler = (event) => { + setIsFocused(true); + if (props.onFocus) props.onFocus(event); + }; + + const handleBlur: React.FocusEventHandler = (event) => { + setIsFocused(false); + if (props.onBlur) props.onBlur(event); + }; + + const hasError = !!error; + + return ( +
+ {label && } + +
+
+ + + {showPasswordToggleButton && ( + + )} +
+ + {rightContent}
- {rightContent} + {hintContent}
+ ); + } +); - {hint && {hint}} - - ); -} +TextField.displayName = "TextField"; diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 63368c88..5835640f 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -9,3 +9,5 @@ export const DOWNLOADER_NAME = { [Downloader.PixelDrain]: "PixelDrain", [Downloader.Qiwi]: "Qiwi", }; + +export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index fd3e4600..9e36a5ea 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -5,10 +5,17 @@ import { setHeaderTitle } from "@renderer/features"; import { getSteamLanguage } from "@renderer/helpers"; import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks"; -import type { Game, GameRepack, GameShop, ShopDetails } from "@types"; +import type { + Game, + GameRepack, + GameShop, + GameStats, + ShopDetails, +} from "@types"; import { useTranslation } from "react-i18next"; import { GameDetailsContext } from "./game-details.context.types"; +import { SteamContentDescriptor } from "@shared"; export const gameDetailsContext = createContext({ game: null, @@ -22,11 +29,14 @@ export const gameDetailsContext = createContext({ gameColor: "", showRepacksModal: false, showGameOptionsModal: false, + stats: null, + hasNSFWContentBlocked: false, setGameColor: () => {}, selectGameExecutable: async () => null, updateGame: async () => {}, setShowGameOptionsModal: () => {}, setShowRepacksModal: () => {}, + setHasNSFWContentBlocked: () => {}, }); const { Provider } = gameDetailsContext; @@ -41,9 +51,12 @@ export function GameDetailsContextProvider({ }: GameDetailsContextProps) { const { objectID, shop } = useParams(); - const [shopDetails, setGameDetails] = useState(null); + const [shopDetails, setShopDetails] = useState(null); const [repacks, setRepacks] = useState([]); const [game, setGame] = useState(null); + const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false); + + const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(false); const [gameColor, setGameColor] = useState(""); @@ -85,13 +98,25 @@ export function GameDetailsContextProvider({ getSteamLanguage(i18n.language) ), window.electron.searchGameRepacks(gameTitle), + window.electron.getGameStats(objectID!, shop as GameShop), ]) - .then(([appDetailsResult, repacksResult]) => { - if (appDetailsResult.status === "fulfilled") - setGameDetails(appDetailsResult.value); + .then(([appDetailsResult, repacksResult, statsResult]) => { + if (appDetailsResult.status === "fulfilled") { + setShopDetails(appDetailsResult.value); + + if ( + appDetailsResult.value!.content_descriptors.ids.includes( + SteamContentDescriptor.AdultOnlySexualContent + ) + ) { + setHasNSFWContentBlocked(true); + } + } if (repacksResult.status === "fulfilled") setRepacks(repacksResult.value); + + if (statsResult.status === "fulfilled") setStats(statsResult.value); }) .finally(() => { setIsLoading(false); @@ -101,7 +126,7 @@ export function GameDetailsContextProvider({ }, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]); useEffect(() => { - setGameDetails(null); + setShopDetails(null); setGame(null); setIsLoading(true); setisGameRunning(false); @@ -167,6 +192,9 @@ export function GameDetailsContextProvider({ gameColor, showGameOptionsModal, showRepacksModal, + stats, + hasNSFWContentBlocked, + setHasNSFWContentBlocked, setGameColor, selectGameExecutable, updateGame, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index 36e55a79..7c3bd20b 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -1,4 +1,10 @@ -import type { Game, GameRepack, GameShop, ShopDetails } from "@types"; +import type { + Game, + GameRepack, + GameShop, + GameStats, + ShopDetails, +} from "@types"; export interface GameDetailsContext { game: Game | null; @@ -12,9 +18,12 @@ export interface GameDetailsContext { gameColor: string; showRepacksModal: boolean; showGameOptionsModal: boolean; + stats: GameStats | null; + hasNSFWContentBlocked: boolean; setGameColor: React.Dispatch>; selectGameExecutable: () => Promise; updateGame: () => Promise; setShowRepacksModal: React.Dispatch>; setShowGameOptionsModal: React.Dispatch>; + setHasNSFWContentBlocked: React.Dispatch>; } diff --git a/src/renderer/src/context/index.ts b/src/renderer/src/context/index.ts index ded6dc3e..d9c1c7e4 100644 --- a/src/renderer/src/context/index.ts +++ b/src/renderer/src/context/index.ts @@ -1,2 +1,3 @@ export * from "./game-details/game-details.context"; export * from "./settings/settings.context"; +export * from "./user-profile/user-profile.context"; diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index dff006fc..f92f41f1 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -1,8 +1,8 @@ -import { createContext, useEffect, useState } from "react"; +import { createContext, useCallback, useEffect, useState } from "react"; import { setUserPreferences } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; -import type { UserPreferences } from "@types"; +import type { UserBlocks, UserPreferences } from "@types"; import { useSearchParams } from "react-router-dom"; export interface SettingsContext { @@ -11,6 +11,8 @@ export interface SettingsContext { clearSourceUrl: () => void; sourceUrl: string | null; currentCategoryIndex: number; + blockedUsers: UserBlocks["blocks"]; + fetchBlockedUsers: () => Promise; } export const settingsContext = createContext({ @@ -19,6 +21,8 @@ export const settingsContext = createContext({ clearSourceUrl: () => {}, sourceUrl: null, currentCategoryIndex: 0, + blockedUsers: [], + fetchBlockedUsers: async () => {}, }); const { Provider } = settingsContext; @@ -35,6 +39,8 @@ export function SettingsContextProvider({ const [sourceUrl, setSourceUrl] = useState(null); const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0); + const [blockedUsers, setBlockedUsers] = useState([]); + const [searchParams] = useSearchParams(); const defaultSourceUrl = searchParams.get("urls"); @@ -48,6 +54,15 @@ export function SettingsContextProvider({ } }, [defaultSourceUrl]); + const fetchBlockedUsers = useCallback(async () => { + const blockedUsers = await window.electron.getBlockedUsers(12, 0); + setBlockedUsers(blockedUsers.blocks); + }, []); + + useEffect(() => { + fetchBlockedUsers(); + }, [fetchBlockedUsers]); + const clearSourceUrl = () => setSourceUrl(null); const updateUserPreferences = async (values: Partial) => { @@ -63,8 +78,10 @@ export function SettingsContextProvider({ updateUserPreferences, setCurrentCategoryIndex, clearSourceUrl, + fetchBlockedUsers, currentCategoryIndex, sourceUrl, + blockedUsers, }} > {children} diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx new file mode 100644 index 00000000..1eb0c47c --- /dev/null +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -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; +} + +export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; + +export const userProfileContext = createContext({ + 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(null); + + const [userProfile, setUserProfile] = useState(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 ( + + {children} + + ); +} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 28bf415e..8e341ed8 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -1,3 +1,4 @@ +import type { CatalogueCategory } from "@shared"; import type { AppUpdaterEvent, CatalogueEntry, @@ -18,7 +19,10 @@ import type { FriendRequestAction, UserFriends, UserBlocks, + UpdateProfileRequest, + GameStats, TrendingGame, + UserStats, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -40,7 +44,7 @@ declare global { /* Catalogue */ searchGames: (query: string) => Promise; - getCatalogue: () => Promise; + getCatalogue: (category: CatalogueCategory) => Promise; getGameShopDetails: ( objectID: string, shop: GameShop, @@ -57,6 +61,7 @@ declare global { prevCursor?: number ) => Promise<{ results: CatalogueEntry[]; cursor: number }>; searchGameRepacks: (query: string) => Promise; + getGameStats: (objectId: string, shop: GameShop) => Promise; getTrendingGames: () => Promise; /* Library */ @@ -67,6 +72,7 @@ declare global { ) => Promise; createGameShortcut: (id: number) => Promise; updateExecutablePath: (id: number, executablePath: string) => Promise; + verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; openGameInstaller: (gameId: number) => Promise; openGameInstallerPath: (gameId: number) => Promise; @@ -138,11 +144,20 @@ declare global { take: number, skip: number ) => Promise; - getUserBlocks: (take: number, skip: number) => Promise; + getBlockedUsers: (take: number, skip: number) => Promise; + getUserStats: (userId: string) => Promise; + reportUser: ( + userId: string, + reason: string, + description: string + ) => Promise; /* Profile */ getMe: () => Promise; undoFriendship: (userId: string) => Promise; + updateProfile: ( + updateProfile: UpdateProfileRequest + ) => Promise; updateProfile: (updateProfile: UpdateProfileProps) => Promise; processProfileImage: ( path: string diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index d559de09..00542020 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -1,9 +1,9 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; -import type { FriendRequest, UserDetails } from "@types"; +import type { FriendRequest, UserProfile } from "@types"; export interface UserDetailsState { - userDetails: UserDetails | null; + userDetails: UserProfile | null; profileBackground: null | string; friendRequests: FriendRequest[]; isFriendsModalVisible: boolean; @@ -24,7 +24,7 @@ export const userDetailsSlice = createSlice({ name: "user-details", initialState, reducers: { - setUserDetails: (state, action: PayloadAction) => { + setUserDetails: (state, action: PayloadAction) => { state.userDetails = action.payload; }, setProfileBackground: (state, action: PayloadAction) => { diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 182aef25..3dea26d3 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,16 +1,6 @@ import type { GameShop } from "@types"; import Color from "color"; -import { average } from "color.js"; - -export const steamUrlBuilder = { - library: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`, - libraryHero: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`, - logo: (objectID: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`, -}; export const formatDownloadProgress = (progress?: number) => { if (!progress) return "0%"; @@ -46,14 +36,3 @@ export const buildGameDetailsPath = ( export const darkenColor = (color: string, amount: number, alpha: number = 1) => new Color(color).darken(amount).alpha(alpha).toString(); - -export const profileBackgroundFromProfileImage = async ( - profileImageUrl: string -) => { - const output = await average(profileImageUrl, { - amount: 1, - format: "hex", - }); - - return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; -}; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 5bc287b8..910e7a3c 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -4,3 +4,4 @@ export * from "./use-date"; export * from "./use-toast"; export * from "./redux"; export * from "./use-user-details"; +export * from "./use-format"; diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index 07c885cf..31c3bf2f 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -25,11 +25,10 @@ export function useDownload() { const startDownload = async (payload: StartGameDownloadPayload) => { dispatch(clearDownload()); - return window.electron.startGameDownload(payload).then((game) => { - updateLibrary(); + const game = await window.electron.startGameDownload(payload); - return game; - }); + await updateLibrary(); + return game; }; const pauseDownload = async (gameId: number) => { diff --git a/src/renderer/src/hooks/use-format.ts b/src/renderer/src/hooks/use-format.ts new file mode 100644 index 00000000..75e3a78b --- /dev/null +++ b/src/renderer/src/hooks/use-format.ts @@ -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 }; +} diff --git a/src/renderer/src/hooks/use-friendship.ts b/src/renderer/src/hooks/use-friendship.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 0cf2a381..a826b008 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -7,10 +7,12 @@ import { setFriendsModalVisible, setFriendsModalHidden, } from "@renderer/features"; -import { profileBackgroundFromProfileImage } from "@renderer/helpers"; -import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types"; +import type { + FriendRequestAction, + UpdateProfileRequest, + UserProfile, +} from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; -import { logger } from "@renderer/logger"; export function useUserDetails() { const dispatch = useAppDispatch(); @@ -38,17 +40,18 @@ export function useUserDetails() { }, [clearUserDetails]); const updateUserDetails = useCallback( - async (userDetails: UserDetails) => { + async (userDetails: UserProfile) => { dispatch(setUserDetails(userDetails)); if (userDetails.profileImageUrl) { - const profileBackground = await profileBackgroundFromProfileImage( - userDetails.profileImageUrl - ).catch((err) => { - logger.error("profileBackgroundFromProfileImage", err); - return `#151515B3`; - }); - dispatch(setProfileBackground(profileBackground)); + // TODO: Decide if we want to use this + // const profileBackground = await profileBackgroundFromProfileImage( + // userDetails.profileImageUrl + // ).catch((err) => { + // logger.error("profileBackgroundFromProfileImage", err); + // return `#151515B3`; + // }); + // dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( "userDetails", @@ -64,7 +67,7 @@ export function useUserDetails() { ); } }, - [dispatch] + [dispatch, profileBackground] ); const fetchUserDetails = useCallback(async () => { @@ -78,14 +81,14 @@ export function useUserDetails() { }, [clearUserDetails]); const patchUser = useCallback( - async (props: UpdateProfileProps) => { - const response = await window.electron.updateProfile(props); + async (values: UpdateProfileRequest) => { + const response = await window.electron.updateProfile(values); return updateUserDetails(response); }, [updateUserDetails] ); - const fetchFriendRequests = useCallback(() => { + const fetchFriendRequests = useCallback(async () => { return window.electron .getFriendRequests() .then((friendRequests) => { @@ -124,13 +127,10 @@ export function useUserDetails() { [fetchFriendRequests] ); - const undoFriendship = (userId: string) => { - return window.electron.undoFriendship(userId); - }; + const undoFriendship = (userId: string) => + window.electron.undoFriendship(userId); - const blockUser = (userId: string) => { - return window.electron.blockUser(userId); - }; + const blockUser = (userId: string) => window.electron.blockUser(userId); const unblockUser = (userId: string) => { return window.electron.unblockUser(userId); diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 2377bf7c..b98d5ed9 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -22,12 +22,12 @@ import { SearchResults, Settings, Catalogue, + Profile, } from "@renderer/pages"; import { store } from "./store"; import resources from "@locales"; -import { User } from "./pages/user/user"; Sentry.init({}); @@ -41,8 +41,14 @@ i18n escapeValue: false, }, }) - .then(() => { - window.electron.updateUserPreferences({ language: i18n.language }); + .then(async () => { + const userPreferences = await window.electron.getUserPreferences(); + + if (userPreferences?.language) { + i18n.changeLanguage(userPreferences.language); + } else { + window.electron.updateUserPreferences({ language: i18n.language }); + } }); ReactDOM.createRoot(document.getElementById("root")!).render( @@ -57,7 +63,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index ae2605ec..29acb9da 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -6,10 +6,9 @@ import { Badge, Button } from "@renderer/components"; import { buildGameDetailsPath, formatDownloadProgress, - steamUrlBuilder, } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, steamUrlBuilder } from "@shared"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useDownload } from "@renderer/hooks"; diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index a4965ca0..2ba19246 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -2,8 +2,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { average } from "color.js"; import Color from "color"; -import { steamUrlBuilder } from "@renderer/helpers"; - import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; @@ -12,6 +10,7 @@ import { Sidebar } from "./sidebar/sidebar"; import * as styles from "./game-details.css"; import { useTranslation } from "react-i18next"; import { gameDetailsContext } from "@renderer/context"; +import { steamUrlBuilder } from "@shared"; const HERO_ANIMATION_THRESHOLD = 25; @@ -22,8 +21,14 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { objectID, shopDetails, game, gameColor, setGameColor } = - useContext(gameDetailsContext); + const { + objectID, + shopDetails, + game, + gameColor, + setGameColor, + hasNSFWContentBlocked, + } = useContext(gameDetailsContext); const [backdropOpactiy, setBackdropOpacity] = useState(1); @@ -65,7 +70,7 @@ export function GameDetailsContent() { }; return ( -
+
{game?.title}
diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx index ab1c1d37..23f0c6f1 100644 --- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx +++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx @@ -43,7 +43,7 @@ export function GameDetailsSkeleton() {
-
+ {/*

HowLongToBeat

    @@ -53,7 +53,7 @@ export function GameDetailsSkeleton() { className={sidebarStyles.howLongToBeatCategorySkeleton} /> ))} -
+ */}
{ + setHasNSFWContentBlocked(false); + navigate(-1); + }; + return ( setShowRepacksModal(false)} /> + setHasNSFWContentBlocked(false)} + clickOutsideToClose={false} + /> + {game && ( ); + const gameActionButton = () => { + if (isGameRunning) { + return ( + + ); + } + + if (game?.executablePath) { + return ( + + ); + } + + return ( + + ); + }; + if (repacks.length && !game) { return ( <> @@ -96,26 +143,7 @@ export function HeroPanelActions() { if (game) { return (
- {isGameRunning ? ( - - ) : ( - - )} + {gameActionButton()}
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 6a0d2f93..7955694b 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -2,19 +2,20 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import * as styles from "./hero-panel.css"; import { formatDownloadProgress } from "@renderer/helpers"; -import { useDate, useDownload } from "@renderer/hooks"; +import { useDate, useDownload, useFormat } from "@renderer/hooks"; import { Link } from "@renderer/components"; import { gameDetailsContext } from "@renderer/context"; - -const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; export function HeroPanelPlaytime() { const [lastTimePlayed, setLastTimePlayed] = useState(""); const { game, isGameRunning } = useContext(gameDetailsContext); - const { i18n, t } = useTranslation("game_details"); + const { t } = useTranslation("game_details"); + + const { numberFormatter } = useFormat(); const { progress, lastPacket } = useDownload(); @@ -30,13 +31,7 @@ export function HeroPanelPlaytime() { } }, [game?.lastTimePlayed, formatDistance]); - const numberFormatter = useMemo(() => { - return new Intl.NumberFormat(i18n.language, { - maximumFractionDigits: 0, - }); - }, [i18n.language]); - - const formatPlayTime = () => { + const formattedPlayTime = useMemo(() => { const milliseconds = game?.playTimeInMilliseconds || 0; const seconds = milliseconds / 1000; const minutes = seconds / 60; @@ -49,7 +44,7 @@ export function HeroPanelPlaytime() { const hours = minutes / 60; return t("amount_hours", { amount: numberFormatter.format(hours) }); - }; + }, [game?.playTimeInMilliseconds, numberFormatter, t]); if (!game) return null; @@ -96,7 +91,7 @@ export function HeroPanelPlaytime() { <>

{t("play_time", { - amount: formatPlayTime(), + amount: formattedPlayTime, })}

diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 3450af24..a301110a 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -8,9 +8,9 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import type { GameRepack } from "@types"; -import { SPACING_UNIT } from "@renderer/theme.css"; +import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector } from "@renderer/hooks"; +import { useAppSelector, useToast } from "@renderer/hooks"; export interface DownloadSettingsModalProps { visible: boolean; @@ -31,6 +31,8 @@ export function DownloadSettingsModal({ }: DownloadSettingsModalProps) { const { t } = useTranslation("game_details"); + const { showErrorToast } = useToast(); + const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); @@ -104,10 +106,16 @@ export function DownloadSettingsModal({ if (repack) { setDownloadStarting(true); - startDownload(repack, selectedDownloader!, selectedPath).finally(() => { - setDownloadStarting(false); - onClose(); - }); + startDownload(repack, selectedDownloader!, selectedPath) + .then(() => { + onClose(); + }) + .catch(() => { + showErrorToast(t("download_error")); + }) + .finally(() => { + setDownloadStarting(false); + }); } }; @@ -121,15 +129,14 @@ export function DownloadSettingsModal({ onClose={onClose} >
-
- - {t("downloader")} - +
+ {t("downloader")}
{downloaders.map((downloader) => ( @@ -152,6 +159,13 @@ export function DownloadSettingsModal({ ))}
+ + {selectedDownloader && selectedDownloader !== Downloader.Torrent && ( +

+ {t("warning")}{" "} + {t("hydra_needs_to_remain_open")} +

+ )}
({ isLoading: true, data: null }); @@ -16,10 +17,13 @@ export function Sidebar() { const [activeRequirement, setActiveRequirement] = useState("minimum"); - const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext); + const { gameTitle, shopDetails, objectID, stats } = + useContext(gameDetailsContext); const { t } = useTranslation("game_details"); + const { numberFormatter } = useFormat(); + useEffect(() => { if (objectID) { setHowLongToBeat({ isLoading: true, data: null }); @@ -37,14 +41,46 @@ export function Sidebar() { return (
-
diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 10c17eca..a363f55d 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -38,11 +38,9 @@ export function SettingsGeneral() { const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); useEffect(() => { - async function fetchdefaultDownloadsPath() { - setDefaultDownloadsPath(await window.electron.getDefaultDownloadsPath()); - } - - fetchdefaultDownloadsPath(); + window.electron.getDefaultDownloadsPath().then((path) => { + setDefaultDownloadsPath(path); + }); setLanguageOptions( orderBy( @@ -89,6 +87,15 @@ export function SettingsGeneral() { function updateFormWithUserPreferences() { if (userPreferences) { + const languageKeys = Object.keys(languageResources); + const language = + languageKeys.find((language) => { + return language === userPreferences.language; + }) ?? + languageKeys.find((language) => { + return language.startsWith(userPreferences.language.split("-")[0]); + }); + setForm((prev) => ({ ...prev, downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath, @@ -96,7 +103,7 @@ export function SettingsGeneral() { userPreferences.downloadNotificationsEnabled, repackUpdatesNotificationsEnabled: userPreferences.repackUpdatesNotificationsEnabled, - language: userPreferences.language, + language: language ?? "en", })); } } @@ -127,28 +134,27 @@ export function SettingsGeneral() { />

{t("notifications")}

- <> - - handleChange({ - downloadNotificationsEnabled: !form.downloadNotificationsEnabled, - }) - } - /> - - handleChange({ - repackUpdatesNotificationsEnabled: - !form.repackUpdatesNotificationsEnabled, - }) - } - /> - + + handleChange({ + downloadNotificationsEnabled: !form.downloadNotificationsEnabled, + }) + } + /> + + + handleChange({ + repackUpdatesNotificationsEnabled: + !form.repackUpdatesNotificationsEnabled, + }) + } + /> ); } diff --git a/src/renderer/src/pages/settings/settings-privacy.css.ts b/src/renderer/src/pages/settings/settings-privacy.css.ts new file mode 100644 index 00000000..2aec8cd0 --- /dev/null +++ b/src/renderer/src/pages/settings/settings-privacy.css.ts @@ -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`, +}); diff --git a/src/renderer/src/pages/settings/settings-privacy.tsx b/src/renderer/src/pages/settings/settings-privacy.tsx new file mode 100644 index 00000000..b93d1d07 --- /dev/null +++ b/src/renderer/src/pages/settings/settings-privacy.tsx @@ -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(); + + 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 ( +
+ { + const handleChange = ( + event: React.ChangeEvent + ) => { + field.onChange(event); + handleSubmit(onSubmit)(); + }; + + return ( + <> + ({ + key: visiblity.value, + value: visiblity.value, + label: visiblity.label, + }))} + disabled={isSubmitting} + /> + + {t("profile_visibility_description")} + + ); + }} + /> + +

+ {t("blocked_users")} +

+ +
    + {blockedUsers.map((user) => { + return ( +
  • +
    + {user.displayName} + {user.displayName} +
    + + +
  • + ); + })} +
+ + ); +} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 2718470f..be7e9597 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -11,16 +11,26 @@ import { SettingsContextConsumer, SettingsContextProvider, } from "@renderer/context"; +import { SettingsPrivacy } from "./settings-privacy"; +import { useUserDetails } from "@renderer/hooks"; +import { useMemo } from "react"; export function Settings() { const { t } = useTranslation("settings"); - const categories = [ - t("general"), - t("behavior"), - t("download_sources"), - "Real-Debrid", - ]; + const { userDetails } = useUserDetails(); + + const categories = useMemo(() => { + const categories = [ + t("general"), + t("behavior"), + t("download_sources"), + "Real-Debrid", + ]; + + if (userDetails) return [...categories, t("privacy")]; + return categories; + }, [userDetails, t]); return ( @@ -39,7 +49,11 @@ export function Settings() { return ; } - return ; + if (currentCategoryIndex === 3) { + return ; + } + + return ; }; return ( diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx index b6e6aaea..91126923 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -42,13 +42,13 @@ export const UserFriendModalAddFriend = ({ const handleClickRequest = (userId: string) => { closeModal(); - navigate(`/user/${userId}`); + navigate(`/profile/${userId}`); }; const handleClickSeeProfile = () => { closeModal(); if (friendCode.length === 8) { - navigate(`/user/${friendCode}`); + navigate(`/profile/${friendCode}`); } }; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx index 8ef96baf..36ff7e14 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -80,7 +80,7 @@ export const UserFriendModalList = ({ const handleClickFriend = (userId: string) => { closeModal(); - navigate(`/user/${userId}`); + navigate(`/profile/${userId}`); }; const handleUndoFriendship = (userId: string) => { diff --git a/src/renderer/src/pages/user/user-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx deleted file mode 100644 index 311eb060..00000000 --- a/src/renderer/src/pages/user/user-block-modal.tsx +++ /dev/null @@ -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 ( - <> - -
-

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

-
- - - -
-
-
- - ); -}; diff --git a/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx b/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx deleted file mode 100644 index cfdb5d06..00000000 --- a/src/renderer/src/pages/user/user-confirm-undo-friendship-modal.tsx +++ /dev/null @@ -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 ( - -
-

{t("undo_friendship_modal_text", { displayName })}

-
- - - -
-
-
- ); -} diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx deleted file mode 100644 index f4a46ccd..00000000 --- a/src/renderer/src/pages/user/user-content.tsx +++ /dev/null @@ -1,581 +0,0 @@ -import { - FriendRequestAction, - GameRunning, - UserGame, - UserProfile, -} from "@types"; -import cn from "classnames"; -import * as styles from "./user.css"; -import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import SteamLogo from "@renderer/assets/steam-logo.svg?react"; -import { - useAppSelector, - useDate, - useToast, - useUserDetails, -} from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { - buildGameDetailsPath, - profileBackgroundFromProfileImage, - steamUrlBuilder, -} from "@renderer/helpers"; -import { - CheckCircleIcon, - PersonIcon, - PlusIcon, - TelescopeIcon, - XCircleIcon, -} from "@primer/octicons-react"; -import { Button, Link } from "@renderer/components"; -import { UserProfileSettingsModal } from "./user-profile-settings-modal"; -import { UserSignOutModal } from "./user-sign-out-modal"; -import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; -import { UserBlockModal } from "./user-block-modal"; -import { UserConfirmUndoFriendshipModal } from "./user-confirm-undo-friendship-modal"; - -const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; - -export interface ProfileContentProps { - userProfile: UserProfile; - updateUserProfile: () => Promise; -} - -type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND"); - -export function UserContent({ - userProfile, - updateUserProfile, -}: ProfileContentProps) { - const { t, i18n } = useTranslation("user_profile"); - const { - userDetails, - profileBackground, - signOut, - sendFriendRequest, - fetchFriendRequests, - showFriendsModal, - updateFriendRequestState, - undoFriendship, - blockUser, - } = useUserDetails(); - const { showSuccessToast, showErrorToast } = useToast(); - - const [profileContentBoxBackground, setProfileContentBoxBackground] = - useState(); - const [showProfileSettingsModal, setShowProfileSettingsModal] = - useState(false); - const [showSignOutModal, setShowSignOutModal] = useState(false); - const [showUserBlockModal, setShowUserBlockModal] = useState(false); - const [showUndoFriendshipModal, setShowUndoFriendshipModal] = useState(false); - const [currentGame, setCurrentGame] = useState(null); - - const { gameRunning } = useAppSelector((state) => state.gameRunning); - - const navigate = useNavigate(); - - const numberFormatter = useMemo(() => { - return new Intl.NumberFormat(i18n.language, { - maximumFractionDigits: 0, - }); - }, [i18n.language]); - - const { formatDistance, formatDiffInMillis } = useDate(); - - const formatPlayTime = () => { - const seconds = userProfile.totalPlayTimeInSeconds; - 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) }); - }; - - const handleGameClick = (game: UserGame) => { - navigate(buildGameDetailsPath(game)); - }; - - const handleEditProfile = () => { - setShowProfileSettingsModal(true); - }; - - const handleOnClickFriend = (userId: string) => { - navigate(`/user/${userId}`); - }; - - const handleConfirmSignout = async () => { - await signOut(); - - showSuccessToast(t("successfully_signed_out")); - - navigate("/"); - }; - - const isMe = userDetails?.id == userProfile.id; - - useEffect(() => { - if (isMe && gameRunning) { - setCurrentGame(gameRunning); - return; - } - - setCurrentGame(userProfile.currentGame); - }, [gameRunning, isMe, userProfile.currentGame]); - - useEffect(() => { - if (isMe) fetchFriendRequests(); - }, [isMe, fetchFriendRequests]); - - useEffect(() => { - if (isMe && profileBackground) { - setProfileContentBoxBackground(profileBackground); - } - - if (userProfile.profileImageUrl) { - profileBackgroundFromProfileImage(userProfile.profileImageUrl).then( - (profileBackground) => { - setProfileContentBoxBackground(profileBackground); - } - ); - } - }, [profileBackground, isMe, userProfile.profileImageUrl]); - - const handleFriendAction = (userId: string, action: FriendAction) => { - try { - if (action === "UNDO") { - undoFriendship(userId).then(updateUserProfile); - return; - } - - if (action === "BLOCK") { - blockUser(userId).then(() => { - setShowUserBlockModal(false); - showSuccessToast(t("user_blocked_successfully")); - navigate(-1); - }); - - return; - } - - if (action === "SEND") { - sendFriendRequest(userProfile.id).then(updateUserProfile); - return; - } - - updateFriendRequestState(userId, action).then(updateUserProfile); - } catch (err) { - showErrorToast(t("try_again")); - } - }; - - const showFriends = isMe || userProfile.totalFriends > 0; - const showProfileContent = - isMe || - userProfile.profileVisibility === "PUBLIC" || - (userProfile.relation?.status === "ACCEPTED" && - userProfile.profileVisibility === "FRIENDS"); - - const getProfileActions = () => { - if (isMe) { - return ( - <> - - - - - ); - } - - if (userProfile.relation == null) { - return ( - <> - - - - - ); - } - - if (userProfile.relation.status === "ACCEPTED") { - return ( - <> - - - ); - } - - if (userProfile.relation.BId === userProfile.id) { - return ( - - ); - } - - return ( - <> - - - - ); - }; - - return ( - <> - setShowProfileSettingsModal(false)} - updateUserProfile={updateUserProfile} - userProfile={userProfile} - /> - - setShowSignOutModal(false)} - onConfirm={handleConfirmSignout} - /> - - setShowUserBlockModal(false)} - onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")} - displayName={userProfile.displayName} - /> - - setShowUndoFriendshipModal(false)} - onConfirm={() => handleFriendAction(userProfile.id, "UNDO")} - displayName={userProfile.displayName} - /> - -
- {currentGame && ( - {currentGame.title} - )} - -
- -
- {userProfile.profileImageUrl ? ( - {userProfile.displayName} - ) : ( - - )} -
- -
-

- {userProfile.displayName} -

- {currentGame && ( -
-
- - {currentGame.title} - -
- - {t("playing_for", { - amount: formatDiffInMillis( - currentGame.sessionDurationInMillis, - new Date() - ), - })} - -
- )} -
- -
-
- {getProfileActions()} -
-
-
- - {showProfileContent && ( -
-
-

{t("activity")}

- - {!userProfile.recentGames.length ? ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- ) : ( -
- {userProfile.recentGames.map((game) => ( - - ))} -
- )} -
- -
-
-
-

{t("library")}

- -
-

- {userProfile.libraryGames.length} -

-
- - {t("total_play_time", { amount: formatPlayTime() })} - -
- {userProfile.libraryGames.map((game) => ( - - ))} -
-
- - {showFriends && ( -
- - -
- {userProfile.friends.map((friend) => { - return ( - - ); - })} - - {isMe && ( - - )} -
-
- )} -
-
- )} - - ); -} diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx deleted file mode 100644 index 896d3684..00000000 --- a/src/renderer/src/pages/user/user-profile-settings-modal/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./user-profile-settings-modal"; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx deleted file mode 100644 index 0790b725..00000000 --- a/src/renderer/src/pages/user/user-profile-settings-modal/user-block-list.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import { UserFriend } from "@types"; -import { useEffect, useRef, useState } from "react"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { useTranslation } from "react-i18next"; -import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; - -const pageSize = 12; - -export const UserEditProfileBlockList = () => { - const { t } = useTranslation("user_profile"); - const { showErrorToast } = useToast(); - - const [page, setPage] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [maxPage, setMaxPage] = useState(0); - const [blocks, setBlocks] = useState([]); - const listContainer = useRef(null); - - const { unblockUser } = useUserDetails(); - - const loadNextPage = () => { - if (page > maxPage) return; - setIsLoading(true); - window.electron - .getUserBlocks(pageSize, page * pageSize) - .then((newPage) => { - if (page === 0) { - setMaxPage(newPage.totalBlocks / pageSize); - } - - setBlocks([...blocks, ...newPage.blocks]); - setPage(page + 1); - }) - .catch(() => {}) - .finally(() => setIsLoading(false)); - }; - - const handleScroll = () => { - const scrollTop = listContainer.current?.scrollTop || 0; - const scrollHeight = listContainer.current?.scrollHeight || 0; - const clientHeight = listContainer.current?.clientHeight || 0; - const maxScrollTop = scrollHeight - clientHeight; - - if (scrollTop < maxScrollTop * 0.9 || isLoading) { - return; - } - - loadNextPage(); - }; - - useEffect(() => { - const container = listContainer.current; - container?.addEventListener("scroll", handleScroll); - return () => container?.removeEventListener("scroll", handleScroll); - }, [isLoading]); - - const reloadList = () => { - setPage(0); - setMaxPage(0); - setBlocks([]); - loadNextPage(); - }; - - useEffect(() => { - reloadList(); - }, []); - - const handleUnblock = (userId: string) => { - unblockUser(userId) - .then(() => { - reloadList(); - }) - .catch(() => { - showErrorToast(t("try_again")); - }); - }; - - return ( - -
- {!isLoading && blocks.length === 0 &&

{t("no_blocked_users")}

} - {blocks.map((friend) => { - return ( - - ); - })} - {isLoading && ( - - )} -
-
- ); -}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx deleted file mode 100644 index e9177383..00000000 --- a/src/renderer/src/pages/user/user-profile-settings-modal/user-edit-profile.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; -import { Button, SelectField, TextField } from "@renderer/components"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { UserProfile } from "@types"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import * as styles from "../user.css"; -import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; - -export interface UserEditProfileProps { - userProfile: UserProfile; - updateUserProfile: () => Promise; -} - -export const UserEditProfile = ({ - userProfile, - updateUserProfile, -}: UserEditProfileProps) => { - const { t } = useTranslation("user_profile"); - - const [form, setForm] = useState({ - displayName: userProfile.displayName, - profileVisibility: userProfile.profileVisibility, - profileImageUrl: null as string | null, - }); - const [isSaving, setIsSaving] = useState(false); - const [isLoadingImage, setIsLoadingImage] = useState(false); - - const { patchUser } = useUserDetails(); - - const { showSuccessToast, showErrorToast } = useToast(); - - const [profileVisibilityOptions, setProfileVisibilityOptions] = useState< - { value: string; label: string }[] - >([]); - - useEffect(() => { - setProfileVisibilityOptions([ - { value: "PUBLIC", label: t("public") }, - { value: "FRIENDS", label: t("friends_only") }, - { value: "PRIVATE", label: t("private") }, - ]); - }, [t]); - - const handleChangeProfileAvatar = async () => { - const { filePaths } = await window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { - name: "Image", - extensions: ["jpg", "jpeg", "png", "webp"], - }, - ], - }); - - if (filePaths && filePaths.length > 0) { - setIsLoadingImage(true); - - const { imagePath } = await window.electron - .processProfileImage(filePaths[0]) - .catch(() => { - showErrorToast(t("image_process_failure")); - return { imagePath: null }; - }) - .finally(() => setIsLoadingImage(false)); - - setForm({ ...form, profileImageUrl: imagePath }); - } - }; - - const handleProfileVisibilityChange = (event) => { - setForm({ - ...form, - profileVisibility: event.target.value, - }); - }; - - const handleSaveProfile: React.FormEventHandler = async ( - event - ) => { - event.preventDefault(); - setIsSaving(true); - - patchUser(form) - .then(async () => { - await updateUserProfile(); - showSuccessToast(t("saved_successfully")); - }) - .catch(() => { - showErrorToast(t("try_again")); - }) - .finally(() => { - setIsSaving(false); - }); - }; - - const profileImageUrl = useMemo(() => { - if (form.profileImageUrl) return `local:${form.profileImageUrl}`; - if (userProfile.profileImageUrl) return userProfile.profileImageUrl; - return null; - }, [form, userProfile]); - - const profileImageContent = () => { - if (isLoadingImage) { - return ; - } - - if (profileImageUrl) { - return ( - {userProfile.displayName} - ); - } - - return ; - }; - - return ( - -
- - - setForm({ ...form, displayName: e.target.value })} - /> - - ({ - key: visiblity.value, - value: visiblity.value, - label: visiblity.label, - }))} - /> - - - -
- ); -}; diff --git a/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx b/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx deleted file mode 100644 index d71b1bd7..00000000 --- a/src/renderer/src/pages/user/user-profile-settings-modal/user-profile-settings-modal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Button, Modal } from "@renderer/components"; -import { UserProfile } from "@types"; -import { SPACING_UNIT } from "@renderer/theme.css"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { UserEditProfile } from "./user-edit-profile"; -import { UserEditProfileBlockList } from "./user-block-list"; - -export interface UserProfileSettingsModalProps { - userProfile: UserProfile; - visible: boolean; - onClose: () => void; - updateUserProfile: () => Promise; -} - -export const UserProfileSettingsModal = ({ - userProfile, - visible, - onClose, - updateUserProfile, -}: UserProfileSettingsModalProps) => { - const { t } = useTranslation("user_profile"); - - const tabs = [t("edit_profile"), t("blocked_users")]; - - const [currentTabIndex, setCurrentTabIndex] = useState(0); - - const renderTab = () => { - if (currentTabIndex == 0) { - return ( - - ); - } - - if (currentTabIndex == 1) { - return ; - } - - return <>; - }; - - return ( - <> - -
-
- {tabs.map((tab, index) => { - return ( - - ); - })} -
- {renderTab()} -
-
- - ); -}; diff --git a/src/renderer/src/pages/user/user-sign-out-modal.tsx b/src/renderer/src/pages/user/user-sign-out-modal.tsx deleted file mode 100644 index afc5561b..00000000 --- a/src/renderer/src/pages/user/user-sign-out-modal.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button, Modal } from "@renderer/components"; -import * as styles from "./user.css"; -import { useTranslation } from "react-i18next"; - -export interface UserSignOutModalProps { - visible: boolean; - onConfirm: () => void; - onClose: () => void; -} - -export const UserSignOutModal = ({ - visible, - onConfirm, - onClose, -}: UserSignOutModalProps) => { - const { t } = useTranslation("user_profile"); - - return ( - <> - -
-

{t("sign_out_modal_text")}

-
- - - -
-
-
- - ); -}; diff --git a/src/renderer/src/pages/user/user-skeleton.tsx b/src/renderer/src/pages/user/user-skeleton.tsx deleted file mode 100644 index dc23fb0e..00000000 --- a/src/renderer/src/pages/user/user-skeleton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Skeleton from "react-loading-skeleton"; -import cn from "classnames"; -import * as styles from "./user.css"; -import { SPACING_UNIT } from "@renderer/theme.css"; -import { useTranslation } from "react-i18next"; - -export const UserSkeleton = () => { - const { t } = useTranslation("user_profile"); - return ( - <> - -
-
-

{t("activity")}

- {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
- -
-

{t("library")}

-
- {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
-
-
- - ); -}; diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts deleted file mode 100644 index 6bcb30b0..00000000 --- a/src/renderer/src/pages/user/user.css.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { SPACING_UNIT, vars } from "../../theme.css"; -import { style } from "@vanilla-extract/css"; - -export const wrapper = style({ - padding: "24px", - width: "100%", - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 3}px`, -}); - -export const profileContentBox = style({ - display: "flex", - cursor: "pointer", - gap: `${SPACING_UNIT * 3}px`, - alignItems: "center", - borderRadius: "4px", - border: `solid 1px ${vars.color.border}`, - width: "100%", - boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)", - transition: "all ease 0.3s", -}); - -export const profileAvatarContainer = style({ - width: "96px", - minWidth: "96px", - height: "96px", - borderRadius: "50%", - display: "flex", - justifyContent: "center", - alignItems: "center", - backgroundColor: vars.color.background, - position: "relative", - overflow: "hidden", - border: `solid 1px ${vars.color.border}`, - boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", - zIndex: 1, -}); - -export const friendAvatarContainer = style({ - width: "35px", - minWidth: "35px", - height: "35px", - borderRadius: "50%", - display: "flex", - justifyContent: "center", - alignItems: "center", - backgroundColor: vars.color.background, - overflow: "hidden", - border: `solid 1px ${vars.color.border}`, - boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", -}); - -export const friendListDisplayName = style({ - fontWeight: "bold", - fontSize: vars.size.body, - textAlign: "left", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -export const profileAvatarEditContainer = style({ - alignSelf: "center", - width: "128px", - height: "128px", - display: "flex", - borderRadius: "50%", - 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: "50%", - overflow: "hidden", -}); - -export const profileAvatarEditOverlay = style({ - position: "absolute", - width: "100%", - height: "100%", - backgroundColor: "#00000055", - color: vars.color.muted, - zIndex: 1, - cursor: "pointer", -}); - -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%", -}); - -export const profileContent = style({ - display: "flex", - height: "100%", - flexDirection: "row", - gap: `${SPACING_UNIT * 4}px`, -}); - -export const profileGameSection = style({ - width: "100%", - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 2}px`, -}); - -export const friendsSection = style({ - width: "100%", - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 2}px`, -}); - -export const friendsSectionHeader = style({ - fontSize: vars.size.body, - color: vars.color.body, - cursor: "pointer", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: `${SPACING_UNIT * 2}px`, - ":hover": { - color: vars.color.muted, - }, -}); - -export const contentSidebar = style({ - width: "100%", - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 3}px`, - "@media": { - "(min-width: 768px)": { - width: "100%", - maxWidth: "150px", - }, - "(min-width: 1024px)": { - maxWidth: "250px", - width: "100%", - }, - }, -}); - -export const feedGameIcon = style({ - height: "100%", -}); - -export const libraryGameIcon = style({ - width: "100%", - height: "100%", - borderRadius: "4px", -}); - -export const friendProfileIcon = style({ - height: "100%", -}); - -export const feedItem = style({ - color: vars.color.body, - display: "flex", - flexDirection: "row", - gap: `${SPACING_UNIT * 2}px`, - width: "100%", - overflow: "hidden", - height: "72px", - transition: "all ease 0.2s", - cursor: "pointer", - zIndex: "1", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const gameListItem = style({ - color: vars.color.body, - transition: "all ease 0.2s", - cursor: "pointer", - zIndex: "1", - overflow: "hidden", - padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`, - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const friendListContainer = style({ - color: vars.color.body, - width: "100%", - height: "54px", - padding: `0 ${SPACING_UNIT}px`, - gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, - transition: "all ease 0.2s", - position: "relative", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const gameInformation = style({ - display: "flex", - flexDirection: "column", - alignItems: "flex-start", - gap: `${SPACING_UNIT / 2}px`, -}); - -export const profileHeaderSkeleton = style({ - height: "144px", -}); - -export const editProfileImageBadge = style({ - width: "28px", - height: "28px", - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - color: vars.color.background, - backgroundColor: vars.color.muted, - position: "absolute", - bottom: "0px", - right: "0px", - zIndex: "1", -}); - -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 noDownloads = style({ - display: "flex", - width: "100%", - height: "100%", - justifyContent: "center", - alignItems: "center", - flexDirection: "column", - gap: `${SPACING_UNIT}px`, -}); - -export const signOutModalContent = style({ - display: "flex", - width: "100%", - flexDirection: "column", - gap: `${SPACING_UNIT}px`, -}); - -export const signOutModalButtonsContainer = style({ - display: "flex", - width: "100%", - justifyContent: "end", - alignItems: "center", - gap: `${SPACING_UNIT}px`, - paddingTop: `${SPACING_UNIT}px`, -}); - -export const profileBackground = style({ - width: "100%", - height: "100%", - position: "absolute", - objectFit: "cover", - left: "0", - top: "0", - borderRadius: "4px", -}); - -export const cancelRequestButton = style({ - cursor: "pointer", - color: vars.color.body, - ":hover": { - color: vars.color.danger, - }, -}); - -export const acceptRequestButton = style({ - cursor: "pointer", - color: vars.color.success, -}); diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx deleted file mode 100644 index 565d412a..00000000 --- a/src/renderer/src/pages/user/user.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { UserProfile } from "@types"; -import { useCallback, useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { setHeaderTitle } from "@renderer/features"; -import { useAppDispatch, useToast } from "@renderer/hooks"; -import { UserSkeleton } from "./user-skeleton"; -import { UserContent } from "./user-content"; -import { SkeletonTheme } from "react-loading-skeleton"; -import { vars } from "@renderer/theme.css"; -import * as styles from "./user.css"; -import { useTranslation } from "react-i18next"; - -export const User = () => { - const { userId } = useParams(); - const [userProfile, setUserProfile] = useState(); - const navigate = useNavigate(); - - const { t } = useTranslation("user_profile"); - - const { showErrorToast } = useToast(); - - const dispatch = useAppDispatch(); - - const getUserProfile = useCallback(() => { - return window.electron.getUser(userId!).then((userProfile) => { - if (userProfile) { - dispatch(setHeaderTitle(userProfile.displayName)); - setUserProfile(userProfile); - } else { - showErrorToast(t("user_not_found")); - navigate(-1); - } - }); - }, [dispatch, navigate, showErrorToast, userId, t]); - - useEffect(() => { - getUserProfile(); - }, [getUserProfile]); - - return ( - -
- {userProfile ? ( - - ) : ( - - )} -
-
- ); -}; diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 00000000..896d2ede --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,25 @@ +export enum Downloader { + RealDebrid, + Torrent, + Gofile, + PixelDrain, + Qiwi, +} + +export enum DownloadSourceStatus { + UpToDate, + Errored, +} + +export enum CatalogueCategory { + Hot = "hot", + Weekly = "weekly", +} + +export enum SteamContentDescriptor { + SomeNudityOrSexualContent = 1, + FrequenceViolenceOrGore = 2, + AdultOnlySexualContent = 3, + FrequentNudityOrSexualContent = 4, + GeneralMatureContent = 5, +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 28e7315b..556000f2 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,15 +1,6 @@ -export enum Downloader { - RealDebrid, - Torrent, - Gofile, - PixelDrain, - Qiwi, -} +import { Downloader } from "./constants"; -export enum DownloadSourceStatus { - UpToDate, - Errored, -} +export * from "./constants"; export class UserNotLoggedInError extends Error { constructor() { @@ -98,3 +89,16 @@ export const getDownloadersForUris = (uris: string[]) => { return Array.from(downloadersSet); }; + +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`, + cover: (objectID: string) => + `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/library_600x900.jpg`, + icon: (objectID: string, clientIcon: string) => + `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 8a1708c6..34bfa8b6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import type { DownloadSourceStatus, Downloader } from "@shared"; +import type { SteamAppDetails } from "./steam.types"; export type GameStatus = | "active" @@ -12,58 +13,6 @@ export type GameShop = "steam" | "epic"; export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; -export interface SteamGenre { - id: string; - name: string; -} - -export interface SteamScreenshot { - id: number; - path_thumbnail: string; - path_full: string; -} - -export interface SteamVideoSource { - max: string; - "480": string; -} - -export interface SteamMovies { - id: number; - mp4: SteamVideoSource; - webm: SteamVideoSource; - thumbnail: string; - name: string; - highlight: boolean; -} - -export interface SteamAppDetails { - name: string; - detailed_description: string; - about_the_game: string; - short_description: string; - publishers: string[]; - genres: SteamGenre[]; - movies?: SteamMovies[]; - screenshots?: SteamScreenshot[]; - pc_requirements: { - minimum: string; - recommended: string; - }; - mac_requirements: { - minimum: string; - recommended: string; - }; - linux_requirements: { - minimum: string; - recommended: string; - }; - release_date: { - coming_soon: boolean; - date: string; - }; -} - export interface GameRepack { id: number; title: string; @@ -99,7 +48,7 @@ export interface CatalogueEntry { } export interface UserGame { - objectID: string; + objectId: string; shop: GameShop; title: string; iconUrl: string | null; @@ -203,83 +152,12 @@ export interface StartGameDownloadPayload { downloader: Downloader; } -export interface RealDebridUnrestrictLink { - id: string; - filename: string; - mimeType: string; - filesize: number; - link: string; - host: string; - host_icon: string; - chunks: number; - crc: number; - download: string; - streamable: number; -} - -export interface RealDebridAddMagnet { - id: string; - // URL of the created ressource - uri: string; -} - -export interface RealDebridTorrentInfo { - id: string; - filename: string; - original_filename: string; - hash: string; - bytes: number; - original_bytes: number; - host: string; - split: number; - progress: number; - status: - | "magnet_error" - | "magnet_conversion" - | "waiting_files_selection" - | "queued" - | "downloading" - | "downloaded" - | "error" - | "virus" - | "compressing" - | "uploading" - | "dead"; - added: string; - files: { - id: number; - path: string; - bytes: number; - selected: number; - }[]; - links: string[]; - ended: string; - speed: number; - seeders: number; -} - -export interface RealDebridUser { - id: number; - username: string; - email: string; - points: number; - locale: string; - avatar: string; - type: string; - premium: number; - expiration: string; -} - -export interface UserDetails { - id: string; - displayName: string; - profileImageUrl: string | null; -} - export interface UserFriend { id: string; displayName: string; profileImageUrl: string | null; + createdAt: string; + updatedAt: string; } export interface UserFriends { @@ -307,6 +185,11 @@ export interface UserRelation { updatedAt: string; } +export interface UserProfileCurrentGame extends Omit { + objectId: string; + sessionDurationInSeconds: number; +} + export interface UserProfile { id: string; displayName: string; @@ -318,10 +201,10 @@ export interface UserProfile { friends: UserFriend[]; totalFriends: number; relation: UserRelation | null; - currentGame: GameRunning | null; + currentGame: UserProfileCurrentGame | null; } -export interface UpdateProfileProps { +export interface UpdateProfileRequest { displayName?: string; profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS"; profileImageUrl?: string | null; @@ -340,8 +223,22 @@ export interface DownloadSource { updatedAt: Date; } +export interface GameStats { + downloadCount: number; + playerCount: number; +} + export interface TrendingGame { uri: string; description: string; background: string; + logo: string | null; } + +export interface UserStats { + libraryCount: number; + friendsCount: number; +} + +export * from "./steam.types"; +export * from "./real-debrid.types"; diff --git a/src/types/real-debrid.types.ts b/src/types/real-debrid.types.ts new file mode 100644 index 00000000..6b16ecfd --- /dev/null +++ b/src/types/real-debrid.types.ts @@ -0,0 +1,66 @@ +export interface RealDebridUnrestrictLink { + id: string; + filename: string; + mimeType: string; + filesize: number; + link: string; + host: string; + host_icon: string; + chunks: number; + crc: number; + download: string; + streamable: number; +} + +export interface RealDebridAddMagnet { + id: string; + // URL of the created resource + uri: string; +} + +export interface RealDebridTorrentInfo { + id: string; + filename: string; + original_filename: string; + hash: string; + bytes: number; + original_bytes: number; + host: string; + split: number; + progress: number; + status: + | "magnet_error" + | "magnet_conversion" + | "waiting_files_selection" + | "queued" + | "downloading" + | "downloaded" + | "error" + | "virus" + | "compressing" + | "uploading" + | "dead"; + added: string; + files: { + id: number; + path: string; + bytes: number; + selected: number; + }[]; + links: string[]; + ended: string; + speed: number; + seeders: number; +} + +export interface RealDebridUser { + id: number; + username: string; + email: string; + points: number; + locale: string; + avatar: string; + type: string; + premium: number; + expiration: string; +} diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts new file mode 100644 index 00000000..0792664e --- /dev/null +++ b/src/types/steam.types.ts @@ -0,0 +1,54 @@ +export interface SteamGenre { + id: string; + name: string; +} + +export interface SteamScreenshot { + id: number; + path_thumbnail: string; + path_full: string; +} + +export interface SteamVideoSource { + max: string; + "480": string; +} + +export interface SteamMovies { + id: number; + mp4: SteamVideoSource; + webm: SteamVideoSource; + thumbnail: string; + name: string; + highlight: boolean; +} + +export interface SteamAppDetails { + name: string; + detailed_description: string; + about_the_game: string; + short_description: string; + publishers: string[]; + genres: SteamGenre[]; + movies?: SteamMovies[]; + screenshots?: SteamScreenshot[]; + pc_requirements: { + minimum: string; + recommended: string; + }; + mac_requirements: { + minimum: string; + recommended: string; + }; + linux_requirements: { + minimum: string; + recommended: string; + }; + release_date: { + coming_soon: boolean; + date: string; + }; + content_descriptors: { + ids: number[]; + }; +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 839413a1..0f99c8ce 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,6 +1,6 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/index.ts"], + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*"], "compilerOptions": { "module": "ESNext", "composite": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 91b111a9..ca29bd89 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -6,7 +6,7 @@ "src/renderer/src/**/*.tsx", "src/preload/*.d.ts", "src/locales/index.ts", - "src/shared/index.ts" + "src/shared/**/*" ], "compilerOptions": { "composite": true, diff --git a/yarn.lock b/yarn.lock index 9c846438..9aa73bd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -955,6 +955,11 @@ resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.0.22.tgz#2c5249347ba84fef16e71a58e0ec01b460174093" integrity sha512-PwjvKPGFbgpwfKjWZj1zeUvd7ExUW2AqHE9PF9ysAJ2gOuzIHWE6mEVIlchYif7WC2pQhn+g0w6xooCObVi+4A== +"@hookform/resolvers@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.0.tgz#cf540ac21c6c0cd24a40cf53d8e6d64391fb753d" + integrity sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg== + "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz" @@ -6421,6 +6426,11 @@ prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" + integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" @@ -6477,6 +6487,11 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-hook-form@^7.53.0: + version "7.53.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.0.tgz#3cf70951bf41fa95207b34486203ebefbd3a05ab" + integrity sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ== + react-i18next@^14.1.0: version "14.1.1" resolved "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz" @@ -7245,6 +7260,11 @@ tildify@2.0.0: resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tiny-typed-emitter@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz" @@ -7292,6 +7312,11 @@ token-types@^5.0.1: "@tokenizer/token" "^0.3.0" ieee754 "^1.2.1" +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + tough-cookie@^4.0.0, tough-cookie@^4.1.3: version "4.1.4" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz" @@ -7374,6 +7399,11 @@ type-fest@^0.20.2: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz" @@ -7869,6 +7899,16 @@ yocto-queue@^1.0.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yup@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.4.0.tgz#898dcd660f9fb97c41f181839d3d65c3ee15a43e" + integrity sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0" + zod@^3.23.8: version "3.23.8" resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz"